From 0431adf3061388b5b984cbcbbf336a09bab85be3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 03:22:45 +0700 Subject: [PATCH] fix(targets): close all remaining multi-target droid edge cases --- docs/system-architecture/target-adapters.md | 72 ++-- src/auth/profile-detector.ts | 5 + src/ccs.ts | 325 +++++++++++++----- src/cliproxy/session-tracker.ts | 152 +++++--- src/targets/claude-adapter.ts | 61 +++- src/targets/droid-adapter.ts | 91 ++++- src/targets/droid-config-manager.ts | 210 ++++++++--- src/targets/droid-detector.ts | 48 ++- src/targets/target-adapter.ts | 6 +- src/targets/target-resolver.ts | 84 ++++- src/utils/shell-executor.ts | 55 ++- src/web-server/jsonl-parser.ts | 6 + src/web-server/usage/data-aggregator.ts | 12 +- src/web-server/usage/handlers.ts | 1 + .../cliproxy/session-tracker-target.test.ts | 56 +++ tests/unit/data-aggregator.test.ts | 36 ++ tests/unit/jsonl-parser.test.ts | 20 ++ .../unit/targets/droid-config-manager.test.ts | 103 ++++++ tests/unit/targets/droid-detector.test.ts | 53 +++ tests/unit/targets/target-registry.test.ts | 52 ++- tests/unit/targets/target-resolver.test.ts | 74 +++- 21 files changed, 1257 insertions(+), 265 deletions(-) create mode 100644 tests/unit/cliproxy/session-tracker-target.test.ts create mode 100644 tests/unit/targets/droid-detector.test.ts diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 1edb7704..51488b3c 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -95,16 +95,11 @@ export function resolveTargetType( args: string[], profileConfig?: { target?: TargetType } ): TargetType { - // 1. Check --target flag - const targetIdx = args.indexOf('--target'); - if (targetIdx !== -1 && args[targetIdx + 1]) { - const flagValue = args[targetIdx + 1]; - if (VALID_TARGETS.has(flagValue)) { - return flagValue as TargetType; - } - // Invalid target → error - console.error(`[X] Unknown target "${flagValue}". Available: claude, droid`); - process.exit(1); + // 1. Parse --target flags (supports --target value and --target=value) + // Repeated flags: last one wins. + const parsed = parseTargetFlags(args); + if (parsed.targetOverride) { + return parsed.targetOverride; } // 2. Check profile config @@ -113,7 +108,7 @@ export function resolveTargetType( } // 3. Check argv[0] (binary name) - const binName = path.basename(process.argv[1] || '').replace(/\.(cmd|bat)$/i, ''); + const binName = path.basename(process.argv[1] || process.argv0 || '').replace(/\.(cmd|bat|ps1|exe)$/i, ''); if (ARGV0_TARGET_MAP[binName]) { return ARGV0_TARGET_MAP[binName]; } @@ -194,8 +189,14 @@ export class ClaudeAdapter implements TargetAdapter { } // Handle process termination - process.on('SIGINT', () => child.kill('SIGINT')); - process.on('SIGTERM', () => child.kill('SIGTERM')); + const onSigInt = () => child.kill('SIGINT'); + const onSigTerm = () => child.kill('SIGTERM'); + process.once('SIGINT', onSigInt); + process.once('SIGTERM', onSigTerm); + child.on('exit', () => { + process.removeListener('SIGINT', onSigInt); + process.removeListener('SIGTERM', onSigTerm); + }); } supportsProfileType(profileType: string): boolean { @@ -253,12 +254,10 @@ export class DroidAdapter implements TargetAdapter { } async prepareCredentials(creds: TargetCredentials): Promise { - const profile = creds.envVars?.['CCS_PROFILE_NAME'] || 'default'; - // Write custom model entry to ~/.factory/settings.json - await upsertCcsModel(profile, { + await upsertCcsModel(creds.profile, { model: creds.model || 'claude-opus-4-6', - displayName: `CCS ${profile}`, + displayName: `CCS ${creds.profile}`, baseUrl: creds.baseUrl, apiKey: creds.apiKey, provider: creds.provider || 'anthropic', @@ -296,13 +295,19 @@ export class DroidAdapter implements TargetAdapter { } // Handle process termination - process.on('SIGINT', () => child.kill('SIGINT')); - process.on('SIGTERM', () => child.kill('SIGTERM')); + const onSigInt = () => child.kill('SIGINT'); + const onSigTerm = () => child.kill('SIGTERM'); + process.once('SIGINT', onSigInt); + process.once('SIGTERM', onSigTerm); + child.on('exit', () => { + process.removeListener('SIGINT', onSigInt); + process.removeListener('SIGTERM', onSigTerm); + }); } supportsProfileType(profileType: string): boolean { - // Droid supports all profile types (like Claude) - return true; + // Droid currently supports direct settings/default paths only + return profileType === 'settings' || profileType === 'default'; } } ``` @@ -313,22 +318,22 @@ export class DroidAdapter implements TargetAdapter { ```json { - "customModels": { - "ccs-gemini": { + "customModels": [ + { "model": "claude-opus-4-6", "displayName": "CCS gemini", "baseUrl": "https://generativelanguage.googleapis.com/v1beta/openai/", "apiKey": "AIza...", "provider": "openai" }, - "ccs-glm": { + { "model": "glm-4", "displayName": "CCS glm", "baseUrl": "https://open.bigmodel.cn/api/paas/v4/", "apiKey": "your-glm-key", "provider": "openai" } - } + ] } ``` @@ -349,7 +354,7 @@ ccs --target droid glm ### Binary Alias Pattern ```bash -# Create symlink to auto-select droid target +# Create alias/symlink to auto-select droid target ln -s /path/to/ccs /path/to/ccsd # Usage @@ -358,6 +363,8 @@ ccsd glm → droid -m custom:ccs-glm "args..." ``` +On Windows, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized. + --- ## Registry and Lookup @@ -552,8 +559,15 @@ export function escapeShellArg(arg: string): string { Both adapters propagate signals from parent to child: ```typescript -process.on('SIGINT', () => child.kill('SIGINT')); -process.on('SIGTERM', () => child.kill('SIGTERM')); +const onSigInt = () => child.kill('SIGINT'); +const onSigTerm = () => child.kill('SIGTERM'); +process.once('SIGINT', onSigInt); +process.once('SIGTERM', onSigTerm); + +child.on('exit', () => { + process.removeListener('SIGINT', onSigInt); + process.removeListener('SIGTERM', onSigTerm); +}); ``` This ensures CTRL+C and graceful shutdowns work correctly. @@ -578,7 +592,7 @@ describe('ClaudeAdapter', () => { baseUrl: 'https://api.anthropic.com', apiKey: 'sk-ant-...', model: 'claude-opus-4-6', - }, 'clipproxy'); + }, 'cliproxy'); expect(env['ANTHROPIC_AUTH_TOKEN']).toBe('sk-ant-...'); }); diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 0d10bad3..064f9360 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -24,6 +24,7 @@ import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loade import { getCcsDir } from '../utils/config-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import type { TargetType } from '../targets/target-adapter'; export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; @@ -34,6 +35,7 @@ export type CLIProxyProfileName = CLIProxyProvider; export interface ProfileDetectionResult { type: ProfileType; name: string; + target?: TargetType; settingsPath?: string; profile?: Settings | ProfileMetadata; message?: string; @@ -143,6 +145,7 @@ class ProfileDetector { return { type: 'cliproxy', name: profileName, + target: composite.target, provider: defaultTierConfig.provider as CLIProxyProfileName, settingsPath: composite.settings, port: composite.port, @@ -156,6 +159,7 @@ class ProfileDetector { return { type: 'cliproxy', name: profileName, + target: singleVariant.target, provider: singleVariant.provider as CLIProxyProfileName, settingsPath: singleVariant.settings, port: singleVariant.port, @@ -170,6 +174,7 @@ class ProfileDetector { return { type: 'settings', name: profileName, + target: profile.target, env: settingsEnv, }; } diff --git a/src/ccs.ts b/src/ccs.ts index 79bc8027..e7e7dd74 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -44,6 +44,7 @@ import { getTarget, ClaudeAdapter, DroidAdapter, + pruneOrphanedModels, type TargetCredentials, } from './targets'; import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; @@ -180,7 +181,8 @@ async function execClaudeWithProxy( }; const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli); const webSearchEnv = getWebSearchHookEnv(); const imageAnalysisEnv = getImageAnalysisHookEnv(profileName); const env = { @@ -192,7 +194,17 @@ async function execClaudeWithProxy( }; let claude: ChildProcess; - if (needsShell) { + if (isPowerShellScript) { + claude = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args], + { + stdio: 'inherit', + windowsHide: true, + env, + } + ); + } else if (needsShell) { const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); claude = spawn(cmdString, { stdio: 'inherit', @@ -209,28 +221,57 @@ async function execClaudeWithProxy( } // 5. Cleanup: kill proxy when Claude exits + const forwardSigTerm = () => { + proxy.kill('SIGTERM'); + claude.kill('SIGTERM'); + }; + const forwardSigInt = () => { + proxy.kill('SIGTERM'); + claude.kill('SIGINT'); + }; + const forwardSighup = () => { + proxy.kill('SIGTERM'); + claude.kill('SIGHUP'); + }; + process.on('SIGTERM', forwardSigTerm); + process.on('SIGINT', forwardSigInt); + process.on('SIGHUP', forwardSighup); + + const cleanupSignalHandlers = () => { + process.removeListener('SIGTERM', forwardSigTerm); + process.removeListener('SIGINT', forwardSigInt); + process.removeListener('SIGHUP', forwardSighup); + }; + claude.on('exit', (code, signal) => { + cleanupSignalHandlers(); proxy.kill('SIGTERM'); if (signal) process.kill(process.pid, signal as NodeJS.Signals); else process.exit(code || 0); }); claude.on('error', (error) => { - console.error(fail(`Claude CLI error: ${error}`)); + cleanupSignalHandlers(); + const err = error as NodeJS.ErrnoException; + if (err.code === 'EACCES') { + console.error(fail(`Claude CLI is not executable: ${claudeCli}`)); + console.error(' Check file permissions and executable bit.'); + } else if (err.code === 'ENOENT') { + if (isPowerShellScript) { + console.error(fail('PowerShell executable not found (required for .ps1 wrapper launch).')); + console.error(' Ensure powershell.exe is available in PATH.'); + } else if (needsShell) { + console.error(fail('Windows command shell not found for Claude wrapper launch.')); + console.error(' Ensure cmd.exe is available and accessible.'); + } else { + console.error(fail(`Claude CLI not found: ${claudeCli}`)); + } + } else { + console.error(fail(`Claude CLI error: ${err.message}`)); + } proxy.kill('SIGTERM'); process.exit(1); }); - - // Also handle parent process termination - process.once('SIGTERM', () => { - proxy.kill('SIGTERM'); - claude.kill('SIGTERM'); - }); - - process.once('SIGINT', () => { - proxy.kill('SIGTERM'); - claude.kill('SIGTERM'); - }); } // ========== Main Execution ========== @@ -399,7 +440,8 @@ async function main(): Promise { // Special case: version command (check BEFORE profile detection) if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') { - handleVersionCommand(); + await handleVersionCommand(); + return; } // Special case: help command @@ -575,33 +617,6 @@ async function main(): Promise { process.exit(exitCode); } - // Special case: headless delegation (-p flag) - if (args.includes('-p') || args.includes('--prompt')) { - // CLIProxy profiles (codex/gemini/agy/etc, including user variants) must stay on - // the normal CLIProxy path so provider-specific flags (e.g. --effort/--thinking) - // and proxy chains are applied consistently. - let shouldUseDelegation = true; - const { profile } = detectProfile(args); - try { - const ProfileDetectorModule = await import('./auth/profile-detector'); - const ProfileDetector = ProfileDetectorModule.default; - const detector = new ProfileDetector(); - const profileInfo = detector.detectProfileType(profile); - if (profileInfo.type === 'cliproxy') { - shouldUseDelegation = false; - } - } catch { - // Best effort detection only; keep delegation fallback behavior. - } - - if (shouldUseDelegation) { - const { DelegationHandler } = await import('./delegation/delegation-handler'); - const handler = new DelegationHandler(); - await handler.route(args); - return; - } - } - // First-time install: offer setup wizard for interactive users // Check independently of recovery status (user may have empty config.yaml) // Skip if headless, CI, or non-TTY environment @@ -613,36 +628,6 @@ async function main(): Promise { console.log(''); } - // Detect profile (strip --target from args before profile detection) - const cleanArgs = stripTargetFlag(args); - const { profile, remainingArgs } = detectProfile(cleanArgs); - - // Resolve target CLI (--target flag > per-profile config > argv[0] > 'claude') - const resolvedTarget = resolveTargetType(args); - - // Detect Claude CLI (needed for claude target and CLIProxy flows) - const claudeCliRaw = detectClaudeCli(); - if (resolvedTarget === 'claude' && !claudeCliRaw) { - await ErrorManager.showClaudeNotFound(); - process.exit(1); - } - // For claude target, claudeCli is guaranteed non-null after the check above. - // For non-claude targets, CLIProxy flows still need Claude CLI — warn if missing. - const claudeCli = claudeCliRaw || ''; - - // For non-claude targets, verify target binary exists - if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); - const binaryInfo = adapter.detectBinary(); - if (!binaryInfo) { - console.error(fail(`${adapter.displayName} CLI not found.`)); - if (resolvedTarget === 'droid') { - console.error(info('Install: npm i -g @factory/cli')); - } - process.exit(1); - } - } - // Use ProfileDetector to determine profile type const ProfileDetectorModule = await import('./auth/profile-detector'); const ProfileDetector = ProfileDetectorModule.default; @@ -654,14 +639,132 @@ async function main(): Promise { const detector = new ProfileDetector(); try { + // Detect profile (strip --target flags before profile detection) + const cleanArgs = stripTargetFlag(args); + const { profile, remainingArgs } = detectProfile(cleanArgs); const profileInfo = detector.detectProfileType(profile); + let resolvedTarget: ReturnType; + try { + resolvedTarget = resolveTargetType( + args, + profileInfo.target ? { target: profileInfo.target } : undefined + ); + } catch (error) { + console.error(fail((error as Error).message)); + process.exit(1); + return; + } + + // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) + const claudeCliRaw = detectClaudeCli(); + if (resolvedTarget === 'claude' && !claudeCliRaw) { + await ErrorManager.showClaudeNotFound(); + process.exit(1); + } + const claudeCli = claudeCliRaw || ''; + + // Resolve non-claude target adapter once. + const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null; + + // Preflight unsupported profile/target combinations BEFORE binary detection, + // so users get the most actionable error even when the target CLI is not installed. + if (resolvedTarget !== 'claude') { + if (!targetAdapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + + if (profileInfo.type === 'cliproxy' && !targetAdapter.supportsProfileType('cliproxy')) { + console.error(fail(`${targetAdapter.displayName} does not support CLIProxy profiles`)); + console.error(info('Use a settings-based profile with --target instead')); + process.exit(1); + } + + if (profileInfo.type === 'copilot' && !targetAdapter.supportsProfileType('copilot')) { + console.error(fail(`${targetAdapter.displayName} does not support Copilot profiles`)); + process.exit(1); + } + + if (profileInfo.type === 'account' && !targetAdapter.supportsProfileType('account')) { + console.error(fail(`${targetAdapter.displayName} does not support account-based profiles`)); + console.error(info('Use a settings-based profile with --target instead')); + process.exit(1); + } + + // GLMT always requires Claude target because it depends on embedded proxy flow. + if (profileInfo.type === 'settings' && profileInfo.name === 'glmt') { + console.error(fail(`${targetAdapter.displayName} does not support GLMT proxy profiles`)); + console.error( + info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)') + ); + process.exit(1); + } + + if (profileInfo.type === 'default') { + if (!targetAdapter.supportsProfileType('default')) { + console.error(fail(`${targetAdapter.displayName} does not support default profile mode`)); + process.exit(1); + } + + // For default mode, Droid requires explicit credentials from environment. + if (resolvedTarget === 'droid') { + const baseUrl = process.env['ANTHROPIC_BASE_URL'] || ''; + const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || ''; + if (!baseUrl.trim() || !apiKey.trim()) { + console.error( + fail( + `${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` + ) + ); + console.error(info('Use a settings-based profile instead: ccs glm --target droid')); + process.exit(1); + } + } + } + } + + // For non-claude targets, verify target binary exists once and pass it through. + const targetBinaryInfo = targetAdapter?.detectBinary() ?? null; + if (resolvedTarget !== 'claude' && (!targetAdapter || !targetBinaryInfo)) { + const displayName = targetAdapter?.displayName || resolvedTarget; + console.error(fail(`${displayName} CLI not found.`)); + if (resolvedTarget === 'droid') { + console.error(info('Install: npm i -g @factory/cli')); + } + process.exit(1); + } + + // Best-effort: prune stale Droid model entries at runtime so settings.json stays clean. + if (resolvedTarget === 'droid') { + try { + const allProfiles = detector.getAllProfiles(); + const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9_-]+$/.test(name)); + await pruneOrphanedModels(activeProfiles); + } catch (error) { + console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); + } + } + + // Special case: headless delegation (-p/--prompt) + // Keep existing behavior for Claude targets only; non-claude targets must continue + // through normal adapter dispatch logic. + if (args.includes('-p') || args.includes('--prompt')) { + const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type !== 'cliproxy'; + if (shouldUseDelegation) { + const { DelegationHandler } = await import('./delegation/delegation-handler'); + const handler = new DelegationHandler(); + await handler.route(cleanArgs); + return; + } + } if (profileInfo.type === 'cliproxy') { // Guard: non-claude targets don't support CLIProxy flow yet if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); - if (!adapter.supportsProfileType('cliproxy')) { - console.error(fail(`${adapter.displayName} does not support CLIProxy profiles yet`)); + if (!targetAdapter?.supportsProfileType('cliproxy')) { + console.error( + fail(`${targetAdapter?.displayName || 'Target'} does not support CLIProxy profiles`) + ); console.error(info('Use a settings-based profile with --target instead')); process.exit(1); } @@ -687,9 +790,10 @@ async function main(): Promise { } else if (profileInfo.type === 'copilot') { // Guard: non-claude targets don't support Copilot flow if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); - if (!adapter.supportsProfileType('copilot')) { - console.error(fail(`${adapter.displayName} does not support Copilot profiles`)); + if (!targetAdapter?.supportsProfileType('copilot')) { + console.error( + fail(`${targetAdapter?.displayName || 'Target'} does not support Copilot profiles`) + ); process.exit(1); } } @@ -774,13 +878,14 @@ async function main(): Promise { // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { - // Guard: non-claude targets don't support GLMT proxy flow if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); - if (!adapter.supportsProfileType('settings')) { - console.error(fail(`${adapter.displayName} does not support GLMT proxy profiles`)); - process.exit(1); - } + console.error( + fail(`${targetAdapter?.displayName || 'Target'} does not support GLMT proxy profiles`) + ); + console.error( + info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)') + ); + process.exit(1); } // GLMT FLOW: Settings-based with embedded proxy for thinking support await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs); @@ -817,7 +922,11 @@ async function main(): Promise { // Dispatch through target adapter for non-claude targets if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } const creds: TargetCredentials = { profile: profileInfo.name, baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '', @@ -827,7 +936,7 @@ async function main(): Promise { await adapter.prepareCredentials(creds); const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); const targetEnv = adapter.buildEnv(creds, profileInfo.type); - adapter.exec(targetArgs, targetEnv); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; } @@ -836,9 +945,12 @@ async function main(): Promise { } else if (profileInfo.type === 'account') { // Guard: non-claude targets don't support account profiles if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); - if (!adapter.supportsProfileType('account')) { - console.error(fail(`${adapter.displayName} does not support account-based profiles`)); + if (!targetAdapter?.supportsProfileType('account')) { + console.error( + fail( + `${targetAdapter?.displayName || 'Target'} does not support account-based profiles` + ) + ); console.error(info('Use a settings-based profile with --target instead')); process.exit(1); } @@ -881,16 +993,34 @@ async function main(): Promise { // Dispatch through target adapter for non-claude targets if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } if (!adapter.supportsProfileType('default')) { console.error(fail(`${adapter.displayName} does not support default profile mode`)); process.exit(1); } - const creds: TargetCredentials = { profile: 'default', baseUrl: '', apiKey: '' }; + const creds: TargetCredentials = { + profile: 'default', + baseUrl: process.env['ANTHROPIC_BASE_URL'] || '', + apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '', + model: process.env['ANTHROPIC_MODEL'], + }; + if (!creds.baseUrl || !creds.apiKey) { + console.error( + fail( + `${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` + ) + ); + console.error(info('Use a settings-based profile instead: ccs glm --target droid')); + process.exit(1); + } await adapter.prepareCredentials(creds); const targetArgs = adapter.buildArgs('default', remainingArgs); const targetEnv = adapter.buildEnv(creds, 'default'); - adapter.exec(targetArgs, targetEnv); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; } @@ -924,12 +1054,19 @@ process.on('unhandledRejection', (reason: unknown) => { // Handle process termination signals for cleanup process.on('SIGTERM', () => { runCleanup(); - process.exit(0); + // If a target exec path registered additional signal listeners, let those + // listeners forward/coordinate child shutdown and final exit codes. + if (process.listenerCount('SIGTERM') <= 1) { + process.exit(143); // 128 + SIGTERM(15) + } }); process.on('SIGINT', () => { runCleanup(); - process.exit(130); // 128 + SIGINT(2) + // Same coordination rule as SIGTERM. + if (process.listenerCount('SIGINT') <= 1) { + process.exit(130); // 128 + SIGINT(2) + } }); // Run main diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts index 90ec06f9..5ec19009 100644 --- a/src/cliproxy/session-tracker.ts +++ b/src/cliproxy/session-tracker.ts @@ -17,6 +17,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; +import * as lockfile from 'proper-lockfile'; import { getCliproxyDir } from './config-generator'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import { CLIPROXY_DEFAULT_PORT } from './config-generator'; @@ -31,8 +32,10 @@ interface SessionLock { version?: string; /** Backend type running (original vs plus) */ backend?: 'original' | 'plus'; - /** Target CLI used for this session (default: 'claude') */ + /** Target summary for active sessions ('mixed' when multiple targets share the proxy) */ target?: string; + /** Per-session target metadata */ + sessionTargets?: Record; } /** Generate unique session ID */ @@ -48,6 +51,34 @@ function getSessionLockPathForPort(port: number): string { return path.join(getCliproxyDir(), `sessions-${port}.json`); } +function ensureCliproxyDir(): string { + const dir = getCliproxyDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + return dir; +} + +function withSessionTrackerLock(fn: () => T): T { + const dir = ensureCliproxyDir(); + let release: (() => void) | undefined; + + try { + release = lockfile.lockSync(dir, { + stale: 10000, + }) as () => void; + return fn(); + } finally { + if (release) { + try { + release(); + } catch { + // Best-effort release + } + } + } +} + /** Get path to session lock file (default port) - kept for future use */ function _getSessionLockPath(): string { return getSessionLockPathForPort(CLIPROXY_DEFAULT_PORT); @@ -87,13 +118,24 @@ function readSessionLock(): SessionLock | null { return readSessionLockForPort(CLIPROXY_DEFAULT_PORT); } +function getTargetSummary(lock: SessionLock): string | undefined { + const targets = lock.sessionTargets ? Object.values(lock.sessionTargets).filter(Boolean) : []; + if (targets.length === 0) { + return lock.target; + } + + const unique = new Set(targets); + if (unique.size === 1) { + return targets[0]; + } + return 'mixed'; +} + /** Write session lock file for specific port */ function writeSessionLockForPort(lock: SessionLock): void { const lockPath = getSessionLockPathForPort(lock.port); - const dir = path.dirname(lockPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } + ensureCliproxyDir(); + lock.target = getTargetSummary(lock); fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 }); } @@ -186,16 +228,24 @@ export function registerSession( port: number, proxyPid: number, version?: string, - backend?: 'original' | 'plus' + backend?: 'original' | 'plus', + target?: string ): string { const sessionId = generateSessionId(); - const existingLock = readSessionLockForPort(port); + withSessionTrackerLock(() => { + const existingLock = readSessionLockForPort(port); + + if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) { + // Add to existing sessions + existingLock.sessions.push(sessionId); + if (target) { + existingLock.sessionTargets = existingLock.sessionTargets || {}; + existingLock.sessionTargets[sessionId] = target; + } + writeSessionLockForPort(existingLock); + return; + } - if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) { - // Add to existing sessions - existingLock.sessions.push(sessionId); - writeSessionLockForPort(existingLock); - } else { // Create new lock (first session for this proxy) const newLock: SessionLock = { port, @@ -204,9 +254,11 @@ export function registerSession( startedAt: new Date().toISOString(), version, backend, + target, + sessionTargets: target ? { [sessionId]: target } : undefined, }; writeSessionLockForPort(newLock); - } + }); return sessionId; } @@ -220,48 +272,66 @@ export function registerSession( export function unregisterSession(sessionId: string, port?: number): boolean { // If port provided, use port-specific lookup if (port !== undefined) { - const lock = readSessionLockForPort(port); + return withSessionTrackerLock(() => { + const lock = readSessionLockForPort(port); + if (!lock) { + return true; + } + + const index = lock.sessions.indexOf(sessionId); + if (index !== -1) { + lock.sessions.splice(index, 1); + } + + if (lock.sessionTargets) { + delete lock.sessionTargets[sessionId]; + if (Object.keys(lock.sessionTargets).length === 0) { + delete lock.sessionTargets; + } + } + + if (lock.sessions.length === 0) { + deleteSessionLockForPort(port); + return true; + } + + writeSessionLockForPort(lock); + return false; + }); + } + + // Fallback: search default port (backward compat) + return withSessionTrackerLock(() => { + const lock = readSessionLock(); if (!lock) { + // No lock file - assume we're the only session return true; } + // Remove this session from the list const index = lock.sessions.indexOf(sessionId); if (index !== -1) { lock.sessions.splice(index, 1); } + if (lock.sessionTargets) { + delete lock.sessionTargets[sessionId]; + if (Object.keys(lock.sessionTargets).length === 0) { + delete lock.sessionTargets; + } + } + + // Check if any sessions remain if (lock.sessions.length === 0) { - deleteSessionLockForPort(port); + // Last session - clean up lock file + deleteSessionLock(); return true; } + // Other sessions still active - keep proxy running writeSessionLockForPort(lock); return false; - } - - // Fallback: search default port (backward compat) - const lock = readSessionLock(); - if (!lock) { - // No lock file - assume we're the only session - return true; - } - - // Remove this session from the list - const index = lock.sessions.indexOf(sessionId); - if (index !== -1) { - lock.sessions.splice(index, 1); - } - - // Check if any sessions remain - if (lock.sessions.length === 0) { - // Last session - clean up lock file - deleteSessionLock(); - return true; - } - - // Other sessions still active - keep proxy running - writeSessionLockForPort(lock); - return false; + }); } /** @@ -422,6 +492,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): { sessionCount?: number; startedAt?: string; version?: string; + target?: string; } { const lock = readSessionLockForPort(port); @@ -442,6 +513,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): { sessionCount: lock.sessions.length, startedAt: lock.startedAt, version: lock.version, + target: lock.target || 'claude', }; } diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index 3b267de8..2f61e4b6 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -56,7 +56,11 @@ export class ClaudeAdapter implements TargetAdapter { return env; } - exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void { + exec( + args: string[], + env: NodeJS.ProcessEnv, + _options?: { cwd?: string; binaryInfo?: TargetBinaryInfo } + ): void { const claudeCli = detectClaudeCli(); if (!claudeCli) { void ErrorManager.showClaudeNotFound(); @@ -65,10 +69,21 @@ export class ClaudeAdapter implements TargetAdapter { } const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli); let child: ChildProcess; - if (needsShell) { + if (isPowerShellScript) { + child = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args], + { + stdio: 'inherit', + windowsHide: true, + env, + } + ); + } else if (needsShell) { const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); child = spawn(cmdString, { stdio: 'inherit', @@ -84,13 +99,49 @@ export class ClaudeAdapter implements TargetAdapter { }); } + const forwardSigInt = () => { + if (!child.killed) child.kill('SIGINT'); + }; + const forwardSigTerm = () => { + if (!child.killed) child.kill('SIGTERM'); + }; + const forwardSighup = () => { + if (!child.killed) child.kill('SIGHUP'); + }; + process.on('SIGINT', forwardSigInt); + process.on('SIGTERM', forwardSigTerm); + process.on('SIGHUP', forwardSighup); + + const cleanupSignalHandlers = () => { + process.removeListener('SIGINT', forwardSigInt); + process.removeListener('SIGTERM', forwardSigTerm); + process.removeListener('SIGHUP', forwardSighup); + }; + child.on('exit', (code, signal) => { + cleanupSignalHandlers(); if (signal) process.kill(process.pid, signal as NodeJS.Signals); else process.exit(code || 0); }); - child.on('error', async () => { - await ErrorManager.showClaudeNotFound(); + child.on('error', async (err: NodeJS.ErrnoException) => { + cleanupSignalHandlers(); + if (err.code === 'EACCES') { + console.error(`[X] Claude CLI is not executable: ${claudeCli}`); + console.error(' Check file permissions and executable bit.'); + } else if (err.code === 'ENOENT') { + if (isPowerShellScript) { + console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).'); + console.error(' Ensure powershell.exe is available in PATH.'); + } else if (needsShell) { + console.error('[X] Windows command shell not found for Claude wrapper launch.'); + console.error(' Ensure cmd.exe is available and accessible.'); + } else { + await ErrorManager.showClaudeNotFound(); + } + } else { + console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`); + } process.exit(1); }); } diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 88133485..0cbb6946 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -6,6 +6,7 @@ */ import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter'; import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector'; import { upsertCcsModel } from './droid-config-manager'; @@ -15,6 +16,15 @@ export class DroidAdapter implements TargetAdapter { readonly type: TargetType = 'droid'; readonly displayName = 'Factory Droid'; + private validateCredentials(creds: TargetCredentials): void { + if (!creds.baseUrl?.trim()) { + throw new Error('Droid target requires ANTHROPIC_BASE_URL'); + } + if (!creds.apiKey?.trim()) { + throw new Error('Droid target requires ANTHROPIC_AUTH_TOKEN'); + } + } + detectBinary(): TargetBinaryInfo | null { const info = getDroidBinaryInfo(); if (!info) return null; @@ -29,6 +39,7 @@ export class DroidAdapter implements TargetAdapter { * This is the key difference from Claude — Droid reads config files, not env vars. */ async prepareCredentials(creds: TargetCredentials): Promise { + this.validateCredentials(creds); await upsertCcsModel(creds.profile, { model: creds.model || 'claude-opus-4-6', displayName: `CCS ${creds.profile}`, @@ -49,19 +60,49 @@ export class DroidAdapter implements TargetAdapter { return { ...process.env }; } - exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void { - const droidPath = detectDroidCli(); + exec( + args: string[], + env: NodeJS.ProcessEnv, + options?: { cwd?: string; binaryInfo?: TargetBinaryInfo } + ): void { + const droidPath = options?.binaryInfo?.path || detectDroidCli(); if (!droidPath) { console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli'); process.exit(1); return; } + try { + const stat = fs.statSync(droidPath); + if (!stat.isFile()) { + console.error(`[X] Droid CLI path is not a file: ${droidPath}`); + process.exit(1); + return; + } + } catch (err) { + const error = err as NodeJS.ErrnoException; + console.error( + `[X] Droid CLI path is not accessible (${error.code || 'unknown'}): ${droidPath}` + ); + process.exit(1); + return; + } const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(droidPath); + const isPowerShellScript = isWindows && /\.ps1$/i.test(droidPath); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(droidPath); let child: ChildProcess; - if (needsShell) { + if (isPowerShellScript) { + child = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', droidPath, ...args], + { + stdio: 'inherit', + windowsHide: true, + env, + } + ); + } else if (needsShell) { const cmdString = [droidPath, ...args].map(escapeShellArg).join(' '); child = spawn(cmdString, { stdio: 'inherit', @@ -77,28 +118,58 @@ export class DroidAdapter implements TargetAdapter { }); } + const forwardSigInt = () => { + if (!child.killed) child.kill('SIGINT'); + }; + const forwardSigTerm = () => { + if (!child.killed) child.kill('SIGTERM'); + }; + const forwardSighup = () => { + if (!child.killed) child.kill('SIGHUP'); + }; + process.on('SIGINT', forwardSigInt); + process.on('SIGTERM', forwardSigTerm); + process.on('SIGHUP', forwardSighup); + + const cleanupSignalHandlers = () => { + process.removeListener('SIGINT', forwardSigInt); + process.removeListener('SIGTERM', forwardSigTerm); + process.removeListener('SIGHUP', forwardSighup); + }; + child.on('exit', (code, signal) => { + cleanupSignalHandlers(); if (signal) process.kill(process.pid, signal as NodeJS.Signals); else process.exit(code || 0); }); child.on('error', (err: NodeJS.ErrnoException) => { + cleanupSignalHandlers(); if (err.code === 'EACCES') { - console.error('[X] Droid CLI not executable. Check file permissions.'); + console.error(`[X] Droid CLI is not executable: ${droidPath}`); + console.error(' Check file permissions and executable bit.'); } else if (err.code === 'ENOENT') { - console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli'); + if (isPowerShellScript) { + console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).'); + console.error(' Ensure powershell.exe is available in PATH.'); + } else if (needsShell) { + console.error('[X] Windows command shell not found for Droid wrapper launch.'); + console.error(' Ensure cmd.exe is available and accessible.'); + } else { + console.error(`[X] Droid CLI not found: ${droidPath}`); + console.error(' Install: npm i -g @factory/cli'); + } } else { - console.error('[X] Failed to start Droid CLI:', err.message); + console.error(`[X] Failed to start Droid CLI (${droidPath}):`, err.message); } process.exit(1); }); } /** - * Droid supports all profile types except account-based. - * Account profiles use CLAUDE_CONFIG_DIR which is Claude-specific. + * Droid currently supports direct settings-based and default flows only. */ supportsProfileType(profileType: string): boolean { - return profileType !== 'account'; + return profileType === 'settings' || profileType === 'default'; } } diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 6f6e206d..f1255f66 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -38,10 +38,54 @@ interface DroidSettings { [key: string]: unknown; } -interface DroidCustomModelEntry extends DroidCustomModel { +interface DroidCustomModelEntry { + model: string; + displayName: string; + baseUrl: string; + apiKey: string; + provider: string; + maxOutputTokens?: number; /** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */ } +function isSupportedProvider(value: string): value is DroidCustomModel['provider'] { + return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api'; +} + +function asModelEntry(value: unknown): DroidCustomModelEntry | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + if ( + typeof record.displayName !== 'string' || + record.displayName.trim() === '' || + typeof record.model !== 'string' || + typeof record.baseUrl !== 'string' || + typeof record.apiKey !== 'string' || + typeof record.provider !== 'string' || + record.provider.trim() === '' + ) { + return null; + } + return value as DroidCustomModelEntry; +} + +function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] { + if (Array.isArray(value)) { + return value + .map((entry) => asModelEntry(entry)) + .filter((entry): entry is DroidCustomModelEntry => !!entry); + } + + // Accept legacy object-map shapes and normalize to array. + if (value && typeof value === 'object') { + return Object.values(value) + .map((entry) => asModelEntry(entry)) + .filter((entry): entry is DroidCustomModelEntry => !!entry); + } + + return []; +} + /** * Get path to ~/.factory/settings.json. * Respects CCS_HOME for test isolation (uses CCS_HOME/.factory/ in tests). @@ -65,6 +109,35 @@ function ensureFactoryDir(): void { } } +function getNoFollowFlag(): number { + const candidate = (fs.constants as Record)['O_NOFOLLOW']; + if (process.platform !== 'win32' && typeof candidate === 'number') { + return candidate; + } + return 0; +} + +function openFileNoFollow(filePath: string, flags: number, mode?: number): number { + const safeFlags = flags | getNoFollowFlag(); + if (mode === undefined) { + return fs.openSync(filePath, safeFlags); + } + return fs.openSync(filePath, safeFlags, mode); +} + +function readFileUtf8NoFollow(filePath: string): string { + const fd = openFileNoFollow(filePath, fs.constants.O_RDONLY); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) { + throw new Error('Refusing to read: settings.json is not a regular file'); + } + return fs.readFileSync(fd, 'utf8'); + } finally { + fs.closeSync(fd); + } +} + /** * Read ~/.factory/settings.json, creating empty structure if missing. */ @@ -74,19 +147,63 @@ function readDroidSettings(): DroidSettings { return { customModels: [] }; } - const raw = fs.readFileSync(settingsPath, 'utf8'); + const fileStat = fs.lstatSync(settingsPath); + if (fileStat.isSymbolicLink()) { + throw new Error('Refusing to read: settings.json is a symlink'); + } + if (!fileStat.isFile()) { + throw new Error('Refusing to read: settings.json is not a regular file'); + } + + const raw = readFileUtf8NoFollow(settingsPath); try { - return JSON.parse(raw) as DroidSettings; + const parsed = JSON.parse(raw) as DroidSettings; + return { + ...parsed, + customModels: normalizeCustomModels((parsed as { customModels?: unknown }).customModels), + }; } catch { // Corrupted file — preserve as backup, start fresh const backup = settingsPath + '.bak'; - fs.copyFileSync(settingsPath, backup); - fs.chmodSync(backup, 0o600); // Secure backup permissions - console.warn(`[!] Corrupted ${settingsPath}, backed up to ${backup}`); + try { + fs.copyFileSync(settingsPath, backup); + fs.chmodSync(backup, 0o600); // Secure backup permissions + console.warn(`[!] Corrupted ${settingsPath}, backed up to ${backup}`); + } catch (error) { + console.warn(`[!] Corrupted ${settingsPath}; backup failed: ${(error as Error).message}`); + } return { customModels: [] }; } } +async function acquireFactoryLock(retries: number): Promise<() => Promise> { + ensureFactoryDir(); + const factoryDir = getFactoryDir(); + try { + return await lockfile.lock(factoryDir, { + stale: 10000, + retries: { retries, minTimeout: 200, maxTimeout: 1000 }, + }); + } catch (error) { + throw new Error( + `Failed to lock Droid settings directory (${factoryDir}): ${(error as Error).message}` + ); + } +} + +function fsyncDir(dirPath: string): void { + try { + const dirFd = fs.openSync(dirPath, fs.constants.O_RDONLY); + try { + fs.fsyncSync(dirFd); + } finally { + fs.closeSync(dirFd); + } + } catch { + // Best-effort directory fsync (platform dependent). + } +} + /** * Write ~/.factory/settings.json atomically with safe permissions. * Uses temp file + rename for atomicity on same filesystem. @@ -104,12 +221,38 @@ function writeDroidSettings(settings: DroidSettings): void { } const tmpPath = settingsPath + '.tmp'; + if (fs.existsSync(tmpPath)) { + const tmpStat = fs.lstatSync(tmpPath); + if (tmpStat.isSymbolicLink()) { + throw new Error('Refusing to write: settings.json.tmp is a symlink'); + } + } - fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', { - encoding: 'utf8', - mode: 0o600, - }); + const payload = JSON.stringify( + { + ...settings, + customModels: normalizeCustomModels((settings as { customModels?: unknown }).customModels), + }, + null, + 2 + ); + const fd = openFileNoFollow( + tmpPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC, + 0o600 + ); + try { + const tmpFdStat = fs.fstatSync(fd); + if (!tmpFdStat.isFile()) { + throw new Error('Refusing to write: settings.json.tmp is not a regular file'); + } + fs.writeFileSync(fd, payload + '\n', { encoding: 'utf8' }); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } fs.renameSync(tmpPath, settingsPath); + fsyncDir(path.dirname(settingsPath)); // Fix permissions on existing file if world-readable try { @@ -139,24 +282,13 @@ function ccsAlias(profile: string): string { export async function upsertCcsModel(profile: string, model: DroidCustomModel): Promise { validateProfileName(profile); ensureFactoryDir(); - const settingsPath = getSettingsPath(); - - // Create file if it doesn't exist (lockfile needs an existing file) - if (!fs.existsSync(settingsPath)) { - writeDroidSettings({ customModels: [] }); - } let release: (() => Promise) | undefined; try { - release = await lockfile.lock(settingsPath, { - stale: 10000, - retries: { retries: 5, minTimeout: 200, maxTimeout: 1000 }, - }); + release = await acquireFactoryLock(5); const settings = readDroidSettings(); - if (!settings.customModels) { - settings.customModels = []; - } + settings.customModels = normalizeCustomModels(settings.customModels); const alias = ccsAlias(profile); const entry: DroidCustomModelEntry = { @@ -186,18 +318,16 @@ export async function upsertCcsModel(profile: string, model: DroidCustomModel): */ export async function removeCcsModel(profile: string): Promise { validateProfileName(profile); + ensureFactoryDir(); const settingsPath = getSettingsPath(); - if (!fs.existsSync(settingsPath)) return; let release: (() => Promise) | undefined; try { - release = await lockfile.lock(settingsPath, { - stale: 10000, - retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 }, - }); + release = await acquireFactoryLock(3); + if (!fs.existsSync(settingsPath)) return; const settings = readDroidSettings(); - if (!settings.customModels) return; + settings.customModels = normalizeCustomModels(settings.customModels); settings.customModels = settings.customModels.filter( (m) => m.displayName !== `CCS ${profile}` && m.displayName !== ccsAlias(profile) @@ -215,12 +345,16 @@ export async function removeCcsModel(profile: string): Promise { export async function listCcsModels(): Promise> { const result = new Map(); const settings = readDroidSettings(); - if (!settings.customModels) return result; - - for (const entry of settings.customModels) { + for (const entry of normalizeCustomModels(settings.customModels)) { if (entry.displayName?.startsWith('CCS ')) { + if (!isSupportedProvider(entry.provider)) { + continue; + } const profile = entry.displayName.slice(4); // Remove "CCS " prefix - result.set(profile, entry); + result.set(profile, { + ...entry, + provider: entry.provider, + }); } } @@ -235,20 +369,18 @@ export async function pruneOrphanedModels(activeProfiles: string[]): Promise validateProfileName(profile)); + ensureFactoryDir(); const settingsPath = getSettingsPath(); - if (!fs.existsSync(settingsPath)) return 0; let release: (() => Promise) | undefined; let removed = 0; try { - release = await lockfile.lock(settingsPath, { - stale: 10000, - retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 }, - }); + release = await acquireFactoryLock(3); + if (!fs.existsSync(settingsPath)) return 0; const settings = readDroidSettings(); - if (!settings.customModels) return 0; + settings.customModels = normalizeCustomModels(settings.customModels); const before = settings.customModels.length; settings.customModels = settings.customModels.filter((m) => { diff --git a/src/targets/droid-detector.ts b/src/targets/droid-detector.ts index a833b54f..bfa2415f 100644 --- a/src/targets/droid-detector.ts +++ b/src/targets/droid-detector.ts @@ -6,7 +6,7 @@ */ import * as fs from 'fs'; -import { execSync } from 'child_process'; +import { execSync, execFileSync } from 'child_process'; import { expandPath } from '../utils/helpers'; import { TargetBinaryInfo } from './target-adapter'; @@ -21,15 +21,25 @@ export function detectDroidCli(): string | null { // Priority 1: CCS_DROID_PATH environment variable if (process.env.CCS_DROID_PATH) { const customPath = expandPath(process.env.CCS_DROID_PATH); - if (fs.existsSync(customPath)) { + try { if (fs.statSync(customPath).isFile()) { return customPath; } console.warn('[!] CCS_DROID_PATH points to a directory, not a file:', customPath); - console.warn(' Falling back to system PATH lookup...'); - } else { - console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath); - console.warn(' Falling back to system PATH lookup...'); + console.warn(' Refusing PATH fallback while CCS_DROID_PATH is explicitly set.'); + return null; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath); + } else { + console.warn( + `[!] Warning: CCS_DROID_PATH is not accessible (${error.code || 'unknown error'}):`, + customPath + ); + } + console.warn(' Refusing PATH fallback while CCS_DROID_PATH is explicitly set.'); + return null; } } @@ -49,16 +59,20 @@ export function detectDroidCli(): string | null { .map((p) => p.trim()) .filter((p) => p); - if (isWindows) { - const withExtension = matches.find((p) => /\.(exe|cmd|bat|ps1)$/i.test(p)); - const droidPath = withExtension || matches[0]; - if (droidPath && fs.existsSync(droidPath)) { - return droidPath; - } - } else { - const droidPath = matches[0]; - if (droidPath && fs.existsSync(droidPath)) { - return droidPath; + const candidates = isWindows + ? [ + ...matches.filter((p) => /\.(exe|cmd|bat|ps1)$/i.test(p)), + ...matches.filter((p) => !/\.(exe|cmd|bat|ps1)$/i.test(p)), + ] + : matches; + + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Ignore unreadable or disappearing path candidates and try next one } } } catch { @@ -87,7 +101,7 @@ export function getDroidBinaryInfo(): TargetBinaryInfo | null { */ export function checkDroidVersion(droidPath: string): void { try { - const version = execSync(`"${droidPath}" --version`, { + const version = execFileSync(droidPath, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index 747c0ca3..edb6ff7d 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -59,7 +59,11 @@ export interface TargetAdapter { buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv; /** Spawn the target CLI process (replaces current process flow) */ - exec(args: string[], env: NodeJS.ProcessEnv, options?: { cwd?: string }): void; + exec( + args: string[], + env: NodeJS.ProcessEnv, + options?: { cwd?: string; binaryInfo?: TargetBinaryInfo } + ): void; /** Check if a profile type is supported by this target */ supportsProfileType(profileType: string): boolean; diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index d96955dd..3aa06441 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -24,6 +24,64 @@ const ARGV0_TARGET_MAP: Record = { */ const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); +interface ParsedTargetFlags { + targetOverride?: TargetType; + cleanedArgs: string[]; +} + +function normalizeTargetValue(value: string): TargetType { + const normalized = value.toLowerCase(); + if (VALID_TARGETS.has(normalized)) { + return normalized as TargetType; + } + + const available = Array.from(VALID_TARGETS).join(', '); + throw new Error(`Unknown target "${value}". Available: ${available}`); +} + +/** + * Parse and strip all --target flags from args. + * Supports both "--target value" and "--target=value" forms. + * For repeated flags, last one wins (common CLI precedence behavior). + */ +function parseTargetFlags(args: string[]): ParsedTargetFlags { + const cleanedArgs: string[] = []; + let targetOverride: TargetType | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + // POSIX option terminator: everything after `--` is positional. + if (arg === '--') { + cleanedArgs.push(...args.slice(i)); + break; + } + + if (arg === '--target') { + const value = args[i + 1]; + if (!value || value.startsWith('-')) { + throw new Error('--target requires a value (claude or droid)'); + } + targetOverride = normalizeTargetValue(value); + i += 1; // Skip value + continue; + } + + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length).trim(); + if (!value) { + throw new Error('--target requires a value (claude or droid)'); + } + targetOverride = normalizeTargetValue(value); + continue; + } + + cleanedArgs.push(arg); + } + + return { targetOverride, cleanedArgs }; +} + /** * Resolve target type from multiple sources with priority ordering. * @@ -35,15 +93,11 @@ export function resolveTargetType( args: string[], profileConfig?: { target?: TargetType } ): TargetType { + const parsed = parseTargetFlags(args); + // 1. Check --target flag (highest priority) - const targetIdx = args.indexOf('--target'); - if (targetIdx !== -1 && args[targetIdx + 1]) { - const flagValue = args[targetIdx + 1]; - if (VALID_TARGETS.has(flagValue)) { - return flagValue as TargetType; - } - const available = Array.from(VALID_TARGETS).join(', '); - throw new Error(`Unknown target "${flagValue}". Available: ${available}`); + if (parsed.targetOverride) { + return parsed.targetOverride; } // 2. Check per-profile config @@ -52,9 +106,9 @@ export function resolveTargetType( } // 3. Check argv[0] (busybox pattern) - // Strip .cmd/.bat extension for Windows npm shims - const rawBin = path.basename(process.argv[1] || ''); - const binName = rawBin.replace(/\.(cmd|bat)$/i, ''); + // Strip common wrapper extensions for Windows shims/wrappers + const rawBin = path.basename(process.argv[1] || process.argv0 || ''); + const binName = rawBin.replace(/\.(cmd|bat|ps1|exe)$/i, ''); const argv0Target = ARGV0_TARGET_MAP[binName]; if (argv0Target) { return argv0Target; @@ -69,11 +123,5 @@ export function resolveTargetType( * Returns new array without the flag (so it's not passed to target CLI). */ export function stripTargetFlag(args: string[]): string[] { - const targetIdx = args.indexOf('--target'); - if (targetIdx === -1) return args; - - const result = [...args]; - // Remove --target and its value - result.splice(targetIdx, 2); - return result; + return parseTargetFlags(args).cleanedArgs; } diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 6afa2620..a672f97b 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -62,7 +62,8 @@ export function execClaude( envVars: NodeJS.ProcessEnv | null = null ): void { const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli); // Get WebSearch hook config env vars const webSearchEnv = getWebSearchHookEnv(); @@ -98,7 +99,17 @@ export function execClaude( } let child: ChildProcess; - if (needsShell) { + if (isPowerShellScript) { + child = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args], + { + stdio: 'inherit', + windowsHide: true, + env, + } + ); + } else if (needsShell) { // When shell needed: concatenate into string to avoid DEP0190 warning const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); child = spawn(cmdString, { @@ -116,13 +127,49 @@ export function execClaude( }); } + const forwardSigInt = () => { + if (!child.killed) child.kill('SIGINT'); + }; + const forwardSigTerm = () => { + if (!child.killed) child.kill('SIGTERM'); + }; + const forwardSighup = () => { + if (!child.killed) child.kill('SIGHUP'); + }; + process.on('SIGINT', forwardSigInt); + process.on('SIGTERM', forwardSigTerm); + process.on('SIGHUP', forwardSighup); + + const cleanupSignalHandlers = () => { + process.removeListener('SIGINT', forwardSigInt); + process.removeListener('SIGTERM', forwardSigTerm); + process.removeListener('SIGHUP', forwardSighup); + }; + child.on('exit', (code, signal) => { + cleanupSignalHandlers(); if (signal) process.kill(process.pid, signal as NodeJS.Signals); else process.exit(code || 0); }); - child.on('error', async () => { - await ErrorManager.showClaudeNotFound(); + child.on('error', async (err: NodeJS.ErrnoException) => { + cleanupSignalHandlers(); + if (err.code === 'EACCES') { + console.error(`[X] Claude CLI is not executable: ${claudeCli}`); + console.error(' Check file permissions and executable bit.'); + } else if (err.code === 'ENOENT') { + if (isPowerShellScript) { + console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).'); + console.error(' Ensure powershell.exe is available in PATH.'); + } else if (needsShell) { + console.error('[X] Windows command shell not found for Claude wrapper launch.'); + console.error(' Ensure cmd.exe is available and accessible.'); + } else { + await ErrorManager.showClaudeNotFound(); + } + } else { + console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`); + } process.exit(1); }); } diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index 85d0caf6..41432e7b 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -31,6 +31,7 @@ export interface RawUsageEntry { timestamp: string; projectPath: string; version?: string; + target?: string; } /** Internal structure matching JSONL assistant entries */ @@ -40,6 +41,7 @@ interface JsonlAssistantEntry { timestamp: string; version?: string; cwd?: string; + target?: string; message: { model: string; usage: { @@ -95,6 +97,10 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr timestamp: assistant.timestamp || new Date().toISOString(), projectPath, version: assistant.version, + target: + typeof (entry as { target?: unknown }).target === 'string' + ? ((entry as { target?: string }).target as string) + : undefined, }; } catch { // Malformed JSON - skip silently diff --git a/src/web-server/usage/data-aggregator.ts b/src/web-server/usage/data-aggregator.ts index 85a13c22..b0f4fb5f 100644 --- a/src/web-server/usage/data-aggregator.ts +++ b/src/web-server/usage/data-aggregator.ts @@ -371,6 +371,10 @@ export function aggregateSessionUsage( const sessionUsage: SessionUsage[] = []; for (const [sessionId, sessionEntries] of bySession) { + const orderedEntries = [...sessionEntries].sort((a, b) => + a.timestamp.localeCompare(b.timestamp) + ); + // Aggregate by model const modelMap = new Map(); const versions = new Set(); @@ -380,8 +384,9 @@ export function aggregateSessionUsage( let totalCacheRead = 0; let lastActivity = ''; let projectPath = ''; + let target: string | undefined; - for (const entry of sessionEntries) { + for (const entry of orderedEntries) { const model = entry.model; const acc = modelMap.get(model) || { inputTokens: 0, @@ -415,6 +420,10 @@ export function aggregateSessionUsage( if (entry.projectPath) { projectPath = entry.projectPath; } + + if (entry.target) { + target = entry.target; + } } // Build model breakdowns @@ -450,6 +459,7 @@ export function aggregateSessionUsage( modelsUsed: Array.from(modelMap.keys()), modelBreakdowns, source, + target, }); } diff --git a/src/web-server/usage/handlers.ts b/src/web-server/usage/handlers.ts index e2b69f06..fb42867c 100644 --- a/src/web-server/usage/handlers.ts +++ b/src/web-server/usage/handlers.ts @@ -549,6 +549,7 @@ export async function handleSessions( cost: Math.round(s.totalCost * 100) / 100, lastActivity: s.lastActivity, modelsUsed: s.modelsUsed, + target: s.target || 'claude', })); res.json({ diff --git a/tests/unit/cliproxy/session-tracker-target.test.ts b/tests/unit/cliproxy/session-tracker-target.test.ts new file mode 100644 index 00000000..2a8d23ad --- /dev/null +++ b/tests/unit/cliproxy/session-tracker-target.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + registerSession, + unregisterSession, + getProxyStatus, +} from '../../../src/cliproxy/session-tracker'; + +describe('session-tracker target metadata', () => { + let tmpDir: string; + let originalCcsHome: string | undefined; + const port = 28317; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns single target when all sessions share same target', () => { + const s1 = registerSession(port, process.pid, undefined, undefined, 'droid'); + const s2 = registerSession(port, process.pid, undefined, undefined, 'droid'); + + const status = getProxyStatus(port); + expect(status.running).toBe(true); + expect(status.target).toBe('droid'); + expect(status.sessionCount).toBe(2); + + unregisterSession(s1, port); + unregisterSession(s2, port); + }); + + it('returns mixed when active sessions use different targets', () => { + const s1 = registerSession(port, process.pid, undefined, undefined, 'claude'); + const s2 = registerSession(port, process.pid, undefined, undefined, 'droid'); + + const status = getProxyStatus(port); + expect(status.running).toBe(true); + expect(status.target).toBe('mixed'); + expect(status.sessionCount).toBe(2); + + unregisterSession(s1, port); + unregisterSession(s2, port); + }); +}); diff --git a/tests/unit/data-aggregator.test.ts b/tests/unit/data-aggregator.test.ts index 206bddae..50268007 100644 --- a/tests/unit/data-aggregator.test.ts +++ b/tests/unit/data-aggregator.test.ts @@ -237,6 +237,42 @@ describe('aggregateSessionUsage', () => { expect(result[1].sessionId).toBe('old-session'); }); + test('propagates target metadata into session usage', () => { + const entries: RawUsageEntry[] = [ + createEntry({ sessionId: 'session-A', target: 'droid' }), + createEntry({ sessionId: 'session-B' }), + ]; + + const result = aggregateSessionUsage(entries); + const sessionA = result.find((s) => s.sessionId === 'session-A'); + const sessionB = result.find((s) => s.sessionId === 'session-B'); + + expect(sessionA?.target).toBe('droid'); + expect(sessionB?.target).toBeUndefined(); + }); + + test('uses latest timestamp target when a session has mixed targets', () => { + const entries: RawUsageEntry[] = [ + createEntry({ + sessionId: 'session-A', + timestamp: '2025-12-09T10:00:00.000Z', + target: 'claude', + }), + createEntry({ + sessionId: 'session-A', + timestamp: '2025-12-09T12:00:00.000Z', + target: 'droid', + }), + createEntry({ + sessionId: 'session-A', + timestamp: '2025-12-09T11:00:00.000Z', + }), + ]; + + const result = aggregateSessionUsage(entries); + expect(result[0].target).toBe('droid'); + }); + test('returns empty array for no entries', () => { const result = aggregateSessionUsage([]); expect(result.length).toBe(0); diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index a4b6a9e2..a6a19b1d 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -147,6 +147,26 @@ describe('parseUsageEntry', () => { const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path'); expect(result!.projectPath).toBe('/custom/project/path'); }); + + test('parses string target when present', () => { + const withTarget = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + target: 'droid', + }); + const result = parseUsageEntry(withTarget, '/test'); + expect(result).not.toBeNull(); + expect(result!.target).toBe('droid'); + }); + + test('ignores non-string target values', () => { + const withNumericTarget = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + target: 123, + }); + const result = parseUsageEntry(withNumericTarget, '/test'); + expect(result).not.toBeNull(); + expect(result!.target).toBeUndefined(); + }); }); // ============================================================================ diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index 734fdf3b..e2d63790 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -109,6 +109,40 @@ describe('droid-config-manager', () => { expect(settings.customModels[1].displayName).toBe('CCS gemini'); }); + it('should preserve user entries with unknown provider strings', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { + model: 'user-model', + displayName: 'My Custom Provider', + baseUrl: 'https://example.invalid', + apiKey: 'user-key', + provider: 'custom-provider', + }, + ], + }) + ); + + await upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy', + provider: 'anthropic', + }); + + const settings = JSON.parse( + fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8') + ); + expect(settings.customModels).toHaveLength(2); + expect(settings.customModels[0].provider).toBe('custom-provider'); + expect(settings.customModels[1].displayName).toBe('CCS gemini'); + }); + it('should write with restricted permissions', async () => { await upsertCcsModel('test', { model: 'test-model', @@ -124,6 +158,23 @@ describe('droid-config-manager', () => { const otherPerms = stat.mode & 0o077; expect(otherPerms).toBe(0); }); + + it('should reject symlinked temp file path', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync(path.join(factoryDir, 'settings.json'), JSON.stringify({ customModels: [] })); + fs.symlinkSync('/tmp', path.join(factoryDir, 'settings.json.tmp')); + + await expect( + upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy-key', + provider: 'anthropic', + }) + ).rejects.toThrow(/settings\.json\.tmp is a symlink/); + }); }); describe('removeCcsModel', () => { @@ -192,6 +243,58 @@ describe('droid-config-manager', () => { const models = await listCcsModels(); expect(models.size).toBe(0); }); + + it('should normalize legacy object-map customModels', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: { + gemini: { + model: 'opus', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy', + provider: 'anthropic', + }, + invalid: { + model: 'x', + baseUrl: 'x', + }, + }, + }) + ); + + const models = await listCcsModels(); + expect(models.size).toBe(1); + expect(models.has('gemini')).toBe(true); + }); + + it('should ignore malformed customModels entries', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [null, 123, 'bad', { displayName: 'CCS ok', model: 'x', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }], + }) + ); + + const models = await listCcsModels(); + expect(models.size).toBe(1); + expect(models.has('ok')).toBe(true); + }); + + it('should reject symlinked settings file on read', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + const target = path.join(factoryDir, 'real-settings.json'); + fs.writeFileSync(target, JSON.stringify({ customModels: [] })); + fs.symlinkSync(target, path.join(factoryDir, 'settings.json')); + + await expect(listCcsModels()).rejects.toThrow(/settings\.json is a symlink/); + }); }); describe('pruneOrphanedModels', () => { diff --git a/tests/unit/targets/droid-detector.test.ts b/tests/unit/targets/droid-detector.test.ts new file mode 100644 index 00000000..17eaabe5 --- /dev/null +++ b/tests/unit/targets/droid-detector.test.ts @@ -0,0 +1,53 @@ +/** + * Unit tests for Droid detector edge cases + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { detectDroidCli, checkDroidVersion } from '../../../src/targets/droid-detector'; + +describe('droid-detector', () => { + let tmpDir: string; + let originalPath: string | undefined; + let originalDroidPath: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-detector-test-')); + originalPath = process.env.PATH; + originalDroidPath = process.env.CCS_DROID_PATH; + process.env.PATH = ''; + }); + + afterEach(() => { + if (originalPath !== undefined) process.env.PATH = originalPath; + else delete process.env.PATH; + + if (originalDroidPath !== undefined) process.env.CCS_DROID_PATH = originalDroidPath; + else delete process.env.CCS_DROID_PATH; + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should prefer CCS_DROID_PATH when it points to a file', () => { + const fakeDroid = path.join(tmpDir, 'droid'); + fs.writeFileSync(fakeDroid, '#!/bin/sh\necho droid\n'); + process.env.CCS_DROID_PATH = fakeDroid; + + expect(detectDroidCli()).toBe(fakeDroid); + }); + + it('should fall back (null) when CCS_DROID_PATH points to directory', () => { + process.env.CCS_DROID_PATH = tmpDir; + expect(detectDroidCli()).toBeNull(); + }); + + it('should fall back (null) when CCS_DROID_PATH does not exist', () => { + process.env.CCS_DROID_PATH = path.join(tmpDir, 'missing-droid'); + expect(detectDroidCli()).toBeNull(); + }); + + it('checkDroidVersion should be non-throwing for invalid binaries', () => { + expect(() => checkDroidVersion(path.join(tmpDir, 'missing-droid'))).not.toThrow(); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index ab437a2d..d6485d49 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -2,6 +2,9 @@ * Unit tests for target registry and adapters */ import { describe, it, expect, beforeEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { registerTarget, getTarget, @@ -112,11 +115,14 @@ describe('DroidAdapter', () => { expect(adapter.supportsProfileType('account')).toBe(false); }); - it('should support non-account profile types', () => { + it('should support settings and default profile types', () => { expect(adapter.supportsProfileType('settings')).toBe(true); - expect(adapter.supportsProfileType('cliproxy')).toBe(true); expect(adapter.supportsProfileType('default')).toBe(true); - expect(adapter.supportsProfileType('copilot')).toBe(true); + }); + + it('should NOT support cliproxy and copilot profile types', () => { + expect(adapter.supportsProfileType('cliproxy')).toBe(false); + expect(adapter.supportsProfileType('copilot')).toBe(false); }); it('should build args with -m custom:ccs- prefix', () => { @@ -137,4 +143,44 @@ describe('DroidAdapter', () => { expect(env['ANTHROPIC_BASE_URL']).toBeUndefined(); expect(env['ANTHROPIC_AUTH_TOKEN']).toBeUndefined(); }); + + it('prepareCredentials should reject missing required credentials', async () => { + await expect( + adapter.prepareCredentials({ + profile: 'gemini', + baseUrl: '', + apiKey: 'dummy', + }) + ).rejects.toThrow(/ANTHROPIC_BASE_URL/); + + await expect( + adapter.prepareCredentials({ + profile: 'gemini', + baseUrl: 'http://localhost:8317', + apiKey: '', + }) + ).rejects.toThrow(/ANTHROPIC_AUTH_TOKEN/); + }); + + it('prepareCredentials should persist valid credentials', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-adapter-test-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + + try { + await adapter.prepareCredentials({ + profile: 'gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy-key', + model: 'claude-opus-4-6', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + expect(fs.existsSync(settingsPath)).toBe(true); + } finally { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 0d609add..03ca380a 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -51,11 +51,19 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); - it('should not match ccsd with .exe extension', () => { - // .exe is not stripped — ccsd.exe won't match 'ccsd' in the map - // This is intentional — npm creates .cmd shims, not .exe + it('should strip .ps1 extension on Windows argv[0]', () => { + process.argv = ['node', 'ccsd.ps1']; + expect(resolveTargetType([])).toBe('droid'); + }); + + it('should strip .exe extension on Windows argv[0]', () => { process.argv = ['node', 'ccsd.exe']; - expect(resolveTargetType([])).toBe('claude'); + expect(resolveTargetType([])).toBe('droid'); + }); + + it('should handle full path argv[0]', () => { + process.argv = ['node', '/usr/local/bin/ccsd']; + expect(resolveTargetType([])).toBe('droid'); }); it('should prioritize --target over argv[0]', () => { @@ -72,6 +80,31 @@ describe('resolveTargetType', () => { process.argv = ['node', 'ccs']; expect(() => resolveTargetType(['--target', 'invalid'])).toThrow(/Unknown target "invalid"/); }); + + it('should support --target= form', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target=droid'])).toBe('droid'); + }); + + it('should throw when --target is missing value', () => { + process.argv = ['node', 'ccs']; + expect(() => resolveTargetType(['--target'])).toThrow(/--target requires a value/); + }); + + it('should throw when --target value is another flag', () => { + process.argv = ['node', 'ccs']; + expect(() => resolveTargetType(['--target', '--help'])).toThrow(/--target requires a value/); + }); + + it('should use last --target flag when repeated', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target', 'droid', '--target=claude'])).toBe('claude'); + }); + + it('should ignore --target after option terminator', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--', '--target', 'droid'])).toBe('claude'); + }); }); describe('stripTargetFlag', () => { @@ -88,9 +121,42 @@ describe('stripTargetFlag', () => { expect(stripTargetFlag(args)).toEqual(['gemini', '-p', 'hello']); }); + it('should remove --target= form', () => { + expect(stripTargetFlag(['gemini', '--target=droid', '--verbose'])).toEqual([ + 'gemini', + '--verbose', + ]); + }); + + it('should remove repeated --target flags', () => { + expect(stripTargetFlag(['--target', 'droid', 'gemini', '--target=claude', '--verbose'])).toEqual( + ['gemini', '--verbose'] + ); + }); + + it('should throw when --target has no value', () => { + expect(() => stripTargetFlag(['gemini', '--target'])).toThrow(/--target requires a value/); + }); + + it('should throw when --target value is another flag', () => { + expect(() => stripTargetFlag(['gemini', '--target', '--help'])).toThrow( + /--target requires a value/ + ); + }); + it('should not modify the original array', () => { const args = ['--target', 'droid', 'gemini']; stripTargetFlag(args); expect(args).toEqual(['--target', 'droid', 'gemini']); }); + + it('should preserve args after option terminator', () => { + expect(stripTargetFlag(['glm', '--', '--target', 'droid', '-p'])).toEqual([ + 'glm', + '--', + '--target', + 'droid', + '-p', + ]); + }); });