diff --git a/docs/browser-automation.md b/docs/browser-automation.md index a8ceb4ad..759a3837 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -1,6 +1,6 @@ # Browser Automation -Last Updated: 2026-04-19 +Last Updated: 2026-05-11 CCS provides browser automation through two separate runtime paths: @@ -127,6 +127,8 @@ CCS still supports environment-variable overrides for backward compatibility. | `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir | | `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory | | `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override | +| `CCS_BROWSER_UPLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for local files that browser upload tools may read | +| `CCS_BROWSER_DOWNLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for caller-provided browser download directories | If an override is active, Browser status surfaces should report that the current session is being managed externally by environment variables. @@ -144,6 +146,22 @@ Config-backed Browser Attach always passes an explicit DevTools port to the runt effective value is the default `9222`. Metadata-based port discovery is preserved only for the legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not set. +### Browser File Transfer Safety + +Claude Browser Attach file-transfer tools intentionally use a deny-by-default filesystem boundary: + +- Downloads without an explicit `downloadPath` go to a CCS-created temporary session directory. +- A caller-provided `downloadPath` must be inside that temporary session directory or inside one of + the directories listed in `CCS_BROWSER_DOWNLOAD_ROOTS`. +- Local upload and drag-and-drop files must be inside the temporary session download directory or + inside one of the directories listed in `CCS_BROWSER_UPLOAD_ROOTS`. +- Hidden path segments and common secret locations/files, such as `.ssh`, `.aws`, `.ccs`, + `.claude`, `.env`, and private-key filenames, are rejected even inside an allowed root. +- Each file-transfer call is limited to 10 files, and each local file must be at most 10 MiB. + +Set upload/download roots only to purpose-built scratch directories. Do not point these variables at +your home directory, a source checkout with secrets, or a real cloud/tooling config directory. + ## Managed Runtime Files - `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 5d203769..afe1d761 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -273,7 +273,8 @@ src/ ### Native Codex Runtime Target - Dedicated runtime entrypoints: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts`, while `ccsxp` resolves through `src/bin/ccsxp-runtime.ts`; all three set `CCS_INTERNAL_ENTRY_TARGET=codex` before delegating to `src/targets/target-resolver.ts`. -- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. +- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process. +- Implicit Codex launches such as `ccs --target codex` and `ccsxp` use native Codex default mode even when the CCS default profile is a Claude account. Explicit unsupported profiles such as `ccs work --target codex` still fail fast with native-vs-pool guidance. - `argv[0]` alias mapping still exists in `src/targets/target-resolver.ts` for same-binary/custom alias scenarios, but the built-in npm bins above do not depend on that map at runtime. - Metadata boundary: `src/targets/target-metadata.ts` keeps Codex runtime-only in v1, so persisted default targets remain `claude | droid`. - Compatibility guardrails: `src/targets/target-runtime-compatibility.ts` centralizes which profile types can execute on Codex. diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index af5f8b52..891a42ee 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -6,9 +6,9 @@ CLI commands for managing CCS dashboard authentication. ## Overview -The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, including when the runtime's default bind is network-accessible or when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`. +The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, such as when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`. -Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it. +Authentication is **disabled by default** for backward compatibility, while `ccs config` binds to `localhost` by default. Use the CLI to configure and enable auth before exposing the dashboard to other devices. CCS does **not** ship a default dashboard username or password. When someone opens the dashboard from a non-loopback/IP address before auth is enabled, the UI now shows a setup state instead of an ambiguous login form. The host owner must run `ccs config auth setup`, or the user should switch back to the localhost URL if they are on the same machine. diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 5af341ee..f3d06468 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -521,6 +521,9 @@ ccsxp → CCS_INTERNAL_ENTRY_TARGET=codex → injects native `model_provider="cliproxy"` override → pins CODEX_HOME to native `~/.codex` unless `CCSXP_CODEX_HOME` is set +→ repairs `[model_providers.cliproxy]` in the active Codex `config.toml` +→ injects the effective CCS CLIProxy auth token into the provider's configured `env_key` +→ ignores the configured CCS default account/profile and stays in native Codex default mode ``` If a user launches CCS through a custom shim instead of the built-in package bins, target diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index 6449915e..9b739ff8 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -379,6 +379,47 @@ function outputSuccess(filePath, description, model, fileSize) { /** * Determine if hook should skip, with debug logging */ +function normalizePathForComparison(value) { + return process.platform === 'win32' ? value.toLowerCase() : value; +} + +function isPathWithinWorkspace(workspaceRoot, candidatePath) { + const relativePath = path.relative(workspaceRoot, candidatePath); + return ( + relativePath === '' || + (!relativePath.startsWith('..') && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath)) + ); +} + +function resolveWorkspaceFilePath(filePath) { + const workspaceRoot = (() => { + try { + return fs.realpathSync(process.cwd()); + } catch { + return path.resolve(process.cwd()); + } + })(); + const absolutePath = path.resolve(process.cwd(), filePath); + const realFilePath = fs.realpathSync(absolutePath); + + if ( + !isPathWithinWorkspace( + normalizePathForComparison(workspaceRoot), + normalizePathForComparison(realFilePath) + ) + ) { + debugLog('Skipping: file is outside the current workspace', { + workspaceRoot, + filePath: realFilePath, + }); + return null; + } + + return realFilePath; +} + function shouldSkipHook() { if (process.env.CCS_IMAGE_ANALYSIS_SKIP_HOOK === '1') { debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP_HOOK=1'); @@ -484,11 +525,16 @@ async function processHook() { process.exit(0); } - const debugContext = getDebugContext(filePath, null); + const workspaceFilePath = resolveWorkspaceFilePath(filePath); + if (!workspaceFilePath) { + process.exit(0); + } + + const debugContext = getDebugContext(workspaceFilePath, null); debugLog('Image analysis runtime prepared', debugContext); - const result = await analyzeFile(filePath); - outputSuccess(filePath, result.description, result.model, result.fileSize); + const result = await analyzeFile(workspaceFilePath); + outputSuccess(workspaceFilePath, result.description, result.model, result.fileSize); } catch (err) { if (process.env.CCS_DEBUG) { console.error('[CCS Hook] Error:', err.message); diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index a4234c78..cbc8d07a 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -151,6 +151,8 @@ const DEFAULT_DRAG_STEPS = 5; const MAX_POINTER_ACTIONS = 25; const SESSION_START_SETTLE_WINDOW_MS = 250; const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024; +const MAX_LOCAL_TRANSFER_FILE_BYTES = 10 * 1024 * 1024; +const MAX_LOCAL_TRANSFER_FILES = 10; const SAFE_ARTIFACT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const SESSION_CANCELED_ERROR_CODE = 'SESSION_CANCELED'; @@ -177,6 +179,37 @@ const recentDownloads = []; const interceptSessionsByPageId = new Map(); let browserDownloadSession = null; let sessionDownloadDir = ''; +const SENSITIVE_LOCAL_PATH_SEGMENTS = new Set([ + '.ssh', + '.gnupg', + '.aws', + '.azure', + '.kube', + '.docker', + '.npmrc', + '.netrc', + '.pypirc', + '.config', + '.claude', + '.ccs', +]); +const SENSITIVE_LOCAL_FILE_NAMES = new Set([ + '.env', + 'id_rsa', + 'id_dsa', + 'id_ecdsa', + 'id_ed25519', + 'known_hosts', + 'authorized_keys', + 'credentials', + 'credentials.json', + 'config.json', + 'settings.json', + 'history', + '.bash_history', + '.zsh_history', + '.fish_history', +]); const MAX_RECENT_REQUESTS = 100; const MAX_RECENT_DOWNLOADS = 100; const FETCH_FAIL_ERROR_REASON = 'Failed'; @@ -1438,10 +1471,105 @@ function getSessionDownloadPath() { return sessionDownloadDir; } +function splitConfiguredPathRoots(value) { + return String(value || '') + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => path.resolve(entry)); +} + +function getDownloadSafeRoots() { + return [ + getSessionDownloadPath(), + ...splitConfiguredPathRoots(process.env.CCS_BROWSER_DOWNLOAD_ROOTS), + ]; +} + +function getUploadSafeRoots() { + return [ + getSessionDownloadPath(), + ...splitConfiguredPathRoots(process.env.CCS_BROWSER_UPLOAD_ROOTS), + ]; +} + +function getNearestExistingAncestor(candidatePath) { + let currentPath = candidatePath; + while (!fs.existsSync(currentPath)) { + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return currentPath; + } + currentPath = parentPath; + } + return currentPath; +} + +function resolveExistingRoot(rootPath) { + fs.mkdirSync(rootPath, { recursive: true }); + return fs.realpathSync(rootPath); +} + +function resolvePathWithRealAncestor(candidatePath) { + const resolvedPath = path.resolve(candidatePath); + const ancestorPath = getNearestExistingAncestor(resolvedPath); + const realAncestorPath = fs.realpathSync(ancestorPath); + const relativeSuffix = path.relative(ancestorPath, resolvedPath); + return relativeSuffix ? path.resolve(realAncestorPath, relativeSuffix) : realAncestorPath; +} + +function isPathInsideRoot(candidatePath, rootPath) { + const relativePath = path.relative(rootPath, candidatePath); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + +function findContainingRoot(candidatePath, rootPaths) { + return rootPaths.find((rootPath) => isPathInsideRoot(candidatePath, rootPath)) || ''; +} + +function getLocalPathSegments(candidatePath, rootPath) { + const rootSegments = path.resolve(rootPath).split(path.sep).filter(Boolean); + const relativePath = path.relative(rootPath, candidatePath); + const relativeSegments = relativePath.split(path.sep).filter(Boolean); + return [...rootSegments, ...relativeSegments]; +} + +function assertNoSensitiveLocalPathSegments(candidatePath, rootPath, label) { + const segments = getLocalPathSegments(candidatePath, rootPath); + for (const segment of segments) { + const normalizedSegment = segment.toLowerCase(); + if (normalizedSegment.startsWith('.') || SENSITIVE_LOCAL_PATH_SEGMENTS.has(normalizedSegment)) { + throw new Error(`${label} cannot include hidden or sensitive path segment: ${segment}`); + } + } + + const fileName = path.basename(candidatePath).toLowerCase(); + if (SENSITIVE_LOCAL_FILE_NAMES.has(fileName)) { + throw new Error( + `${label} cannot reference sensitive file name: ${path.basename(candidatePath)}` + ); + } +} + function ensureWritableDirectory(downloadPath) { - fs.mkdirSync(downloadPath, { recursive: true }); - fs.accessSync(downloadPath, fs.constants.W_OK); - return downloadPath; + const resolvedPath = path.resolve(downloadPath); + const candidatePath = resolvePathWithRealAncestor(resolvedPath); + const safeRoots = getDownloadSafeRoots().map(resolveExistingRoot); + const containingRoot = findContainingRoot(candidatePath, safeRoots); + if (!containingRoot) { + throw new Error( + 'downloadPath must be inside the browser session download directory or a CCS_BROWSER_DOWNLOAD_ROOTS entry' + ); + } + assertNoSensitiveLocalPathSegments(candidatePath, containingRoot, 'downloadPath'); + + fs.mkdirSync(resolvedPath, { recursive: true }); + const realDownloadPath = fs.realpathSync(resolvedPath); + if (!isPathInsideRoot(realDownloadPath, containingRoot)) { + throw new Error('downloadPath cannot traverse outside the allowed download root'); + } + fs.accessSync(realDownloadPath, fs.constants.W_OK); + return realDownloadPath; } function pushRecentDownload(entry) { @@ -2418,16 +2546,35 @@ function buildFileInputHandleExpression(selector, nth, frameSelector, pierceShad } function validateLocalFiles(files) { + if (files.length > MAX_LOCAL_TRANSFER_FILES) { + throw new Error(`files exceeds maximum of ${MAX_LOCAL_TRANSFER_FILES}`); + } + + const safeRoots = getUploadSafeRoots().map(resolveExistingRoot); return files.map((filePath) => { const resolvedPath = path.resolve(filePath); if (!fs.existsSync(resolvedPath)) { throw new Error(`file does not exist: ${resolvedPath}`); } - const stat = fs.statSync(resolvedPath); - if (!stat.isFile()) { - throw new Error(`file is not a regular file: ${resolvedPath}`); + const realFilePath = fs.realpathSync(resolvedPath); + const containingRoot = findContainingRoot(realFilePath, safeRoots); + if (!containingRoot) { + throw new Error( + 'file must be inside the browser session download directory or a CCS_BROWSER_UPLOAD_ROOTS entry' + ); } - return resolvedPath; + assertNoSensitiveLocalPathSegments(realFilePath, containingRoot, 'file'); + + const stat = fs.statSync(realFilePath); + if (!stat.isFile()) { + throw new Error(`file is not a regular file: ${realFilePath}`); + } + if (stat.size > MAX_LOCAL_TRANSFER_FILE_BYTES) { + throw new Error( + `file exceeds maximum size of ${MAX_LOCAL_TRANSFER_FILE_BYTES} bytes: ${realFilePath}` + ); + } + return realFilePath; }); } diff --git a/package.json b/package.json index 1ff7e317..a8dd7eb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.78.1", + "version": "7.78.1-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", diff --git a/src/bin/ccsxp-runtime.ts b/src/bin/ccsxp-runtime.ts index 3e314e34..273b8205 100644 --- a/src/bin/ccsxp-runtime.ts +++ b/src/bin/ccsxp-runtime.ts @@ -1,10 +1,12 @@ const os = require('os'); const path = require('path'); +const { CCSXP_CLIPROXY_SHORTCUT_ENV } = require('../targets/codex-cliproxy-provider-config'); const { stripTargetFlag } = require('../targets/target-resolver'); const { expandPath } = require('../utils/helpers'); const { fail } = require('../utils/ui'); process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex'; +process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = '1'; const CCSXP_CLIPROXY_OVERRIDE = 'model_provider="cliproxy"'; const DISALLOWED_CCSXP_CONFIG_KEY_REGEX = /^(model_provider|local_provider|profile)\s*=|^model_providers\./i; diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts index 7b95e19b..9846ef71 100644 --- a/src/channels/official-channels-runtime.ts +++ b/src/channels/official-channels-runtime.ts @@ -9,6 +9,7 @@ import { isClaudeCliVersionAtLeast, type ClaudeAuthStatus, } from '../utils/claude-detector'; +import { isClaudeSubcommandInvocation } from '../utils/claude-subcommand-detector'; export interface OfficialChannelDefinition { id: OfficialChannelId; @@ -337,6 +338,17 @@ export function resolveOfficialChannelsLaunchPlan( }; } + // Claude subcommands (agents, doctor, mcp, ...) don't accept official-channels + // plugin args. Skip silently — channels are session-only. Issue #1218. + if (isClaudeSubcommandInvocation(args)) { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages, + }; + } + if (!isDiscordChannelsSessionSupported(target, profileType)) { return { applied: false, diff --git a/src/channels/official-channels-store.ts b/src/channels/official-channels-store.ts index 5eb73dac..60e94079 100644 --- a/src/channels/official-channels-store.ts +++ b/src/channels/official-channels-store.ts @@ -11,6 +11,7 @@ import { isOfficialChannelTokenRequired, } from './official-channels-runtime'; import { getCcsDir } from '../config/config-loader-facade'; +import { listAccountInstancePaths } from '../management/instance-directory'; export type OfficialChannelTokenSource = 'saved_env' | 'process_env' | 'missing'; @@ -151,10 +152,8 @@ function listManagedClaudeConfigDirs(): string[] { return [...dirs]; } - for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { - if (entry.isDirectory()) { - dirs.add(path.join(instancesDir, entry.name)); - } + for (const instancePath of listAccountInstancePaths(instancesDir)) { + dirs.add(instancePath); } return [...dirs]; diff --git a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts index 33f62b38..82b83ca0 100644 --- a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts @@ -14,6 +14,13 @@ const remoteTarget: ProxyTarget = { isRemote: true, }; +const localTarget: ProxyTarget = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, +}; + afterEach(() => { restoreFetch(); }); @@ -80,6 +87,31 @@ describe('requestPasteCallbackStart', () => { }); }); +describe('OAuth start failure guidance', () => { + it('explains Codex paste-callback recovery in headless local mode', async () => { + const { buildOAuthStartFailureGuidance, formatOAuthStartFailureForCli } = await import( + `../oauth-start-failure-guidance?codex-local-guidance=${Date.now()}` + ); + + const guidance = buildOAuthStartFailureGuidance('codex', { + target: localTarget, + startPath: '/v0/management/codex-auth-url?is_webui=true', + cause: new Error('fetch failed'), + addAccount: true, + }); + const cliOutput = formatOAuthStartFailureForCli(guidance).join('\n'); + + expect(guidance.message).toContain('OpenAI Codex OAuth could not start'); + expect(guidance.endpoint).toBe( + 'http://127.0.0.1:8317/v0/management/codex-auth-url?is_webui=true' + ); + expect(cliOutput).toContain('ccs cliproxy start'); + expect(cliOutput).toContain('ccs codex --auth --add --paste-callback'); + expect(cliOutput).toContain('ssh -L 1455:localhost:1455 @'); + expect(cliOutput).toContain('ccs codex --auth --add --port-forward'); + }); +}); + describe('Gemini Plus OAuth credential diagnostics', () => { it('fails fast when Gemini uses Plus without OAuth client env', async () => { const { getGeminiPlusOAuthCredentialError } = await import( diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index ac58e0bc..bde903b3 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -58,6 +58,10 @@ import { import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; import { parseGitLabPatAuthResponse } from './gitlab-pat-response'; +import { + buildOAuthStartFailureGuidance, + formatOAuthStartFailureForCli, +} from './oauth-start-failure-guidance'; import { getProxyTarget, buildProxyUrl, @@ -647,6 +651,7 @@ async function handlePasteCallbackMode( options?: { kiroMethod?: OAuthOptions['kiroMethod']; gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl']; + add?: boolean; } ): Promise { // Resolve CLIProxyAPI target (local or remote based on config) @@ -661,13 +666,33 @@ async function handlePasteCallbackMode( // Request auth URL from CLIProxyAPI management endpoints when the selected // provider/method supports the manual start-url contract. let startData: PasteCallbackStartData; + let startPath = getPasteCallbackStartPath(provider, { + kiroMethod: options?.kiroMethod, + }); + const normalizedGitLabBaseUrl = + provider === 'gitlab' ? normalizeGitLabBaseUrl(options?.gitlabBaseUrl) : undefined; + if (startPath && normalizedGitLabBaseUrl) { + startPath += `&base_url=${encodeURIComponent(normalizedGitLabBaseUrl)}`; + } try { startData = await requestPasteCallbackStart(provider, target, { kiroMethod: options?.kiroMethod, + gitlabBaseUrl: options?.gitlabBaseUrl, }); } catch (error) { const startError = (error as Error).message; console.log(fail('Failed to start OAuth flow')); + if (startPath) { + const guidance = buildOAuthStartFailureGuidance(provider, { + target, + startPath, + cause: error, + addAccount: options?.add, + }); + for (const line of formatOAuthStartFailureForCli(guidance)) { + console.log(` ${line}`); + } + } warnPossible403Ban(provider, startError); return null; } @@ -1109,6 +1134,7 @@ export async function triggerOAuth( { kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined, + add, } ); } diff --git a/src/cliproxy/auth/oauth-start-failure-guidance.ts b/src/cliproxy/auth/oauth-start-failure-guidance.ts new file mode 100644 index 00000000..8d998efd --- /dev/null +++ b/src/cliproxy/auth/oauth-start-failure-guidance.ts @@ -0,0 +1,110 @@ +import type { CLIProxyProvider } from '../types'; +import { getOAuthCallbackPort, getProviderDisplayName } from '../provider-capabilities'; +import { buildProxyUrl, type ProxyTarget } from '../proxy/proxy-target-resolver'; + +export interface OAuthStartFailureGuidance { + error: 'cliproxy_oauth_start_failed'; + provider: CLIProxyProvider; + message: string; + details: string; + hints: string[]; + endpoint: string | null; + retryCommand: string; + portForwardCommand?: string; +} + +interface GuidanceOptions { + target: ProxyTarget; + startPath?: string | null; + cause?: unknown; + addAccount?: boolean; +} + +function getCauseMessage(cause: unknown): string | null { + if (cause instanceof Error && cause.message.trim()) { + return cause.message.trim(); + } + if (typeof cause === 'string' && cause.trim()) { + return cause.trim(); + } + return null; +} + +function buildAuthCommand( + provider: CLIProxyProvider, + flag: '--paste-callback' | '--port-forward', + addAccount?: boolean +): string { + const parts = ['ccs', provider, '--auth']; + if (addAccount) { + parts.push('--add'); + } + parts.push(flag); + return parts.join(' '); +} + +export function buildOAuthStartFailureGuidance( + provider: CLIProxyProvider, + options: GuidanceOptions +): OAuthStartFailureGuidance { + const { target, startPath, cause, addAccount } = options; + const displayName = getProviderDisplayName(provider); + const endpoint = startPath ? buildProxyUrl(target, startPath) : null; + const causeMessage = getCauseMessage(cause); + const retryCommand = buildAuthCommand(provider, '--paste-callback', addAccount); + const portForwardRetryCommand = buildAuthCommand(provider, '--port-forward', addAccount); + const callbackPort = getOAuthCallbackPort(provider); + const portForwardCommand = callbackPort + ? `ssh -L ${callbackPort}:localhost:${callbackPort} @` + : undefined; + + const targetDescription = target.isRemote + ? `remote CLIProxy management API at ${target.protocol}://${target.host}:${target.port}` + : `local CLIProxy management API at ${target.protocol}://${target.host}:${target.port}`; + + const message = `${displayName} OAuth could not start through the ${targetDescription}.`; + const details = [ + endpoint ? `Endpoint: ${endpoint}` : null, + causeMessage ? `Cause: ${causeMessage}` : null, + ] + .filter(Boolean) + .join(' '); + + const hints = target.isRemote + ? [ + 'Verify the remote CLIProxy server is running and reachable from this machine.', + 'Check cliproxy_server.remote.management_key or auth_token in CCS config.', + `After fixing the remote proxy, retry: ${retryCommand}`, + ] + : [ + 'Start local CLIProxy first: ccs cliproxy start', + `Then retry paste-callback mode: ${retryCommand}`, + ...(portForwardCommand + ? [ + `For SSH/VPS auth, open a tunnel from your local machine: ${portForwardCommand}`, + `Then run inside that SSH session: ${portForwardRetryCommand}`, + ] + : []), + ]; + + return { + error: 'cliproxy_oauth_start_failed', + provider, + message, + details, + hints, + endpoint, + retryCommand, + portForwardCommand, + }; +} + +export function formatOAuthStartFailureForCli(guidance: OAuthStartFailureGuidance): string[] { + const lines = [guidance.message]; + if (guidance.details) { + lines.push(guidance.details); + } + lines.push('Next steps:'); + lines.push(...guidance.hints.map((hint) => ` - ${hint}`)); + return lines; +} diff --git a/src/cliproxy/executor/claude-launcher.ts b/src/cliproxy/executor/claude-launcher.ts index c4f97a13..66663f05 100644 --- a/src/cliproxy/executor/claude-launcher.ts +++ b/src/cliproxy/executor/claude-launcher.ts @@ -24,6 +24,12 @@ import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy'; import { setupCleanupHandlers } from './session-bridge'; import { resolveRuntimeQuotaMonitorProviders } from './account-resolution'; +import { + isClaudeSubcommandInvocation, + stripClaudeCodeFeatureBlockingEnv, + stripClaudeSubcommandSessionArgs, + stripSubcommandBlockingEnv, +} from '../../utils/claude-subcommand-detector'; export interface ClaudeLaunchContext { /** Path to the Claude CLI executable */ @@ -90,18 +96,25 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise boolean; +} + +export interface ShouldKeepSessionProxiesAliveOptions { + code: number | null; + signal: NodeJS.Signals | null; + backgroundKeepAliveBaseUrl?: string; + hasBackgroundWorkerUsingBaseUrl: (baseUrl: string) => boolean; +} + +const CLAUDE_BG_WORKER_ARGS = ['--bg-spare', '--bg-pty-host'] as const; + +export function hasClaudeBackgroundWorkerUsingBaseUrlInProcessList( + processList: string, + baseUrl: string +): boolean { + const envNeedle = `ANTHROPIC_BASE_URL=${baseUrl}`; + return processList + .split(/\r?\n/) + .some( + (line) => line.includes(envNeedle) && CLAUDE_BG_WORKER_ARGS.some((arg) => line.includes(arg)) + ); +} + +export function hasClaudeBackgroundWorkerUsingBaseUrl(baseUrl: string): boolean { + if (process.platform === 'win32') { + return false; + } + + for (const args of [['auxEww'], ['auxeww'], ['eww', '-axo', 'pid=,command=']]) { + try { + const processList = execFileSync('ps', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(processList, baseUrl)) { + return true; + } + } catch { + continue; + } + } + + return false; +} + +export function shouldKeepSessionProxiesAlive( + options: ShouldKeepSessionProxiesAliveOptions +): boolean { + if (options.code !== 0 || options.signal) { + return false; + } + + const baseUrl = options.backgroundKeepAliveBaseUrl; + if (!baseUrl) { + return false; + } + + return options.hasBackgroundWorkerUsingBaseUrl(baseUrl); +} + /** * Check for existing proxy and handle version mismatch, or determine if new spawn needed */ @@ -180,7 +244,8 @@ export function setupCleanupHandlers( codexReasoningProxy: unknown, toolSanitizationProxy: unknown, httpsTunnel: unknown, - verbose: boolean + verbose: boolean, + keepAliveOptions: SessionProxyKeepAliveOptions = {} ): void { const log = (msg: string) => { if (verbose) { @@ -188,66 +253,67 @@ export function setupCleanupHandlers( } }; - const cleanup = () => { - stopQuotaMonitor(); - log('Parent signal received, cleaning up'); + let resourcesStopped = false; - if ( - codexReasoningProxy && - typeof codexReasoningProxy === 'object' && - 'stop' in codexReasoningProxy - ) { - (codexReasoningProxy as { stop: () => void }).stop(); + const stopResource = (resource: unknown) => { + if (resource && typeof resource === 'object' && 'stop' in resource) { + (resource as { stop: () => void }).stop(); } - - if ( - toolSanitizationProxy && - typeof toolSanitizationProxy === 'object' && - 'stop' in toolSanitizationProxy - ) { - (toolSanitizationProxy as { stop: () => void }).stop(); - } - - if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) { - (httpsTunnel as { stop: () => void }).stop(); - } - - // Unregister session, proxy keeps running (local mode only) - if (sessionId) { - unregisterSession(sessionId, sessionPort); - } - claude.kill('SIGTERM'); }; - claude.on('exit', (code, signal) => { + const stopSessionResources = () => { + if (resourcesStopped) return; + resourcesStopped = true; stopQuotaMonitor(); - log(`Claude exited: code=${code}, signal=${signal}`); + stopResource(codexReasoningProxy); + stopResource(toolSanitizationProxy); + stopResource(httpsTunnel); - if ( - codexReasoningProxy && - typeof codexReasoningProxy === 'object' && - 'stop' in codexReasoningProxy - ) { - (codexReasoningProxy as { stop: () => void }).stop(); - } - - if ( - toolSanitizationProxy && - typeof toolSanitizationProxy === 'object' && - 'stop' in toolSanitizationProxy - ) { - (toolSanitizationProxy as { stop: () => void }).stop(); - } - - if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) { - (httpsTunnel as { stop: () => void }).stop(); - } - - // Unregister this session (proxy keeps running for persistence) - only for local mode if (sessionId) { unregisterSession(sessionId, sessionPort); log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); } + }; + + const backgroundProbe = + keepAliveOptions.hasBackgroundWorkerUsingBaseUrl ?? hasClaudeBackgroundWorkerUsingBaseUrl; + + const startKeepAliveWatcher = (baseUrl: string) => { + const timer = setInterval(() => { + if (backgroundProbe(baseUrl)) return; + log('No Claude bg worker is using the session proxy; cleaning up'); + stopSessionResources(); + process.exit(0); + }, keepAliveOptions.keepAlivePollIntervalMs ?? 30000); + timer.unref(); + }; + + const cleanup = () => { + log('Parent signal received, cleaning up'); + stopSessionResources(); + claude.kill('SIGTERM'); + }; + + claude.on('exit', (code, signal) => { + log(`Claude exited: code=${code}, signal=${signal}`); + const backgroundKeepAliveBaseUrl = keepAliveOptions.backgroundKeepAliveBaseUrl; + + if ( + shouldKeepSessionProxiesAlive({ + code, + signal, + backgroundKeepAliveBaseUrl, + hasBackgroundWorkerUsingBaseUrl: backgroundProbe, + }) && + backgroundKeepAliveBaseUrl + ) { + stopQuotaMonitor(); + log(`Keeping session proxy alive for Claude bg worker: ${backgroundKeepAliveBaseUrl}`); + startKeepAliveWatcher(backgroundKeepAliveBaseUrl); + return; + } + + stopSessionResources(); if (signal) { process.kill(process.pid, signal as NodeJS.Signals); @@ -257,33 +323,8 @@ export function setupCleanupHandlers( }); claude.on('error', (error) => { - stopQuotaMonitor(); console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`)); - - if ( - codexReasoningProxy && - typeof codexReasoningProxy === 'object' && - 'stop' in codexReasoningProxy - ) { - (codexReasoningProxy as { stop: () => void }).stop(); - } - - if ( - toolSanitizationProxy && - typeof toolSanitizationProxy === 'object' && - 'stop' in toolSanitizationProxy - ) { - (toolSanitizationProxy as { stop: () => void }).stop(); - } - - if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) { - (httpsTunnel as { stop: () => void }).stop(); - } - - // Unregister session, proxy keeps running (local mode only) - if (sessionId) { - unregisterSession(sessionId, sessionPort); - } + stopSessionResources(); process.exit(1); }); diff --git a/src/commands/config-command-options.ts b/src/commands/config-command-options.ts index f219136d..0f4a6149 100644 --- a/src/commands/config-command-options.ts +++ b/src/commands/config-command-options.ts @@ -1,5 +1,6 @@ import { extractOption, hasAnyFlag, scanCommandArgs } from './arg-extractor'; import { getOfficialChannelsSupportMessage } from '../channels/official-channels-runtime'; +import { DEFAULT_DASHBOARD_HOST } from './config-dashboard-host'; const CONFIG_COMMAND_FLAGS = ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'] as const; @@ -22,6 +23,7 @@ function formatUnexpectedArgsError(tokens: string[]): string { export function parseConfigCommandArgs(args: string[]): ConfigCommandParseResult { const options: ConfigCommandOptions = { + host: DEFAULT_DASHBOARD_HOST, hostProvided: false, dev: false, }; @@ -121,7 +123,7 @@ export function showConfigCommandHelp(): void { console.log(''); console.log('Options:'); console.log(' --port, -p PORT Specify server port (default: auto-detect)'); - console.log(' --host, -H HOST Bind dashboard server host (default: system default)'); + console.log(' --host, -H HOST Bind dashboard server host (default: localhost)'); console.log(' --dev Development mode with Vite HMR'); console.log(' --help, -h Show this help message'); console.log(''); @@ -129,7 +131,7 @@ export function showConfigCommandHelp(): void { console.log(' ccs config Auto-detect available port'); console.log(' ccs config --port 3000 Use specific port'); console.log(' ccs config --host 0.0.0.0 Force all-interface binding for remote devices'); - console.log(' ccs config --host 127.0.0.1 Restrict dashboard to this machine'); + console.log(' ccs config --host localhost Restrict dashboard to this machine'); console.log(' ccs config --dev Development mode with hot reload'); console.log(' ccs config auth setup Configure dashboard login'); console.log(' ccs config channels Show Official Channels status'); diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 1e156462..91aa4ed8 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -176,7 +176,7 @@ export async function handleConfigCommand( port, dev: options.dev, }; - if (options.hostProvided && options.host) { + if (options.host) { serverOptions.host = normalizeDashboardHost(options.host); } diff --git a/src/commands/config-dashboard-host.ts b/src/commands/config-dashboard-host.ts index c0e10fb7..09aac96d 100644 --- a/src/commands/config-dashboard-host.ts +++ b/src/commands/config-dashboard-host.ts @@ -1,5 +1,7 @@ import * as os from 'os'; +export const DEFAULT_DASHBOARD_HOST = 'localhost'; + const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']); diff --git a/src/dispatcher/__tests__/profile-resolver.test.ts b/src/dispatcher/__tests__/profile-resolver.test.ts index 3200f9e3..5361eb1f 100644 --- a/src/dispatcher/__tests__/profile-resolver.test.ts +++ b/src/dispatcher/__tests__/profile-resolver.test.ts @@ -252,6 +252,65 @@ describe('resolveProfileAndTarget', () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it('keeps explicit codex cliproxy profile on the Codex target', async () => { + const mockCodexAdapter = { + displayName: 'Codex CLI', + detectBinary: mock(() => ({ path: '/usr/local/bin/codex', version: '1.0.0' })), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockCodexAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'codex' as const); + mockStripTargetFlag.mockImplementation((args: string[]) => { + const next: string[] = []; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === '--target') { + index += 1; + continue; + } + next.push(args[index]); + } + return next; + }); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: args[0] || 'default', + remainingArgs: args.slice(1), + })); + mockDetectProfileType.mockImplementation((profile: string) => + profile === 'codex' + ? { + type: 'cliproxy' as const, + name: 'codex', + provider: 'codex', + target: undefined, + } + : { + type: 'account' as const, + name: 'work', + target: undefined, + } + ); + + const result = await resolveProfileAndTarget({ + args: ['codex', '--target', 'codex', 'fix failing tests'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.profile).toBe('codex'); + expect(result.profileInfo.type).toBe('cliproxy'); + expect(result.resolvedTarget).toBe('codex'); + expect(result.targetAdapter).toBe(mockCodexAdapter); + expect(result.targetRemainingArgs).toEqual(['fix failing tests']); + expect(mockEvaluateTargetRuntimeCompatibility).toHaveBeenCalledWith( + expect.objectContaining({ + target: 'codex', + profileType: 'cliproxy', + cliproxyProvider: 'codex', + }) + ); + expect(exitSpy).not.toHaveBeenCalled(); + }); + it('resolves settings profile (glm) with --target droid and loads settings', async () => { // Settings loading only runs in the non-claude preflight block (resolvedTarget !== 'claude'). const mockDroidAdapter = { diff --git a/src/dispatcher/flows/settings-flow.ts b/src/dispatcher/flows/settings-flow.ts index 07674e54..a82b9273 100644 --- a/src/dispatcher/flows/settings-flow.ts +++ b/src/dispatcher/flows/settings-flow.ts @@ -44,6 +44,10 @@ import { normalizeDeprecatedGlmtEnv, } from '../../utils/glmt-deprecation'; import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings'; +import { + isClaudeSubcommandInvocation, + stripClaudeSubcommandSessionArgs, +} from '../../utils/claude-subcommand-detector'; import { resolveDroidProvider, evaluateTargetRuntimeCompatibility, @@ -367,11 +371,19 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise; } +function usesImplicitDefaultProfile(cleanArgs: string[]): boolean { + return cleanArgs.length === 0 || cleanArgs[0]?.startsWith('-') === true; +} + +function buildNativeCodexDefaultProfile(): ProfileDetectionResult { + return { + type: 'default', + name: 'default', + message: 'Using native Codex auth; CCS default profile is not applied to Codex target.', + }; +} + // ========== Profile and Target Resolver ========== /** @@ -112,7 +124,7 @@ export async function resolveProfileAndTarget( // Detect profile (strip --target flags before profile detection) const cleanArgs = stripTargetFlag(args); const { profile, remainingArgs } = detectProfile(cleanArgs); - const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); + let profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); let resolvedTarget: ReturnType; try { @@ -127,6 +139,10 @@ export async function resolveProfileAndTarget( throw error; } + if (resolvedTarget === 'codex' && usesImplicitDefaultProfile(cleanArgs)) { + profileInfo = buildNativeCodexDefaultProfile(); + } + // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) const claudeCliRaw = detectClaudeCli(); if (resolvedTarget === 'claude' && !claudeCliRaw) { diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts index 5c969a83..dd55ecee 100644 --- a/src/management/checks/profile-check.ts +++ b/src/management/checks/profile-check.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import { ok, fail, warn, info } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; import { getCcsDir } from '../../config/config-loader-facade'; +import { listAccountInstanceNames } from '../instance-directory'; const ora = createSpinner(); @@ -144,9 +145,7 @@ export class InstancesChecker implements IHealthChecker { return; } - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); + const instances = listAccountInstanceNames(instancesDir); if (instances.length === 0) { spinner.info(); diff --git a/src/management/checks/symlink-check.ts b/src/management/checks/symlink-check.ts index 56f7d0e4..c10851fb 100644 --- a/src/management/checks/symlink-check.ts +++ b/src/management/checks/symlink-check.ts @@ -8,6 +8,7 @@ import * as os from 'os'; import { ok, fail, warn } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; import { getCcsDir } from '../../config/config-loader-facade'; +import { listAccountInstanceNames } from '../instance-directory'; const ora = createSpinner(); @@ -180,9 +181,7 @@ export class SettingsSymlinksChecker implements IHealthChecker { return; } - const instances = fs - .readdirSync(instancesDir) - .filter((n) => fs.statSync(path.join(instancesDir, n)).isDirectory()); + const instances = listAccountInstanceNames(instancesDir); const broken = instances.filter((inst) => { const instSettings = path.join(instancesDir, inst, 'settings.json'); diff --git a/src/management/checks/system-check.ts b/src/management/checks/system-check.ts index 875c0976..7e6ceba3 100644 --- a/src/management/checks/system-check.ts +++ b/src/management/checks/system-check.ts @@ -5,7 +5,11 @@ import * as fs from 'fs'; import { spawn } from 'child_process'; import { getClaudeCliInfo } from '../../utils/claude-detector'; -import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor'; +import { + escapeShellArg, + getWindowsEscapedCommandShell, + stripClaudeCodeEnv, +} from '../../utils/shell-executor'; import { ok, fail } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; import { getCcsDir } from '../../config/config-loader-facade'; @@ -47,7 +51,7 @@ export class ClaudeCliChecker implements IHealthChecker { ? spawn([claudeCli, '--version'].map(escapeShellArg).join(' '), { stdio: 'pipe', timeout: 5000, - shell: true, + shell: getWindowsEscapedCommandShell(), env: stripClaudeCodeEnv(process.env), }) : spawn(claudeCli, ['--version'], { diff --git a/src/management/instance-directory.ts b/src/management/instance-directory.ts new file mode 100644 index 00000000..56b0b384 --- /dev/null +++ b/src/management/instance-directory.ts @@ -0,0 +1,28 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export function isAccountInstanceName(name: string): boolean { + return !name.startsWith('.'); +} + +export function listAccountInstanceNames(instancesDir: string): string[] { + if (!fs.existsSync(instancesDir)) { + return []; + } + + return fs.readdirSync(instancesDir).filter((name) => { + if (!isAccountInstanceName(name)) { + return false; + } + + try { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + } catch { + return false; + } + }); +} + +export function listAccountInstancePaths(instancesDir: string): string[] { + return listAccountInstanceNames(instancesDir).map((name) => path.join(instancesDir, name)); +} diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index e6d73717..ba23138e 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -15,6 +15,7 @@ import type { AccountContextPolicy } from '../auth/account-context'; import { getCcsHome } from '../utils/config-manager'; import { createLogger } from '../services/logging'; import { getCcsDir } from '../config/config-loader-facade'; +import { listAccountInstanceNames } from './instance-directory'; const logger = createLogger('management:instance-manager'); @@ -189,18 +190,7 @@ class InstanceManager { * List all instance names */ listInstances(): string[] { - if (!fs.existsSync(this.instancesDir)) { - return []; - } - - return fs.readdirSync(this.instancesDir).filter((name) => { - if (name.startsWith('.')) { - return false; - } - - const instancePath = path.join(this.instancesDir, name); - return fs.statSync(instancePath).isDirectory(); - }); + return listAccountInstanceNames(this.instancesDir); } /** diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index d9963679..6ce5b2ce 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -19,6 +19,7 @@ import { normalizePluginMetadataValue, } from './plugin-path-normalizer'; import { getCcsDir } from '../config/config-loader-facade'; +import { listAccountInstanceNames, listAccountInstancePaths } from './instance-directory'; export { normalizePluginMetadataContent, normalizePluginMetadataPathString, @@ -832,16 +833,8 @@ class SharedManager { path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), ]); - if (fs.existsSync(this.instancesDir)) { - for (const entry of fs.readdirSync(this.instancesDir, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name.startsWith('.')) { - continue; - } - - sourcePaths.add( - path.join(this.instancesDir, entry.name, 'plugins', 'known_marketplaces.json') - ); - } + for (const instancePath of listAccountInstancePaths(this.instancesDir)) { + sourcePaths.add(path.join(instancePath, 'plugins', 'known_marketplaces.json')); } if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { @@ -1037,14 +1030,10 @@ class SharedManager { // Update all instances to use new symlinks if (fs.existsSync(this.instancesDir)) { try { - const instances = fs.readdirSync(this.instancesDir); - - for (const instance of instances) { + for (const instance of listAccountInstanceNames(this.instancesDir)) { const instancePath = path.join(this.instancesDir, instance); try { - if (fs.statSync(instancePath).isDirectory()) { - this.linkSharedDirectories(instancePath); - } + this.linkSharedDirectories(instancePath); } catch (_err) { console.log(warn(`Failed to update instance ${instance}: ${(_err as Error).message}`)); } @@ -1081,10 +1070,7 @@ class SharedManager { return; } - const instances = fs.readdirSync(this.instancesDir).filter((name) => { - const instancePath = path.join(this.instancesDir, name); - return fs.statSync(instancePath).isDirectory(); - }); + const instances = listAccountInstanceNames(this.instancesDir); let migrated = 0; let skipped = 0; diff --git a/src/proxy/proxy-daemon-entry.ts b/src/proxy/proxy-daemon-entry.ts index 8f3b7e24..abf06ce2 100644 --- a/src/proxy/proxy-daemon-entry.ts +++ b/src/proxy/proxy-daemon-entry.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import { resolveOpenAICompatProfileConfig } from './profile-router'; import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; import { startOpenAICompatProxyServer } from './server/proxy-server'; @@ -9,6 +10,7 @@ interface RuntimeOptions { profileName: string; settingsPath: string; authToken: string; + authTokenFile: string; insecure: boolean; } @@ -18,6 +20,7 @@ function parseArgs(argv: string[]): RuntimeOptions { let profileName = ''; let settingsPath = ''; let authToken = ''; + let authTokenFile = ''; let insecure = false; for (let i = 0; i < argv.length; i++) { @@ -42,16 +45,36 @@ function parseArgs(argv: string[]): RuntimeOptions { authToken = argv[++i] || ''; continue; } + if (arg === '--auth-token-file' && argv[i + 1]) { + authTokenFile = argv[++i] || ''; + continue; + } if (arg === '--insecure') { insecure = true; } } - return { port, host, profileName, settingsPath, authToken, insecure }; + return { port, host, profileName, settingsPath, authToken, authTokenFile, insecure }; +} + +function readAuthToken(options: RuntimeOptions): string { + if (options.authTokenFile) { + try { + const token = fs.readFileSync(options.authTokenFile, 'utf8').trim(); + fs.unlinkSync(options.authTokenFile); + return token; + } catch (error) { + throw new Error(`Failed to read local proxy auth token file: ${(error as Error).message}`); + } + } + + return options.authToken; } function startRuntime(options: RuntimeOptions): void { - if (!options.authToken.trim()) { + const authToken = readAuthToken(options); + + if (!authToken.trim()) { throw new Error('Missing local proxy auth token'); } @@ -71,7 +94,7 @@ function startRuntime(options: RuntimeOptions): void { profile, host: options.host, port: options.port, - authToken: options.authToken, + authToken, insecure: options.insecure, }); server.once('error', (error) => { diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts index 8ca7bc05..0ed9b046 100644 --- a/src/proxy/proxy-daemon-state.ts +++ b/src/proxy/proxy-daemon-state.ts @@ -1,3 +1,4 @@ +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import { @@ -20,7 +21,40 @@ export interface OpenAICompatProxySession { } function ensureProxyDir(): void { - fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true }); + const proxyDir = getOpenAICompatProxyDir(); + fs.mkdirSync(proxyDir, { recursive: true, mode: 0o700 }); + fs.chmodSync(proxyDir, 0o700); +} + +function encodeProfileFileKey(profileName: string): string { + return encodeURIComponent(profileName); +} + +export function writeOpenAICompatProxyAuthTokenFile( + profileName: string, + authToken: string +): string { + ensureProxyDir(); + const tokenPath = path.join( + getOpenAICompatProxyDir(), + `.${encodeProfileFileKey(profileName)}.${crypto.randomBytes(8).toString('hex')}.token` + ); + const fd = fs.openSync(tokenPath, 'wx', 0o600); + try { + fs.writeFileSync(fd, authToken, 'utf8'); + } finally { + fs.closeSync(fd); + } + fs.chmodSync(tokenPath, 0o600); + return tokenPath; +} + +export function removeOpenAICompatProxyAuthTokenFile(tokenPath: string): void { + try { + fs.unlinkSync(tokenPath); + } catch { + // Best-effort cleanup. + } } function readPid(pidPath: string): number | null { @@ -43,7 +77,11 @@ export function getLegacyOpenAICompatProxyPid(): number | null { export function writeOpenAICompatProxyPid(profileName: string, pid: number): void { ensureProxyDir(); - fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8'); + fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), { + encoding: 'utf8', + mode: 0o600, + }); + fs.chmodSync(getOpenAICompatProxyPidPath(profileName), 0o600); } export function removeOpenAICompatProxyPid(profileName: string): void { @@ -80,11 +118,12 @@ export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession | export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void { ensureProxyDir(); - fs.writeFileSync( - getOpenAICompatProxySessionPath(session.profileName), - JSON.stringify(session, null, 2) + '\n', - 'utf8' - ); + const sessionPath = getOpenAICompatProxySessionPath(session.profileName); + fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); + fs.chmodSync(sessionPath, 0o600); } export function removeOpenAICompatProxySession(profileName: string): void { diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index a96caf85..22dac996 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -19,10 +19,12 @@ import { readOpenAICompatProxySession, removeLegacyOpenAICompatProxyPid, removeLegacyOpenAICompatProxySession, + removeOpenAICompatProxyAuthTokenFile, removeOpenAICompatProxyPid, removeOpenAICompatProxySession, resolveOpenAICompatProxyEntrypointCandidates, type OpenAICompatProxySession, + writeOpenAICompatProxyAuthTokenFile, writeOpenAICompatProxyPid, writeOpenAICompatProxySession, } from './proxy-daemon-state'; @@ -82,7 +84,8 @@ function generateProxyAuthToken(): string { async function withOpenAICompatProxyLock(operation: () => Promise): Promise { const proxyDir = getOpenAICompatProxyDir(); - await fs.promises.mkdir(proxyDir, { recursive: true }); + await fs.promises.mkdir(proxyDir, { recursive: true, mode: 0o700 }); + await fs.promises.chmod(proxyDir, 0o700); let release: (() => Promise) | undefined; try { @@ -543,6 +546,7 @@ export async function startOpenAICompatProxy( let timeout: NodeJS.Timeout | null = null; let stderr = ''; const authToken = generateProxyAuthToken(); + const authTokenFile = writeOpenAICompatProxyAuthTokenFile(profile.profileName, authToken); const commitState = () => { if (proc.pid) { writeOpenAICompatProxyPid(profile.profileName, proc.pid); @@ -563,9 +567,12 @@ export async function startOpenAICompatProxy( if (resolved) return; resolved = true; if (timeout) clearTimeout(timeout); - if (!result.success && persistState) { - removeOpenAICompatProxyPid(profile.profileName); - removeOpenAICompatProxySession(profile.profileName); + if (!result.success) { + removeOpenAICompatProxyAuthTokenFile(authTokenFile); + if (persistState) { + removeOpenAICompatProxyPid(profile.profileName); + removeOpenAICompatProxySession(profile.profileName); + } } resolve(result); }; @@ -582,8 +589,8 @@ export async function startOpenAICompatProxy( profile.profileName, '--settings-path', profile.settingsPath, - '--auth-token', - authToken, + '--auth-token-file', + authTokenFile, ...(options.insecure ? ['--insecure'] : []), '--ccs-openai-proxy-daemon', ], diff --git a/src/proxy/transformers/sse-stream-transformer.ts b/src/proxy/transformers/sse-stream-transformer.ts index dff2b7ce..1f15b8cb 100644 --- a/src/proxy/transformers/sse-stream-transformer.ts +++ b/src/proxy/transformers/sse-stream-transformer.ts @@ -50,6 +50,7 @@ export function createAnthropicErrorResponse( ): Response { const responseHeaders = new Headers(headers); responseHeaders.set('Content-Type', 'application/json'); + responseHeaders.delete('Content-Encoding'); responseHeaders.delete('Content-Length'); return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), { diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 31cf2104..ad3c805a 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -24,6 +24,14 @@ import { readCodexVersion, } from './codex-detector'; import { createLogger } from '../services/logging'; +import { getEffectiveApiKey } from '../cliproxy/auth/auth-token-manager'; +import { resolveLifecyclePort } from '../cliproxy/config/port-manager'; +import { + CCSXP_CLIPROXY_SHORTCUT_ENV, + CODEX_CLIPROXY_PROVIDER_ENV_KEY, + ensureCodexCliproxyProviderConfig, + isCcsxpCliproxyShortcut, +} from './codex-cliproxy-provider-config'; const adapterLogger = createLogger('targets:codex'); @@ -176,13 +184,25 @@ function prepareExplicitCodexHome( export class CodexAdapter implements TargetAdapter { readonly type: TargetType = 'codex'; readonly displayName = 'Codex CLI'; + private ccsxpCliproxyEnvKey = CODEX_CLIPROXY_PROVIDER_ENV_KEY; detectBinary(): TargetBinaryInfo | null { return getCodexBinaryInfo({ includeVersion: false, includeFeatures: false }); } async prepareCredentials(_creds: TargetCredentials): Promise { - // Codex uses transient -c overrides plus env_key injection. + if (!isCcsxpCliproxyShortcut()) { + return; + } + + try { + const providerRepair = await ensureCodexCliproxyProviderConfig(resolveLifecyclePort()); + this.ccsxpCliproxyEnvKey = providerRepair.envKey; + } catch (error) { + throw new Error( + `ccsxp could not repair the native Codex cliproxy provider: ${(error as Error).message}` + ); + } } buildArgs( @@ -258,7 +278,11 @@ export class CodexAdapter implements TargetAdapter { const env: NodeJS.ProcessEnv = { ...stripBrowserEnv(stripCodexSessionEnv(stripAnthropicEnv(process.env))), }; + delete env[CCSXP_CLIPROXY_SHORTCUT_ENV]; delete env[CODEX_RUNTIME_ENV_KEY]; + if (profileType === 'default' && isCcsxpCliproxyShortcut()) { + env[this.ccsxpCliproxyEnvKey || CODEX_CLIPROXY_PROVIDER_ENV_KEY] = getEffectiveApiKey(); + } if (profileType !== 'default') { if (!creds.apiKey?.trim()) { throw new Error('Codex target requires an API key for CCS-backed profile launches.'); diff --git a/src/targets/codex-cliproxy-provider-config.ts b/src/targets/codex-cliproxy-provider-config.ts new file mode 100644 index 00000000..6ef94a23 --- /dev/null +++ b/src/targets/codex-cliproxy-provider-config.ts @@ -0,0 +1,174 @@ +import * as os from 'os'; +import * as path from 'path'; +import { expandPath } from '../utils/helpers'; +import { + probeTomlObjectFile, + stringifyTomlObject, + writeTomlFileAtomic, +} from '../web-server/services/compatible-cli-toml-file-service'; + +export const CCSXP_CLIPROXY_SHORTCUT_ENV = 'CCSXP_CLIPROXY_SHORTCUT'; +export const CODEX_CLIPROXY_PROVIDER_ID = 'cliproxy'; +export const CODEX_CLIPROXY_PROVIDER_ENV_KEY = 'CLIPROXY_API_KEY'; +export const CODEX_CLIPROXY_PROVIDER_NAME = 'CLIProxy Codex'; + +export interface CodexCliproxyProviderRepairResult { + changed: boolean; + configPath: string; + displayPath: string; + envKey: string; +} + +function resolveCodexConfigPath(env: NodeJS.ProcessEnv = process.env): { + configPath: string; + displayPath: string; +} { + const baseDir = path.resolve( + env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(os.homedir(), '.codex') + ); + const displayBase = env.CODEX_HOME ? '$CODEX_HOME' : '~/.codex'; + return { + configPath: path.join(baseDir, 'config.toml'), + displayPath: `${displayBase}/config.toml`, + }; +} + +export function buildCodexCliproxyProviderBaseUrl(port: number): string { + return `http://127.0.0.1:${port}/api/provider/codex`; +} + +export function isCcsxpCliproxyShortcut(env: NodeJS.ProcessEnv = process.env): boolean { + return env[CCSXP_CLIPROXY_SHORTCUT_ENV] === '1'; +} + +function asObject(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function normalizeLocalProviderUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + if ( + (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && + url.pathname === '/api/provider/codex' + ) { + url.hostname = '127.0.0.1'; + return url.toString().replace(/\/$/, ''); + } + } catch { + return null; + } + return null; +} + +function resolveProviderEnvKey(provider: Record | null): string { + const envKey = provider?.env_key; + if (typeof envKey === 'string' && envKey.trim()) { + return envKey.trim(); + } + return CODEX_CLIPROXY_PROVIDER_ENV_KEY; +} + +function isProviderReady( + provider: Record, + expectedBaseUrl: string, + envKey: string +): boolean { + return ( + provider.name === CODEX_CLIPROXY_PROVIDER_NAME && + normalizeLocalProviderUrl(provider.base_url) === expectedBaseUrl && + provider.env_key === envKey && + provider.wire_api === 'responses' && + provider.requires_openai_auth === false && + provider.supports_websockets === false + ); +} + +function buildProviderConfig(baseUrl: string, envKey: string): Record { + return { + name: CODEX_CLIPROXY_PROVIDER_NAME, + base_url: baseUrl, + env_key: envKey, + wire_api: 'responses', + requires_openai_auth: false, + supports_websockets: false, + }; +} + +function buildProviderBlock(baseUrl: string): string { + return stringifyTomlObject({ + model_providers: { + [CODEX_CLIPROXY_PROVIDER_ID]: buildProviderConfig(baseUrl, CODEX_CLIPROXY_PROVIDER_ENV_KEY), + }, + }); +} + +function appendProviderBlock(rawText: string, baseUrl: string): string { + const prefix = rawText.trimEnd(); + const providerBlock = buildProviderBlock(baseUrl).trimEnd(); + return prefix ? `${prefix}\n\n${providerBlock}\n` : `${providerBlock}\n`; +} + +export async function ensureCodexCliproxyProviderConfig( + port: number, + env: NodeJS.ProcessEnv = process.env +): Promise { + const { configPath, displayPath } = resolveCodexConfigPath(env); + const fileProbe = await probeTomlObjectFile(configPath, 'Codex user config', displayPath); + + if (fileProbe.diagnostics.readError) { + throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.readError}`); + } + if (fileProbe.diagnostics.parseError) { + throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.parseError}`); + } + + const config = fileProbe.config ?? {}; + const modelProvidersValue = config.model_providers; + const providers = asObject(modelProvidersValue); + const expectedBaseUrl = buildCodexCliproxyProviderBaseUrl(port); + + if (modelProvidersValue !== undefined && !providers) { + throw new Error(`Cannot repair ${displayPath}: [model_providers] must be a table.`); + } + + if (!providers || !Object.prototype.hasOwnProperty.call(providers, CODEX_CLIPROXY_PROVIDER_ID)) { + await writeTomlFileAtomic({ + filePath: configPath, + rawText: appendProviderBlock(fileProbe.rawText, expectedBaseUrl), + expectedMtime: fileProbe.diagnostics.mtimeMs ?? undefined, + fileLabel: 'config.toml', + }); + return { changed: true, configPath, displayPath, envKey: CODEX_CLIPROXY_PROVIDER_ENV_KEY }; + } + + const currentProvider = asObject(providers[CODEX_CLIPROXY_PROVIDER_ID]); + if (!currentProvider) { + throw new Error( + `Cannot repair ${displayPath}: [model_providers.${CODEX_CLIPROXY_PROVIDER_ID}] must be a table.` + ); + } + + const envKey = resolveProviderEnvKey(currentProvider); + + if (isProviderReady(currentProvider, expectedBaseUrl, envKey)) { + return { changed: false, configPath, displayPath, envKey }; + } + + providers[CODEX_CLIPROXY_PROVIDER_ID] = { + ...currentProvider, + ...buildProviderConfig(expectedBaseUrl, envKey), + }; + + await writeTomlFileAtomic({ + filePath: configPath, + rawText: stringifyTomlObject(config), + expectedMtime: fileProbe.diagnostics.mtimeMs ?? undefined, + fileLabel: 'config.toml', + }); + return { changed: true, configPath, displayPath, envKey }; +} diff --git a/src/targets/target-runtime-compatibility.ts b/src/targets/target-runtime-compatibility.ts index 627479cf..a667417b 100644 --- a/src/targets/target-runtime-compatibility.ts +++ b/src/targets/target-runtime-compatibility.ts @@ -46,7 +46,7 @@ export function evaluateTargetRuntimeCompatibility( if (input.profileType === 'account') { return unsupported( 'Codex CLI does not support Claude account-based profiles.', - 'Use native Codex auth with: ccs --target codex' + 'Native Codex: ccs --target codex. CLIProxy Codex pool: ccs codex --target codex or ccsxp. Manage pool/routing with: ccs codex --auth and ccs cliproxy routing.' ); } diff --git a/src/utils/browser/claude-tool-args.ts b/src/utils/browser/claude-tool-args.ts index d0e3338b..89b0cd9c 100644 --- a/src/utils/browser/claude-tool-args.ts +++ b/src/utils/browser/claude-tool-args.ts @@ -2,6 +2,7 @@ import { hasExactFlagValue as hasExactClaudeFlagValue, splitArgsAtTerminator as splitClaudeArgsAtTerminator, } from '../claude-tool-args'; +import { isClaudeSubcommandInvocation } from '../claude-subcommand-detector'; const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; const BROWSER_STEERING_PROMPT = @@ -18,5 +19,7 @@ function ensureBrowserSteeringPrompt(args: string[]): string[] { } export function appendBrowserToolArgs(args: string[]): string[] { + // Subcommands reject `--append-system-prompt`. Issue #1218. + if (isClaudeSubcommandInvocation(args)) return args; return ensureBrowserSteeringPrompt(args); } diff --git a/src/utils/claude-detector.ts b/src/utils/claude-detector.ts index a0cea8ed..67eb2c56 100644 --- a/src/utils/claude-detector.ts +++ b/src/utils/claude-detector.ts @@ -2,7 +2,12 @@ import * as fs from 'fs'; import { execFileSync, execSync } from 'child_process'; import { expandPath } from './helpers'; import type { ClaudeCliInfo } from '../types'; -import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from './shell-executor'; +import { + escapeShellArg, + getWindowsEscapedCommandShell, + stripAnthropicEnv, + stripClaudeCodeEnv, +} from './shell-executor'; export interface ClaudeAuthStatus { loggedIn: boolean; @@ -164,11 +169,12 @@ function runClaudeCliCommand(args: string[], envOverrides?: NodeJS.ProcessEnv): } if (needsShell) { + const shell = getWindowsEscapedCommandShell(); return execSync([claudePath, ...args].map(escapeShellArg).join(' '), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, - shell: process.env.ComSpec || 'cmd.exe', + shell: typeof shell === 'string' ? shell : undefined, env, }).trim(); } diff --git a/src/utils/claude-subcommand-detector.ts b/src/utils/claude-subcommand-detector.ts new file mode 100644 index 00000000..dac5b7fa --- /dev/null +++ b/src/utils/claude-subcommand-detector.ts @@ -0,0 +1,191 @@ +/** + * Claude subcommand detection. + * + * Claude Code's CLI accepts both an interactive session form (`claude [prompt]`, + * possibly with `--print`) and explicit subcommands (`claude agents`, + * `claude doctor`, `claude mcp`, ...). Subcommand parsers reject most top-level + * session flags — e.g. `claude agents --append-system-prompt foo` exits with + * `error: unknown option '--append-system-prompt'`. + * + * CCS injects WebSearch / image-analysis / browser steering args + * (`--append-system-prompt`, `--disallowedTools`) for interactive sessions. + * Those flags must be skipped when the user is actually invoking a Claude + * subcommand, otherwise the subcommand fails or falls back to non-interactive + * mode (e.g. `claude agents` printing the list instead of opening the agent + * view — see issue #1218). + * + * Detection walks args left-to-right, skipping known value-taking top-level + * flags, and reports whether the first positional token matches a known + * Claude subcommand. + */ + +/** + * Known Claude CLI subcommands. Sourced from `claude --help` (v2.1.139). + * Keep in sync with upstream — additions are safe (over-skipping injection + * for an unknown command is acceptable; under-skipping is the bug). + */ +const CLAUDE_SUBCOMMANDS = new Set([ + 'agents', + 'auth', + 'auto-mode', + 'doctor', + 'install', + 'mcp', + 'plugin', + 'plugins', + 'project', + 'setup-token', + 'ultrareview', + 'update', + 'upgrade', +]); + +/** + * Top-level Claude flags that consume the next argv token as their value. + * Used so the detector doesn't mistake a flag value (e.g. `--name auth`) for + * a subcommand. Boolean flags are intentionally absent. + * + * Variadic flags (`--add-dir`, `--mcp-config`, etc.) only consume their + * immediate next token here; Commander.js' real variadic parsing isn't worth + * replicating since the goal is just to skip past one obvious value safely. + */ +const VALUE_TAKING_FLAGS = new Set([ + '--add-dir', + '--agent', + '--agents', + '--allowedTools', + '--allowed-tools', + '--append-system-prompt', + '--betas', + '--channels', + '--debug-file', + '--disallowedTools', + '--disallowed-tools', + '--effort', + '--fallback-model', + '--file', + '--input-format', + '--json-schema', + '--max-budget-usd', + '--mcp-config', + '--model', + '--name', + '-n', + '--output-format', + '--permission-mode', + '--plugin-dir', + '--plugin-url', + '--remote-control-session-name-prefix', + '--session-id', + '--setting-sources', + '--settings', + '--system-prompt', + '--teammate-mode', + '--tools', +]); + +const SUBCOMMAND_SESSION_ONLY_FLAGS = new Set([ + '--allow-dangerously-skip-permissions', + '--dangerously-skip-permissions', +]); + +const SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS = new Set([ + '--permission-mode', + '--teammate-mode', +]); + +/** + * Returns true when `args` look like a Claude subcommand invocation. + * + * Heuristic: + * 1. Walk args until the `--` terminator or end. + * 2. Skip known value-taking flags together with their next token. + * 3. Skip unknown `--flag=value` forms and bare `--flag` / `-x` tokens. + * 4. The first remaining positional token is the candidate. + * 5. Match against CLAUDE_SUBCOMMANDS. + * + * Anything after the candidate is irrelevant — once a subcommand is in play, + * the rest of the line belongs to that subcommand. + */ +export function isClaudeSubcommandInvocation(args: readonly string[]): boolean { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '--') return false; + + if (arg.startsWith('-')) { + if (VALUE_TAKING_FLAGS.has(arg)) { + // Skip the next token as the flag's value (when present and not another flag). + const next = args[i + 1]; + if (next !== undefined && !next.startsWith('-')) { + i += 1; + } + } + // `--flag=value`, bare boolean flags, and unknown short/long flags fall through. + continue; + } + + return CLAUDE_SUBCOMMANDS.has(arg); + } + + return false; +} + +export function stripClaudeSubcommandSessionArgs(args: readonly string[]): string[] { + if (!isClaudeSubcommandInvocation(args)) { + return [...args]; + } + + const out: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (SUBCOMMAND_SESSION_ONLY_FLAGS.has(arg)) { + continue; + } + + if (arg.startsWith('--permission-mode=') || arg.startsWith('--teammate-mode=')) { + continue; + } + + if (SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS.has(arg)) { + const next = args[i + 1]; + if (next !== undefined && !next.startsWith('-')) { + i += 1; + } + continue; + } + + out.push(arg); + } + + return out; +} + +/** + * Claude Code treats DISABLE_TELEMETRY as a feature kill switch for agent-view + * and background-session surfaces, not only as telemetry preference. + */ +const CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS = ['DISABLE_TELEMETRY'] as const; + +export function stripClaudeCodeFeatureBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const out: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(env)) { + if ( + CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS.includes( + key as (typeof CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS)[number] + ) + ) { + continue; + } + out[key] = value; + } + return out; +} + +/** + * Return a shallow copy of `env` with subcommand-blocking telemetry vars + * removed. Caller is responsible for only invoking this when args are a + * Claude subcommand invocation. + */ +export function stripSubcommandBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return stripClaudeCodeFeatureBlockingEnv(env); +} diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts index c4be4df1..c328158b 100644 --- a/src/utils/image-analysis/claude-tool-args.ts +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -14,6 +14,7 @@ import { hasManagedPromptFileArg, PROMPT_FLAG_INLINE, } from '../prompt-injection-strategy'; +import { isClaudeSubcommandInvocation } from '../claude-subcommand-detector'; const IMAGE_ANALYSIS_STEERING_PROMPT = { name: 'ccs-prompt-image-analysis-tool', @@ -46,6 +47,8 @@ function ensureImageAnalysisSteeringPrompt(args: string[]): string[] { } export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] { + // Subcommands reject session-only prompt flags. Issue #1218. + if (isClaudeSubcommandInvocation(args)) return args; return ensureImageAnalysisSteeringPrompt(args); } diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 62e0af04..69c94916 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -5,9 +5,16 @@ */ import { spawn, spawnSync, ChildProcess, type SpawnOptions } from 'child_process'; +import * as path from 'path'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; import { wireChildProcessSignals } from './signal-forwarder'; +import { + isClaudeSubcommandInvocation, + stripClaudeCodeFeatureBlockingEnv, + stripClaudeSubcommandSessionArgs, + stripSubcommandBlockingEnv, +} from './claude-subcommand-detector'; import SharedManager from '../management/shared-manager'; import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; @@ -49,6 +56,7 @@ const TMUX_SYNC_ENV_KEYS = [ ...ANTHROPIC_MODEL_ENV_KEYS, ...ANTHROPIC_ROUTING_ENV_KEYS, ]; +const DEFAULT_WINDOWS_CMD_SHELL = 'C:\\Windows\\System32\\cmd.exe'; /** * Strip inherited Anthropic routing/auth env while preserving model intent. @@ -210,15 +218,36 @@ export function escapeShellArg(arg: string): string { /** * Return the shell that matches escapeShellArg() quoting semantics. * - * On Windows, prefer ComSpec over a bare `cmd.exe` so escaped wrapper launches - * keep the same shell contract without depending on PATH lookup. + * On Windows, use an absolute, trusted system cmd.exe path instead of a bare + * executable name so wrapper launches cannot be hijacked through the current + * directory or PATH. ComSpec is accepted only when it resolves to that same + * system shell. */ export function getWindowsEscapedCommandShell(): SpawnOptions['shell'] { if (process.platform !== 'win32') { return true; } - return process.env.ComSpec || process.env.COMSPEC || 'cmd.exe'; + const systemRoot = [process.env.SystemRoot, process.env.SYSTEMROOT, process.env.windir].find( + (candidate): candidate is string => Boolean(candidate && path.win32.isAbsolute(candidate)) + ); + const trustedSystemCmd = systemRoot + ? path.win32.normalize(path.win32.join(systemRoot, 'System32', 'cmd.exe')) + : DEFAULT_WINDOWS_CMD_SHELL; + const trustedSystemCmdLower = trustedSystemCmd.toLowerCase(); + + for (const candidate of [process.env.ComSpec, process.env.COMSPEC]) { + if (!candidate || !path.win32.isAbsolute(candidate)) { + continue; + } + + const normalizedCandidate = path.win32.normalize(candidate); + if (normalizedCandidate.toLowerCase() === trustedSystemCmdLower) { + return normalizedCandidate; + } + } + + return trustedSystemCmd; } /** @@ -261,9 +290,21 @@ export function execClaude( ? stripAnthropicRoutingEnv(mergedEnv, envVars ?? undefined) : mergedEnv; + const effectiveArgs = isClaudeSubcommandInvocation(args) + ? stripClaudeSubcommandSessionArgs(args) + : args; + // Strip Claude Code nested session guard env var to allow CCS delegation // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) - const env = stripClaudeCodeEnv(effectiveMergedEnv); + let env = stripClaudeCodeFeatureBlockingEnv(stripClaudeCodeEnv(effectiveMergedEnv)); + + // For Claude subcommand invocations (`agents`, `mcp`, `doctor`, ...) strip + // telemetry-disable env vars that cause upstream Claude Code to fall back + // to non-interactive list mode instead of opening the subcommand TUI. + // Issue #1218. + if (isClaudeSubcommandInvocation(effectiveArgs)) { + env = stripSubcommandBlockingEnv(env); + } if (profileType !== 'account') { try { @@ -281,7 +322,7 @@ export function execClaude( if (isPowerShellScript) { child = spawn( 'powershell.exe', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args], + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...effectiveArgs], { stdio: 'inherit', windowsHide: true, @@ -290,7 +331,7 @@ export function execClaude( ); } else if (needsShell) { // When shell needed: concatenate into string to avoid DEP0190 warning - const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); + const cmdString = [claudeCli, ...effectiveArgs].map(escapeShellArg).join(' '); child = spawn(cmdString, { stdio: 'inherit', windowsHide: true, @@ -299,7 +340,7 @@ export function execClaude( }); } else { // When no shell needed: use array form (faster, no shell overhead) - child = spawn(claudeCli, args, { + child = spawn(claudeCli, effectiveArgs, { stdio: 'inherit', windowsHide: true, env, diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index a680fedd..0ff85d04 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -15,6 +15,7 @@ import { hasManagedPromptFileArg, PROMPT_FLAG_INLINE, } from '../prompt-injection-strategy'; +import { isClaudeSubcommandInvocation } from '../claude-subcommand-detector'; const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; const DISALLOWED_TOOLS_FLAG = '--disallowedTools'; @@ -135,5 +136,8 @@ function ensureWebSearchSteeringPrompt(args: string[]): string[] { } export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] { + // Claude subcommands (agents, doctor, mcp, ...) reject top-level session flags + // like `--append-system-prompt` and `--disallowedTools`. Issue #1218. + if (isClaudeSubcommandInvocation(args)) return args; return ensureWebSearchSteeringPrompt(ensureDisallowedNativeWebSearchTool(args)); } diff --git a/src/web-server/health/profile-checks.ts b/src/web-server/health/profile-checks.ts index 5e5e6561..1d6088e7 100644 --- a/src/web-server/health/profile-checks.ts +++ b/src/web-server/health/profile-checks.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import type { HealthCheck } from './types'; import { isUnifiedMode, loadUnifiedConfig } from '../../config/config-loader-facade'; +import { listAccountInstanceNames } from '../../management/instance-directory'; /** * Check profiles configuration (API profiles from config.json or config.yaml) @@ -108,9 +109,7 @@ export function checkInstances(ccsDir: string): HealthCheck { }; } - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); + const instances = listAccountInstanceNames(instancesDir); if (instances.length === 0) { return { diff --git a/src/web-server/health/symlink-checks.ts b/src/web-server/health/symlink-checks.ts index 40b90b5f..de3c8a45 100644 --- a/src/web-server/health/symlink-checks.ts +++ b/src/web-server/health/symlink-checks.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { HealthCheck } from './types'; +import { listAccountInstanceNames } from '../../management/instance-directory'; /** * Check CCS symlinks health @@ -100,9 +101,7 @@ export function checkSettingsSymlinks(ccsDir: string, claudeDir: string): Health }; } - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(`${instancesDir}/${name}`).isDirectory(); - }); + const instances = listAccountInstanceNames(instancesDir); let broken = 0; for (const instance of instances) { diff --git a/src/web-server/index.ts b/src/web-server/index.ts index c00b5ae5..21a0aaa2 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -8,16 +8,23 @@ import express from 'express'; import http from 'http'; +import type { AddressInfo } from 'net'; import path from 'path'; import { WebSocketServer } from 'ws'; import { setupWebSocket } from './websocket'; -import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware'; +import { + authMiddleware, + createSessionMiddleware, + getDashboardWebSocketRejectionStatus, + isDashboardWebSocketUpgradeAllowed, +} from './middleware/auth-middleware'; import { requestLoggingMiddleware } from './middleware/request-logging-middleware'; import { ensureManagedModelPrefixes } from '../cliproxy/ai-providers/managed-model-prefixes'; import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver'; import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync'; import { shutdownUsageAggregator } from './usage/aggregator'; import { createLogger } from '../services/logging'; +import { DEFAULT_DASHBOARD_HOST, isLoopbackHost } from '../commands/config-dashboard-host'; export interface ServerOptions { port: number; @@ -32,6 +39,10 @@ export interface ServerInstance { cleanup: () => void; } +function getListenHost(options: ServerOptions): string { + return options.host || DEFAULT_DASHBOARD_HOST; +} + const logger = createLogger('web-server'); /** @@ -41,8 +52,7 @@ export async function startServer(options: ServerOptions): Promise { + const pathname = getUpgradePathname(request.url); + if (!pathname) { + rejectWebSocketUpgrade(socket, 400, 'Invalid WebSocket upgrade request'); + return; + } + + if (pathname !== '/ws') { + if (!options.dev) { + rejectWebSocketUpgrade(socket, 404, 'WebSocket endpoint not found'); + } + return; + } + + const response = new http.ServerResponse(request); + sessionMiddleware( + request as express.Request, + response as express.Response, + (error?: unknown) => { + if (error) { + rejectWebSocketUpgrade(socket, 500, 'WebSocket session validation failed'); + return; + } + + if (!isDashboardWebSocketUpgradeAllowed(request)) { + rejectWebSocketUpgrade( + socket, + getDashboardWebSocketRejectionStatus(request), + 'WebSocket access denied' + ); + return; + } + + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); + } + ); + }); + // WebSocket connection handler + file watcher const { cleanup: wsCleanup } = setupWebSocket(wss); @@ -138,11 +189,12 @@ export async function startServer(options: ServerOptions): Promise((resolve, reject) => { + const listenHost = getListenHost(options); const onError = (error: NodeJS.ErrnoException) => { logger.error('server.listen_failed', 'Dashboard server failed to start', { code: error.code || 'unknown', message: error.message, - host: options.host || null, + host: listenHost, port: options.port, }); cleanup(); @@ -153,8 +205,18 @@ export async function startServer(options: ServerOptions): Promise { server.off('error', onError); + try { + assertSafeDashboardBind(options, server.address()); + } catch (error) { + cleanup(); + server.close(() => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + return; + } + logger.info('server.listening', 'Dashboard server listening', { - host: options.host || '0.0.0.0', + host: listenHost, port: options.port, dev: Boolean(options.dev), }); @@ -164,12 +226,7 @@ export async function startServer(options: ServerOptions): Promise void }, + statusCode: 400 | 401 | 403 | 404 | 500, + message: string +): void { + socket.write( + `HTTP/1.1 ${statusCode} ${message}\r\n` + + 'Connection: close\r\n' + + 'Content-Type: text/plain; charset=utf-8\r\n' + + `Content-Length: ${Buffer.byteLength(message)}\r\n` + + '\r\n' + + message + ); + socket.destroy(); +} + +function assertSafeDashboardBind( + options: ServerOptions, + address: string | AddressInfo | null +): void { + const listenHost = getListenHost(options); + + if (!isLoopbackHost(listenHost) || typeof address === 'string' || !address) { + return; + } + + if (isLoopbackHost(address.address)) { + return; + } + + throw new Error( + `Dashboard host ${listenHost} resolved to non-loopback address ${address.address}; pass --host explicitly to allow network exposure.` + ); +} + +function formatListenError(error: NodeJS.ErrnoException, options: ServerOptions): string { + const listenHost = getListenHost(options); if (error.code === 'EADDRINUSE') { - return `Port ${options.port} is already in use`; + return `Unable to bind ${listenHost}:${options.port}; the address may be unavailable or the port may already be in use`; } - if (error.code === 'EADDRNOTAVAIL' && options.host) { - return `Cannot bind to ${options.host}:${options.port} on this machine`; + if (error.code === 'EADDRNOTAVAIL') { + return `Cannot bind to ${listenHost}:${options.port} on this machine`; } if (error.code === 'EACCES') { return `Permission denied while binding to port ${options.port}`; } - if (options.host) { - return `Cannot bind to ${options.host}:${options.port}: ${error.message}`; - } - - return error.message; + return `Cannot bind to ${listenHost}:${options.port}: ${error.message}`; } diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 4afc4703..39228916 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -3,6 +3,7 @@ * Session-based auth with httpOnly cookies for CCS dashboard. */ +import type { IncomingMessage } from 'http'; import type { NextFunction, Request, Response } from 'express'; import session from 'express-session'; import rateLimit from 'express-rate-limit'; @@ -151,6 +152,90 @@ export function isLoopbackRemoteAddress(value: string | undefined): boolean { ); } +function isLoopbackHostname(value: string | undefined): boolean { + if (!value) return false; + const normalized = value + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ''); + return ( + normalized === 'localhost' || + normalized.endsWith('.localhost') || + isLoopbackRemoteAddress(normalized) + ); +} + +function getSingleHeader(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +function parseHostHeader(value: string | undefined): URL | null { + if (!value) return null; + + try { + return new URL(`http://${value}`); + } catch { + return null; + } +} + +function isHttpOrigin(origin: URL): boolean { + return origin.protocol === 'http:' || origin.protocol === 'https:'; +} + +export function isDashboardWebSocketOriginAllowed(req: IncomingMessage): boolean { + const originHeader = getSingleHeader(req.headers.origin); + if (!originHeader) return true; + + let origin: URL; + try { + origin = new URL(originHeader); + } catch { + return false; + } + + if (!isHttpOrigin(origin)) { + return false; + } + + const host = parseHostHeader(getSingleHeader(req.headers.host)); + if (!host) { + return false; + } + + if (origin.host.toLowerCase() === host.host.toLowerCase()) { + return true; + } + + return ( + isLoopbackHostname(origin.hostname) && + isLoopbackHostname(host.hostname) && + origin.port === host.port + ); +} + +export function isDashboardWebSocketUpgradeAllowed(req: IncomingMessage): boolean { + if (!isDashboardWebSocketOriginAllowed(req)) { + return false; + } + + if (!isDashboardAuthEnabled()) { + return isLoopbackRemoteAddress(req.socket.remoteAddress); + } + + return Boolean((req as Request).session?.authenticated); +} + +export function getDashboardWebSocketRejectionStatus(req?: IncomingMessage): 401 | 403 { + if (req && !isDashboardWebSocketOriginAllowed(req)) { + return 403; + } + + if (!isDashboardAuthEnabled()) return 403; + + return 401; +} + export function requireLocalAccessWhenAuthDisabled( req: Request, res: Response, diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 90834888..72920473 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -74,6 +74,7 @@ import { getPlusOAuthCredentialError, getPlusAuthUrlCredentialError, } from '../../cliproxy/auth/oauth-handler'; +import { buildOAuthStartFailureGuidance } from '../../cliproxy/auth/oauth-start-failure-guidance'; import { getStoredConfiguredBackend } from '../../cliproxy/binary-manager'; const router = Router(); @@ -1065,6 +1066,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + let startPath: string | null = null; try { const authUrlProvider = CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; @@ -1077,20 +1079,22 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise provider === 'gitlab' && gitlabBaseUrl ? `&base_url=${encodeURIComponent(gitlabBaseUrl)}` : ''; + startPath = `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}`; // Call CLIProxyAPI to start OAuth and get auth URL // CLIProxyAPI management routes are under /v0/management prefix - const response = await fetch( - buildProxyUrl( - target, - `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}` - ), - { headers: buildManagementHeaders(target) } - ); + const response = await fetch(buildProxyUrl(target, startPath), { + headers: buildManagementHeaders(target), + }); if (!response.ok) { const error = await response.text(); - res.status(response.status).json({ error: error || 'Failed to start OAuth' }); + const guidance = buildOAuthStartFailureGuidance(provider as CLIProxyProvider, { + target, + startPath, + cause: error || `HTTP ${response.status}`, + }); + res.status(response.status).json(guidance); return; } @@ -1144,7 +1148,28 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise method: data.method || null, }); } catch (error) { - respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); + if (error instanceof SyntaxError) { + console.error( + `[cliproxy-auth-routes] Invalid OAuth start response for provider=${provider}: ${error.message}` + ); + res.status(502).json({ + error: 'cliproxy_oauth_start_invalid_response', + provider, + message: 'CLIProxyAPI returned an invalid OAuth start response.', + details: error.message, + }); + return; + } + + const authUrlProvider = + CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; + const guidance = buildOAuthStartFailureGuidance(provider as CLIProxyProvider, { + target, + startPath: startPath ?? `/v0/management/${authUrlProvider}-auth-url?is_webui=true`, + cause: error, + }); + console.error(`[cliproxy-auth-routes] ${guidance.message} ${guidance.details}`); + res.status(503).json(guidance); } }); diff --git a/src/web-server/routes/codex-routes.ts b/src/web-server/routes/codex-routes.ts index e7abaf84..26d26815 100644 --- a/src/web-server/routes/codex-routes.ts +++ b/src/web-server/routes/codex-routes.ts @@ -1,5 +1,6 @@ import type { Request, Response } from 'express'; import { Router } from 'express'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import { CodexRawConfigConflictError, CodexRawConfigValidationError, @@ -10,6 +11,14 @@ import { } from '../services/codex-dashboard-service'; const router = Router(); +const CODEX_CONFIG_ACCESS_ERROR = + 'Codex configuration endpoints require localhost access when dashboard auth is disabled.'; + +router.use('/config', (req: Request, res: Response, next) => { + if (requireLocalAccessWhenAuthDisabled(req, res, CODEX_CONFIG_ACCESS_ERROR)) { + next(); + } +}); router.get('/diagnostics', async (_req: Request, res: Response): Promise => { try { diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 2faa3f4c..2c739fb9 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -63,13 +63,90 @@ const { logRouteError, respondInternalError } = createRouteErrorHelpers('setting function resolvePathWithin(basePath: string, targetPath: string): string { const resolvedBase = path.resolve(basePath); - const resolvedTarget = path.resolve(targetPath); - if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`)) { + const resolvedTarget = path.resolve( + path.isAbsolute(targetPath) ? targetPath : path.join(resolvedBase, targetPath) + ); + if (!isPathWithin(resolvedBase, resolvedTarget)) { throw new Error('Invalid settings path'); } return resolvedTarget; } +function normalizePathForComparison(targetPath: string): string { + const resolvedPath = path.resolve(targetPath); + return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath; +} + +function isPathWithin(basePath: string, targetPath: string): boolean { + const normalizedBase = normalizePathForComparison(basePath); + const normalizedTarget = normalizePathForComparison(targetPath); + const relative = path.relative(normalizedBase, normalizedTarget); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function safeRealPath(targetPath: string): string | null { + try { + return fs.realpathSync.native(targetPath); + } catch { + return null; + } +} + +function getRealCcsDir(): string { + return safeRealPath(getCcsDir()) ?? path.resolve(getCcsDir()); +} + +function requirePathWithinRealCcsDir(targetPath: string): void { + if (!isPathWithin(getRealCcsDir(), targetPath)) { + throw new Error('Invalid settings path'); + } +} + +function requireExistingSettingsPathWithinCcs(settingsPath: string): boolean { + const realSettingsPath = safeRealPath(settingsPath); + if (!realSettingsPath) { + return false; + } + + requirePathWithinRealCcsDir(realSettingsPath); + return true; +} + +function findExistingParentRealPath(targetPath: string): string | null { + let currentPath = path.dirname(targetPath); + + while (true) { + const realParentPath = safeRealPath(currentPath); + if (realParentPath) { + return realParentPath; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return null; + } + currentPath = parentPath; + } +} + +function requireWritableSettingsPathWithinCcs(settingsPath: string): void { + const realSettingsPath = safeRealPath(settingsPath); + if (realSettingsPath) { + requirePathWithinRealCcsDir(realSettingsPath); + return; + } + + const realParentPath = findExistingParentRealPath(settingsPath); + if (!realParentPath) { + throw new Error('Invalid settings path'); + } + requirePathWithinRealCcsDir(realParentPath); +} + +function resolveSettingsCandidatePath(ccsDir: string, settingsPath: string): string { + return resolvePathWithin(ccsDir, expandPath(settingsPath)); +} + function requireSensitiveLocalAccess(req: Request, res: Response): boolean { return requireLocalAccessWhenAuthDisabled( req, @@ -114,24 +191,25 @@ function resolveSettingsPath(profileOrVariant: string): string { path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`) ); } - return path.resolve(resolveProviderSettingsPath(directProvider)); + return resolvePathWithin(resolvedCcsDir, resolveProviderSettingsPath(directProvider)); } // Check if this is a variant const variants = listVariants(); const variant = variants[profileOrVariant]; if (variant?.settings) { - return path.resolve(expandPath(variant.settings)); + return resolveSettingsCandidatePath(resolvedCcsDir, variant.settings); } + let configuredSettingsPath: string | undefined; try { - const configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant]; - if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) { - return path.resolve(expandPath(configuredSettingsPath)); - } + configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant]; } catch { // Fall back to the conventional ~/.ccs/.settings.json path below. } + if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) { + return resolveSettingsCandidatePath(resolvedCcsDir, configuredSettingsPath); + } // Regular profile settings return resolvePathWithin( @@ -328,13 +406,21 @@ async function resolvePreviewImageAnalysisStatus(profileOrVariant: string, setti } function writeSettingsAtomically(settingsPath: string, settings: Settings): void { + requireWritableSettingsPathWithinCcs(settingsPath); const tempPath = `${settingsPath}.tmp.${process.pid}`; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + requireWritableSettingsPathWithinCcs(tempPath); + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', { flag: 'wx' }); fs.renameSync(tempPath, settingsPath); } function withSettingsFileLock(settingsPath: string, callback: () => T): T { - const lockTarget = fs.existsSync(settingsPath) ? settingsPath : path.dirname(settingsPath); + requireWritableSettingsPathWithinCcs(settingsPath); + const lockTarget = safeRealPath(settingsPath) + ? settingsPath + : findExistingParentRealPath(settingsPath); + if (!lockTarget) { + throw new Error('Invalid settings path'); + } let release: (() => void) | undefined; try { @@ -357,6 +443,10 @@ function loadCanonicalProfileSettings( persist = false, strictPersist = false ): Settings { + if (!requireExistingSettingsPathWithinCcs(settingsPath)) { + throw new Error('Settings not found'); + } + const loaded = loadSettings(settingsPath); const canonicalized = canonicalizeProfileSettings(profileOrVariant, loaded); @@ -399,7 +489,7 @@ router.get('/:profile', async (req: Request, res: Response): Promise => { const { profile } = req.params; const settingsPath = resolveSettingsPath(profile); - if (!fs.existsSync(settingsPath)) { + if (!requireExistingSettingsPathWithinCcs(settingsPath)) { res.status(404).json({ error: 'Settings not found' }); return; } @@ -435,7 +525,7 @@ router.get('/:profile/raw', async (req: Request, res: Response): Promise = const { profile } = req.params; const settingsPath = resolveSettingsPath(profile); - if (!fs.existsSync(settingsPath)) { + if (!requireExistingSettingsPathWithinCcs(settingsPath)) { res.status(404).json({ error: 'Settings not found' }); return; } @@ -556,11 +646,13 @@ router.put('/:profile', (req: Request, res: Response): void => { const existingContent = fs.readFileSync(settingsPath, 'utf8'); if (existingContent !== newContent) { const backupDir = path.join(ccsDir, 'backups'); + requireWritableSettingsPathWithinCcs(path.join(backupDir, '.settings-backup-probe')); if (!fs.existsSync(backupDir)) { fs.mkdirSync(backupDir, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); + requireWritableSettingsPathWithinCcs(backupPath); fs.copyFileSync(settingsPath, backupPath); } } else { @@ -569,7 +661,8 @@ router.put('/:profile', (req: Request, res: Response): void => { } const tempPath = `${settingsPath}.tmp.${process.pid}`; - fs.writeFileSync(tempPath, newContent); + requireWritableSettingsPathWithinCcs(tempPath); + fs.writeFileSync(tempPath, newContent, { flag: 'wx' }); fs.renameSync(tempPath, settingsPath); newMtime = fs.statSync(settingsPath).mtime.getTime(); }); @@ -604,7 +697,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { const { profile } = req.params; const settingsPath = resolveSettingsPath(profile); - if (!fs.existsSync(settingsPath)) { + if (!requireExistingSettingsPathWithinCcs(settingsPath)) { res.json({ presets: [] }); return; } diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 537da727..c3e29d1c 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -41,6 +41,7 @@ import { getProviderModelKey, } from './model-identity'; import { getCcsDir } from '../../config/config-loader-facade'; +import { listAccountInstancePaths } from '../../management/instance-directory'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles @@ -77,15 +78,11 @@ function getInstancePaths(): string[] { } try { - const entries = fs.readdirSync(instancesDir, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(instancesDir, entry.name)) - .filter((instancePath) => { - // Only include instances that have a projects directory - const projectsPath = path.join(instancePath, 'projects'); - return fs.existsSync(projectsPath); - }); + return listAccountInstancePaths(instancesDir).filter((instancePath) => { + // Only include instances that have a projects directory + const projectsPath = path.join(instancePath, 'projects'); + return fs.existsSync(projectsPath); + }); } catch { console.error(fail('Failed to read CCS instances directory')); return []; diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index d13a3a83..422216d7 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -169,6 +169,7 @@ function invokeHook( input: JSON.stringify(input), encoding: 'utf8', env: buildHookEnv(env), + cwd: TEST_DIR, timeout: 10000, // 10 second timeout per test }); @@ -187,6 +188,7 @@ function invokeHookAsync( const child = spawn('node', [HOOK_PATH], { env: buildHookEnv(env), stdio: ['pipe', 'pipe', 'pipe'], + cwd: TEST_DIR, }); let stdout = ''; @@ -531,6 +533,7 @@ describe('Image Analyzer Hook', () => { input: 'not valid json', encoding: 'utf8', timeout: 5000, + cwd: TEST_DIR, env: { ...process.env, CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, diff --git a/tests/integration/image-analyzer-hook.test.ts b/tests/integration/image-analyzer-hook.test.ts index 57b8cc27..5c8d43fa 100644 --- a/tests/integration/image-analyzer-hook.test.ts +++ b/tests/integration/image-analyzer-hook.test.ts @@ -47,7 +47,10 @@ function enqueueResponses(...responses: MockResponse[]): void { queuedResponses = responses; } -function invokeHook(env: Record = {}): Promise { +function invokeHook( + env: Record = {}, + options: { filePath?: string; cwd?: string } = {} +): Promise { return new Promise((resolve, reject) => { const child = spawn('node', [HOOK_PATH], { env: { @@ -68,6 +71,7 @@ function invokeHook(env: Record = {}): Promise { ...env, }, stdio: ['pipe', 'pipe', 'pipe'], + cwd: options.cwd ?? TEST_DIR, }); let stdout = ''; @@ -98,7 +102,7 @@ function invokeHook(env: Record = {}): Promise { child.stdin.end( JSON.stringify({ tool_name: 'Read', - tool_input: { file_path: TEST_PNG_PATH }, + tool_input: { file_path: options.filePath ?? TEST_PNG_PATH }, }) ); }); @@ -198,6 +202,54 @@ describe('image analyzer hook regression coverage', () => { expect((requests[0].body as { model: string }).model).toBe('gpt-5.1-codex-mini'); }); + it('does not analyze files outside the current workspace', async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-outside-')); + const outsidePath = path.join(outsideDir, 'sensitive-outside-workspace.png'); + createTestPng(outsidePath); + + try { + const result = await invokeHook({}, { filePath: outsidePath }); + + expect(result.code).toBe(0); + expect(requests).toHaveLength(0); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it.if(process.platform === 'win32')( + 'analyzes Windows-style relative paths inside the current workspace', + async () => { + const nestedDir = path.join(TEST_DIR, 'windows-style-fixture-dir'); + const nestedPath = path.join(nestedDir, 'inside-workspace.png'); + fs.mkdirSync(nestedDir, { recursive: true }); + createTestPng(nestedPath); + + const result = await invokeHook({}, { filePath: path.win32.relative(TEST_DIR, nestedPath) }); + + expect(result.code).toBe(2); + expect(requests).toHaveLength(1); + } + ); + + it('does not analyze workspace symlinks that resolve outside the current workspace', async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-symlink-')); + const outsidePath = path.join(outsideDir, 'sensitive-symlink-target.png'); + const linkPath = path.join(TEST_DIR, 'linked-sensitive.png'); + createTestPng(outsidePath); + + try { + fs.symlinkSync(outsidePath, linkPath); + const result = await invokeHook({}, { filePath: linkPath }); + + expect(result.code).toBe(0); + expect(requests).toHaveLength(0); + } finally { + fs.rmSync(linkPath, { force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => { const result = await invokeHook({ CCS_CURRENT_PROVIDER: 'unknown-provider', diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 6fc5e15f..c19d09bb 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -71,6 +72,27 @@ async function getPortOutsideOpenAICompatAdaptiveRange(): Promise { throw new Error('No stale proxy fixture port found outside the adaptive range'); } +function readProcessCommandLine(pid: number): string | null { + if (process.platform === 'linux') { + try { + const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + return commandLine.split('\u0000').filter(Boolean).join(' ') || null; + } catch { + // Fall back to ps below for platforms without readable procfs entries. + } + } + + try { + return ( + execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], { + encoding: 'utf8', + }).trim() || null + ); + } catch { + return null; + } +} + describe('openai proxy daemon lifecycle', () => { it('starts, reports status, serves health/models, and stops', async () => { const port = await getPort(); @@ -101,18 +123,42 @@ describe('openai proxy daemon lifecycle', () => { const started = await startOpenAICompatProxy(profile, { port }); expect(started.success).toBe(true); expect(started.authToken).toBeTruthy(); + const authToken = started.authToken; + if (!authToken) { + throw new Error('Expected proxy auth token'); + } const status = await getOpenAICompatProxyStatus(); expect(status.running).toBe(true); expect(status.profileName).toBe('hf'); - expect(status.authToken).toBe(started.authToken); + expect(status.authToken).toBe(authToken); + + const sessionPath = getOpenAICompatProxySessionPath('hf'); + const proxyDir = path.dirname(sessionPath); + if (process.platform !== 'win32') { + expect(fs.statSync(proxyDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(sessionPath).mode & 0o777).toBe(0o600); + } + expect(fs.readdirSync(proxyDir).some((entry) => entry.endsWith('.token'))).toBe(false); + if (started.pid) { + const commandLine = readProcessCommandLine(started.pid); + if (!commandLine) { + console.warn( + `Skipping daemon argv assertion: could not read command line for PID ${started.pid}` + ); + } else { + expect(commandLine).toContain('--auth-token-file'); + expect(commandLine).not.toContain(authToken); + expect(commandLine).not.toMatch(/(^|\s)--auth-token(?:\s|=|$)/); + } + } const health = await fetch(`http://127.0.0.1:${port}/health`); expect(health.status).toBe(200); const models = (await ( await fetch(`http://127.0.0.1:${port}/v1/models`, { - headers: { 'x-api-key': started.authToken! }, + headers: { 'x-api-key': authToken }, }) ).json()) as { data?: Array<{ id: string }> }; expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']); diff --git a/tests/integration/proxy/messages-edge-cases.test.ts b/tests/integration/proxy/messages-edge-cases.test.ts index faefb833..5e63d90d 100644 --- a/tests/integration/proxy/messages-edge-cases.test.ts +++ b/tests/integration/proxy/messages-edge-cases.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as http from 'http'; import * as os from 'os'; import * as path from 'path'; +import * as zlib from 'zlib'; import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server'; import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; @@ -144,6 +145,36 @@ describe('openai proxy message edge cases', () => { }); }); + it('does not leak upstream content-encoding onto synthesized error responses', async () => { + const compressed = zlib.gzipSync( + JSON.stringify({ error: { message: 'invalid upstream request' } }) + ); + + await startProxyWithHandler((_req, res) => { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + 'Content-Length': String(compressed.length), + }); + res.end(compressed); + }); + + const response = await requestProxy({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }); + + expect(response.status).toBe(400); + expect(response.headers.get('content-encoding')).toBeNull(); + await expect(response.json()).resolves.toMatchObject({ + type: 'error', + error: { + type: 'invalid_request_error', + message: 'invalid upstream request', + }, + }); + }); + it('returns api_error when the upstream JSON response has no usable choices', async () => { await startProxyWithHandler((_req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); diff --git a/tests/unit/cliproxy/session-bridge-bg-keepalive.test.ts b/tests/unit/cliproxy/session-bridge-bg-keepalive.test.ts new file mode 100644 index 00000000..f911be55 --- /dev/null +++ b/tests/unit/cliproxy/session-bridge-bg-keepalive.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'bun:test'; +import { + hasClaudeBackgroundWorkerUsingBaseUrlInProcessList, + shouldKeepSessionProxiesAlive, +} from '../../../src/cliproxy/executor/session-bridge'; + +describe('session bridge background proxy keepalive', () => { + it('keeps per-session proxies alive after a clean Claude exit when a bg worker inherited the base URL', () => { + expect( + shouldKeepSessionProxiesAlive({ + code: 0, + signal: null, + backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex', + hasBackgroundWorkerUsingBaseUrl: () => true, + }) + ).toBe(true); + }); + + it('cleans up per-session proxies when no bg worker inherited the base URL', () => { + expect( + shouldKeepSessionProxiesAlive({ + code: 0, + signal: null, + backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex', + hasBackgroundWorkerUsingBaseUrl: () => false, + }) + ).toBe(false); + }); + + it('cleans up per-session proxies for signaled Claude exits', () => { + expect( + shouldKeepSessionProxiesAlive({ + code: null, + signal: 'SIGTERM', + backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex', + hasBackgroundWorkerUsingBaseUrl: () => true, + }) + ).toBe(false); + }); + + it('detects Claude bg workers that inherited the exact base URL', () => { + const baseUrl = 'http://127.0.0.1:64314/api/provider/codex'; + const processList = ` +123 /opt/claude/claude.exe --bg-spare /tmp/claim.sock ANTHROPIC_BASE_URL=${baseUrl} ANTHROPIC_AUTH_TOKEN=ccs-internal-managed +456 /opt/claude/claude.exe --bg-spare /tmp/claim.sock ANTHROPIC_BASE_URL=http://127.0.0.1:62075/api/provider/codex +789 /opt/claude/claude.exe --print ANTHROPIC_BASE_URL=${baseUrl} +`; + + expect(hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(processList, baseUrl)).toBe(true); + expect( + hasClaudeBackgroundWorkerUsingBaseUrlInProcessList( + processList, + 'http://127.0.0.1:65535/api/provider/codex' + ) + ).toBe(false); + }); +}); diff --git a/tests/unit/commands/config-command-options.test.ts b/tests/unit/commands/config-command-options.test.ts index abf95b1b..5da5b613 100644 --- a/tests/unit/commands/config-command-options.test.ts +++ b/tests/unit/commands/config-command-options.test.ts @@ -3,12 +3,12 @@ import { describe, expect, it } from 'bun:test'; import { parseConfigCommandArgs } from '../../../src/commands/config-command-options'; describe('config command options parser', () => { - it('defaults to system bind behavior without explicit host', () => { + it('defaults to a localhost-only bind without explicit host', () => { const result = parseConfigCommandArgs([]); expect(result.help).toBe(false); expect(result.error).toBeUndefined(); - expect(result.options.host).toBeUndefined(); + expect(result.options.host).toBe('localhost'); expect(result.options.hostProvided).toBe(false); }); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index bd80a4f0..f7af52f0 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -10,7 +10,7 @@ let logLines: string[] = []; let errorLines: string[] = []; let dashboardAuthEnabled = false; let startServerError: Error | null = null; -let mockServerBindHost = '::'; +let mockServerBindHost = '::1'; let originalConsoleLog: typeof console.log; let originalConsoleError: typeof console.error; let originalProcessExit: typeof process.exit; @@ -79,7 +79,7 @@ beforeEach(() => { errorLines = []; dashboardAuthEnabled = false; startServerError = null; - mockServerBindHost = '::'; + mockServerBindHost = '::1'; originalConsoleLog = console.log; originalConsoleError = console.error; @@ -140,19 +140,16 @@ describe('config command dashboard startup', () => { expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus'); }); - it('keeps the default startup path free of an explicit host override', async () => { + it('binds the default startup path to localhost only', async () => { await handleConfigCommand([], createTestDeps()); expect(startServerCalls).toHaveLength(1); - expect(startServerCalls[0]).toEqual({ port: 3000, dev: false }); + expect(startServerCalls[0]).toEqual({ port: 3000, dev: false, host: 'localhost' }); const rendered = logLines.join('\n'); - expect(rendered).toContain('Dashboard: http://localhost:3000'); - expect(rendered).toContain('Bind host: ::'); - expect(rendered).toContain( - 'Dashboard may be reachable from other devices that can connect to this machine.' - ); - expect(rendered).toContain('Protect it before sharing: ccs config auth setup'); + expect(rendered).toContain('Dashboard: http://[::1]:3000'); + expect(rendered).not.toContain('Dashboard may be reachable from other devices'); + expect(rendered).not.toContain('Protect it before sharing: ccs config auth setup'); expect(errorLines).toHaveLength(0); }); @@ -205,8 +202,8 @@ describe('config command dashboard startup', () => { expect(startServerCalls).toHaveLength(1); const rendered = logLines.join('\n'); - expect(rendered).toContain('Dashboard: http://localhost:3000'); - expect(rendered).toContain('Bind host: ::'); + expect(rendered).toContain('Dashboard: http://[::1]:3000'); + expect(rendered).not.toContain('Bind host:'); expect(errorLines).toHaveLength(0); } finally { networkInterfacesSpy.mockRestore(); diff --git a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts index c31c4b60..a06c3198 100644 --- a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts +++ b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts @@ -48,55 +48,59 @@ describe('ccs-browser MCP server - advanced interactions', () => { }, ]; - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 901, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] }, - }, - }, - { - jsonrpc: '2.0', - id: 902, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { - selector: '#frame-dropzone', - files: [invoicePath], - frameSelector: '#upload-frame', + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 901, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] }, }, }, - }, - { - jsonrpc: '2.0', - id: 903, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { - selector: '#shadow-dropzone', - files: [receiptPath], - pierceShadow: true, + { + jsonrpc: '2.0', + id: 902, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#frame-dropzone', + files: [invoicePath], + frameSelector: '#upload-frame', + }, }, }, - }, - { - jsonrpc: '2.0', - id: 904, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { - selector: '#cancel-dropzone', - files: [invoicePath], + { + jsonrpc: '2.0', + id: 903, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#shadow-dropzone', + files: [receiptPath], + pierceShadow: true, + }, }, }, - }, - ]); + { + jsonrpc: '2.0', + id: 904, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#cancel-dropzone', + files: [invoicePath], + }, + }, + }, + ], + { childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } } + ); expect(getResponseText(responses.find((message) => message.id === 901))).toContain( 'status: files-dropped' @@ -184,7 +188,8 @@ describe('ccs-browser MCP server - advanced interactions', () => { arguments: { selector: '#reject-dropzone', files: [okPath] }, }, }, - ] + ], + { childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } } ); expect(getResponseText(responses.find((message) => message.id === 904))).toContain( diff --git a/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts b/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts index dae29085..ebf42139 100644 --- a/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts +++ b/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'bun:test'; -import { runMcpRequests, getResponseText, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import { mkdirSync, realpathSync } from 'node:fs'; +import { delimiter } from 'node:path'; +import { + runMcpRequests, + getResponseText, + mkdtempSync, + writeFileSync, + tmpdir, + join, +} from './browser-mcp-test-harness'; import type { MockPageState } from './browser-mcp-test-harness'; describe('ccs-browser MCP server - downloads and file inputs', () => { @@ -60,9 +69,15 @@ describe('ccs-browser MCP server - downloads and file inputs', () => { { responseTimeoutMs: 12000 } ); - expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser'); - expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv'); - expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled'); + expect(getResponseText(responses.find((message) => message.id === 57))).toContain( + 'scope: browser' + ); + expect(getResponseText(responses.find((message) => message.id === 58))).toContain( + 'suggestedFilename: report.csv' + ); + expect(getResponseText(responses.find((message) => message.id === 60))).toContain( + 'status: canceled' + ); expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow'); expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1'); }); @@ -93,6 +108,60 @@ describe('ccs-browser MCP server - downloads and file inputs', () => { ); }); + it('restricts caller-provided download paths to configured safe roots', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-download-root-')); + const allowedPath = join(tempDir, 'reports'); + const outsidePath = join(tmpdir(), 'ccs-browser-outside-downloads'); + const sensitiveRoot = join(tempDir, '.aws'); + const sensitiveDownloadPath = join(sensitiveRoot, 'reports'); + + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports', browser: {} }], + [ + { + jsonrpc: '2.0', + id: 615, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { behavior: 'accept', downloadPath: allowedPath }, + }, + }, + { + jsonrpc: '2.0', + id: 616, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { behavior: 'accept', downloadPath: outsidePath }, + }, + }, + { + jsonrpc: '2.0', + id: 619, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { behavior: 'accept', downloadPath: sensitiveDownloadPath }, + }, + }, + ], + { childEnv: { CCS_BROWSER_DOWNLOAD_ROOTS: `${sensitiveRoot}${delimiter}${tempDir}` } } + ); + + expect(getResponseText(responses.find((message) => message.id === 615))).toContain( + `downloadPath: ${realpathSync(allowedPath)}` + ); + const rejectedResponse = responses.find((message) => message.id === 616); + expect((rejectedResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(rejectedResponse)).toContain( + 'downloadPath must be inside the browser session download directory or a CCS_BROWSER_DOWNLOAD_ROOTS entry' + ); + expect(getResponseText(responses.find((message) => message.id === 619))).toContain( + 'downloadPath cannot include hidden or sensitive path segment: .aws' + ); + }); + it('rejects browser_cancel_download for completed downloads', async () => { const pages: MockPageState[] = [ { @@ -144,7 +213,9 @@ describe('ccs-browser MCP server - downloads and file inputs', () => { { responseTimeoutMs: 12000 } ); - expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 611))).toContain( + 'status: completed' + ); const response = responses.find((message) => message.id === 612); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain( @@ -188,7 +259,9 @@ describe('ccs-browser MCP server - downloads and file inputs', () => { { responseTimeoutMs: 12000 } ); - expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed'); + expect(getResponseText(responses.find((message) => message.id === 62))).toContain( + 'status: observed' + ); }); it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => { @@ -279,23 +352,30 @@ describe('ccs-browser MCP server - downloads and file inputs', () => { }, }, }, - ] + ], + { childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } } ); - expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1'); - expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame'); - expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true'); + expect(getResponseText(responses.find((message) => message.id === 64))).toContain( + 'pageIndex: 1' + ); + expect(getResponseText(responses.find((message) => message.id === 65))).toContain( + 'frameSelector: #upload-frame' + ); + expect(getResponseText(responses.find((message) => message.id === 66))).toContain( + 'pierceShadow: true' + ); - expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([ - invoicePath, - receiptPath, - ]); + expect( + (pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles + ).toEqual([realpathSync(invoicePath), realpathSync(receiptPath)]); expect( (pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles - ).toEqual([invoicePath]); + ).toEqual([realpathSync(invoicePath)]); expect( - (pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles - ).toEqual([receiptPath]); + (pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState) + .assignedFiles + ).toEqual([realpathSync(receiptPath)]); }); it('uses pageId for browser_set_file_input when provided', async () => { @@ -340,14 +420,85 @@ describe('ccs-browser MCP server - downloads and file inputs', () => { arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] }, }, }, - ] + ], + { childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } } ); - expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 68))).toContain( + 'pageIndex: 1' + ); expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([ - assetPath, + realpathSync(assetPath), ]); - expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined(); + expect( + (pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles + ).toBeUndefined(); + }); + + it('rejects file uploads outside configured roots and sensitive files inside configured roots', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-root-')); + const outsideDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-outside-')); + const outsidePath = join(outsideDir, 'secret.txt'); + const sensitiveDir = join(tempDir, '.ssh'); + const sensitivePath = join(sensitiveDir, 'id_rsa'); + const sensitiveRootPath = join(sensitiveDir, 'public.txt'); + writeFileSync(outsidePath, 'outside'); + mkdirSync(sensitiveDir, { recursive: true }); + writeFileSync(sensitivePath, 'private-key'); + writeFileSync(sensitiveRootPath, 'not-a-key'); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Upload Guard', + currentUrl: 'https://example.com/', + fileInputs: { + '#real-file-input': { kind: 'file' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 617, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#real-file-input', files: [outsidePath] }, + }, + }, + { + jsonrpc: '2.0', + id: 618, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#real-file-input', files: [sensitivePath] }, + }, + }, + { + jsonrpc: '2.0', + id: 620, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#real-file-input', files: [sensitiveRootPath] }, + }, + }, + ], + { childEnv: { CCS_BROWSER_UPLOAD_ROOTS: `${sensitiveDir}${delimiter}${tempDir}` } } + ); + + expect(getResponseText(responses.find((message) => message.id === 617))).toContain( + 'file must be inside the browser session download directory or a CCS_BROWSER_UPLOAD_ROOTS entry' + ); + expect(getResponseText(responses.find((message) => message.id === 618))).toContain( + 'file cannot include hidden or sensitive path segment: .ssh' + ); + expect(getResponseText(responses.find((message) => message.id === 620))).toContain( + 'file cannot include hidden or sensitive path segment: .ssh' + ); }); it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => { @@ -393,10 +544,16 @@ describe('ccs-browser MCP server - downloads and file inputs', () => { method: 'tools/call', params: { name: 'browser_set_file_input', - arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] }, + arguments: { + pageIndex: 0, + pageId: 'page-1', + selector: '#real-file-input', + files: [okPath], + }, }, }, - ] + ], + { childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } } ); expect(getResponseText(responses.find((message) => message.id === 69))).toContain( @@ -409,5 +566,4 @@ describe('ccs-browser MCP server - downloads and file inputs', () => { 'pageIndex and pageId cannot be used together' ); }); - }); diff --git a/tests/unit/management/checks/system-check.test.ts b/tests/unit/management/checks/system-check.test.ts new file mode 100644 index 00000000..e8b85ef8 --- /dev/null +++ b/tests/unit/management/checks/system-check.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, spyOn } from 'bun:test'; +import * as childProcess from 'child_process'; +import { EventEmitter } from 'events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { HealthCheck } from '../../../../src/management/checks/types'; + +const originalPlatform = process.platform; +let originalClaudePath: string | undefined; + +function createMockChild(): EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; +} { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setTimeout(() => { + child.stdout.emit('data', Buffer.from('1.2.3')); + child.emit('close', 0); + }, 0); + return child; +} + +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + if (originalClaudePath === undefined) delete process.env.CCS_CLAUDE_PATH; + else process.env.CCS_CLAUDE_PATH = originalClaudePath; +}); + +describe('ClaudeCliChecker', () => { + it('uses the pinned Windows shell for cmd wrapper health checks', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-system-check-')); + const fakeClaude = path.join(tmpDir, 'claude.cmd'); + fs.writeFileSync(fakeClaude, ''); + originalClaudePath = process.env.CCS_CLAUDE_PATH; + process.env.CCS_CLAUDE_PATH = fakeClaude; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const spawnSpy = spyOn(childProcess, 'spawn').mockImplementation( + () => createMockChild() as unknown as ReturnType + ); + + try { + const { ClaudeCliChecker } = await import('../../../../src/management/checks/system-check'); + const results = new HealthCheck(); + await new ClaudeCliChecker().run(results); + + expect(spawnSpy).toHaveBeenCalledTimes(1); + const [, options] = spawnSpy.mock.calls[0] as [ + string, + Record | undefined, + ]; + expect(options?.shell).toBe('C:\\Windows\\System32\\cmd.exe'); + expect(results.details['Claude CLI']?.status).toBe('OK'); + } finally { + spawnSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/management/instance-directory.test.ts b/tests/unit/management/instance-directory.test.ts new file mode 100644 index 00000000..da64d7f1 --- /dev/null +++ b/tests/unit/management/instance-directory.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { SettingsSymlinksChecker } from '../../../src/management/checks/symlink-check'; +import { HealthCheck } from '../../../src/management/checks/types'; +import { + listAccountInstanceNames, + listAccountInstancePaths, +} from '../../../src/management/instance-directory'; +import { InstancesChecker } from '../../../src/management/checks/profile-check'; +import { checkInstances } from '../../../src/web-server/health/profile-checks'; +import { checkSettingsSymlinks } from '../../../src/web-server/health/symlink-checks'; + +describe('account instance directory enumeration', () => { + let tempRoot = ''; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + const ccsDir = () => path.join(tempRoot, '.ccs'); + const claudeDir = () => path.join(tempRoot, '.claude'); + const instancesDir = () => path.join(ccsDir(), 'instances'); + + function createValidSettingsLayout(): void { + const claudeSettings = path.join(claudeDir(), 'settings.json'); + const sharedSettings = path.join(ccsDir(), 'shared', 'settings.json'); + const workSettings = path.join(instancesDir(), 'work', 'settings.json'); + + fs.mkdirSync(path.dirname(claudeSettings), { recursive: true }); + fs.mkdirSync(path.dirname(sharedSettings), { recursive: true }); + fs.mkdirSync(path.dirname(workSettings), { recursive: true }); + fs.mkdirSync(path.join(instancesDir(), '.locks'), { recursive: true }); + + fs.writeFileSync(claudeSettings, '{}\n', 'utf8'); + fs.symlinkSync(claudeSettings, sharedSettings, 'file'); + fs.symlinkSync(sharedSettings, workSettings, 'file'); + } + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-directory-test-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + spyOn(os, 'homedir').mockReturnValue(tempRoot); + spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + mock.restore(); + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('lists only user account instance directories', () => { + fs.mkdirSync(path.join(instancesDir(), 'work'), { recursive: true }); + fs.mkdirSync(path.join(instancesDir(), 'personal'), { recursive: true }); + fs.mkdirSync(path.join(instancesDir(), '.locks'), { recursive: true }); + fs.mkdirSync(path.join(instancesDir(), '.cache'), { recursive: true }); + fs.writeFileSync(path.join(instancesDir(), 'README.txt'), 'not an instance', 'utf8'); + + expect(listAccountInstanceNames(instancesDir()).sort()).toEqual(['personal', 'work']); + expect(listAccountInstancePaths(instancesDir()).sort()).toEqual( + [path.join(instancesDir(), 'personal'), path.join(instancesDir(), 'work')].sort() + ); + }); + + it('skips transient entries that cannot be statted', () => { + fs.mkdirSync(path.join(instancesDir(), 'work'), { recursive: true }); + fs.symlinkSync( + path.join(instancesDir(), 'missing-instance'), + path.join(instancesDir(), 'transient'), + 'dir' + ); + + expect(listAccountInstanceNames(instancesDir())).toEqual(['work']); + }); + + it('keeps ccs doctor settings symlinks healthy when .locks exists', () => { + createValidSettingsLayout(); + + const results = new HealthCheck(); + new SettingsSymlinksChecker().run(results); + + expect(results.warnings).toEqual([]); + expect(results.checks.find((check) => check.name === 'Settings Symlinks')?.status).toBe( + 'success' + ); + expect(results.details['Settings Symlinks']?.info).toBe('1 instance(s) valid'); + }); + + it('keeps ccs doctor instance counts tied to real profiles', () => { + fs.mkdirSync(path.join(instancesDir(), 'work'), { recursive: true }); + fs.mkdirSync(path.join(instancesDir(), '.locks'), { recursive: true }); + + const results = new HealthCheck(); + new InstancesChecker().run(results); + + expect(results.checks.find((check) => check.name === 'Instances')?.message).toBe( + '1 account profiles' + ); + }); + + it('keeps dashboard health checks tied to real profiles', () => { + createValidSettingsLayout(); + + expect(checkSettingsSymlinks(ccsDir(), claudeDir())).toMatchObject({ + status: 'ok', + message: '1 instance(s) valid', + }); + expect(checkInstances(ccsDir())).toMatchObject({ + status: 'ok', + message: '1 account profile', + }); + }); +}); diff --git a/tests/unit/proxy/proxy-daemon-state.test.ts b/tests/unit/proxy/proxy-daemon-state.test.ts index c653b030..ba7d6444 100644 --- a/tests/unit/proxy/proxy-daemon-state.test.ts +++ b/tests/unit/proxy/proxy-daemon-state.test.ts @@ -3,11 +3,16 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { + getOpenAICompatProxyDir, getLegacyOpenAICompatProxySessionPath, getOpenAICompatProxyPidPath, getOpenAICompatProxySessionPath, } from '../../../src/proxy/proxy-daemon-paths'; -import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state'; +import { + listOpenAICompatProxyProfileNames, + removeOpenAICompatProxyAuthTokenFile, + writeOpenAICompatProxyAuthTokenFile, +} from '../../../src/proxy/proxy-daemon-state'; let originalCcsHome: string | undefined; let tempDir: string; @@ -51,3 +56,24 @@ describe('listOpenAICompatProxyProfileNames', () => { expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']); }); }); + +describe('writeOpenAICompatProxyAuthTokenFile', () => { + it('writes a private token file under the CCS proxy dir and removes it', () => { + const authToken = 'proxy-token-regression-secret'; + const tokenPath = writeOpenAICompatProxyAuthTokenFile('ccg', authToken); + const proxyDir = getOpenAICompatProxyDir(); + + expect(path.dirname(tokenPath)).toBe(proxyDir); + expect(tokenPath.startsWith(`${proxyDir}${path.sep}`)).toBe(true); + expect(fs.readFileSync(tokenPath, 'utf8')).toBe(authToken); + + if (process.platform !== 'win32') { + expect(fs.statSync(proxyDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600); + } + + removeOpenAICompatProxyAuthTokenFile(tokenPath); + + expect(fs.existsSync(tokenPath)).toBe(false); + }); +}); diff --git a/tests/unit/targets/codex-adapter-exec.test.ts b/tests/unit/targets/codex-adapter-exec.test.ts index d5166e03..ebfe191d 100644 --- a/tests/unit/targets/codex-adapter-exec.test.ts +++ b/tests/unit/targets/codex-adapter-exec.test.ts @@ -94,7 +94,7 @@ describe('codex-adapter exec', () => { string, Record | undefined, ]; - expect(options?.shell).toBe('cmd.exe'); + expect(options?.shell).toBe('C:\\Windows\\System32\\cmd.exe'); expect(command).toContain(fakeCodex); expect(command).toContain('mcp_servers.ccs_browser.args='); expect(command).toContain('@playwright/mcp@0.0.70'); diff --git a/tests/unit/targets/codex-cliproxy-provider-config.test.ts b/tests/unit/targets/codex-cliproxy-provider-config.test.ts new file mode 100644 index 00000000..43dd6b28 --- /dev/null +++ b/tests/unit/targets/codex-cliproxy-provider-config.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + buildCodexCliproxyProviderBaseUrl, + ensureCodexCliproxyProviderConfig, +} from '../../../src/targets/codex-cliproxy-provider-config'; + +describe('codex cliproxy provider config repair', () => { + let tempHome: string; + let codexHome: string; + let configPath: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-provider-config-')); + codexHome = path.join(tempHome, '.codex'); + configPath = path.join(codexHome, 'config.toml'); + env = { CODEX_HOME: codexHome } as NodeJS.ProcessEnv; + }); + + afterEach(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('creates the cliproxy model provider when config.toml is missing', async () => { + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText).toContain('[model_providers.cliproxy]'); + expect(rawText).toContain('name = "CLIProxy Codex"'); + expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); + expect(rawText).not.toContain('model_provider = "cliproxy"'); + }); + + it('appends the missing provider while preserving existing raw config text', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(configPath, '# user note\nmodel = "gpt-5.4"\n', 'utf8'); + + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText.startsWith('# user note\nmodel = "gpt-5.4"\n\n')).toBe(true); + expect(rawText).toContain('[model_providers.cliproxy]'); + }); + + it('repairs an existing incomplete cliproxy provider', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + configPath, + `[model_providers.cliproxy] +base_url = "http://localhost:8317/api/provider/codex" +wire_api = "responses" +`, + 'utf8' + ); + + const result = await ensureCodexCliproxyProviderConfig(9321, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`); + expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); + expect(rawText).toContain('requires_openai_auth = false'); + expect(rawText).toContain('supports_websockets = false'); + }); + + it('preserves a custom cliproxy provider env key while repairing other fields', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + configPath, + `[model_providers.cliproxy] +name = "Old Name" +base_url = "http://localhost:8317/api/provider/codex" +env_key = "CCS_CUSTOM_CLIPROXY_TOKEN" +wire_api = "chat" +`, + 'utf8' + ); + + const result = await ensureCodexCliproxyProviderConfig(9321, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CCS_CUSTOM_CLIPROXY_TOKEN'); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`); + expect(rawText).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"'); + expect(rawText).toContain('wire_api = "responses"'); + }); + + it('rejects invalid non-table model_providers values without appending broken TOML', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + const rawText = 'model_providers = "legacy"\n'; + fs.writeFileSync(configPath, rawText, 'utf8'); + + await expect(ensureCodexCliproxyProviderConfig(8317, env)).rejects.toThrow( + '[model_providers] must be a table' + ); + expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText); + }); + + it('leaves a ready localhost provider unchanged', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + const rawText = `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "http://localhost:8317/api/provider/codex" +env_key = "CLIPROXY_API_KEY" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +`; + fs.writeFileSync(configPath, rawText, 'utf8'); + + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(false); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText); + }); +}); diff --git a/tests/unit/targets/codex-detector.test.ts b/tests/unit/targets/codex-detector.test.ts index b0fc8e52..b361d8ac 100644 --- a/tests/unit/targets/codex-detector.test.ts +++ b/tests/unit/targets/codex-detector.test.ts @@ -81,7 +81,7 @@ describe('codex-detector', () => { expect(spawnSyncSpy).toHaveBeenCalled(); expect(cmdWrapperProbeCall).toBeDefined(); expect((cmdWrapperProbeCall?.[1] as Record | undefined)?.shell).toBe( - 'cmd.exe' + 'C:\\Windows\\System32\\cmd.exe' ); expect(info?.needsShell).toBe(true); expect(info?.features).toContain('config-overrides'); @@ -96,7 +96,9 @@ describe('codex-detector', () => { fs.writeFileSync(fakePsCodex, ''); Object.defineProperty(process, 'platform', { value: 'win32' }); - const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => `${fakeCmdCodex}\n`); + const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation( + () => `${fakeCmdCodex}\n` + ); expect(detectCodexCli()).toBe(fakeCmdCodex); @@ -110,7 +112,9 @@ describe('codex-detector', () => { fs.writeFileSync(fakePsCodex, ''); Object.defineProperty(process, 'platform', { value: 'win32' }); - const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => `${fakePsCodex}\n`); + const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation( + () => `${fakePsCodex}\n` + ); expect(detectCodexCli()).toBe(fakeCmdCodex); @@ -122,19 +126,21 @@ describe('codex-detector', () => { fs.writeFileSync(fakeCodex, ''); process.env.CCS_CODEX_PATH = fakeCodex; - const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => { - const joinedArgs = Array.isArray(args) ? args.join(' ') : ''; + const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation( + (command, args) => { + const joinedArgs = Array.isArray(args) ? args.join(' ') : ''; - if (joinedArgs.includes('--help')) { - return 'Codex CLI\n'; - } + if (joinedArgs.includes('--help')) { + return 'Codex CLI\n'; + } + + if (joinedArgs.includes('-c') && joinedArgs.includes('--version')) { + return 'codex-cli 0.119.0-alpha.1'; + } - if (joinedArgs.includes('-c') && joinedArgs.includes('--version')) { return 'codex-cli 0.119.0-alpha.1'; } - - return 'codex-cli 0.119.0-alpha.1'; - }); + ); const info = getCodexBinaryInfo(); @@ -148,19 +154,21 @@ describe('codex-detector', () => { fs.writeFileSync(fakeCodex, ''); process.env.CCS_CODEX_PATH = fakeCodex; - const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => { - const joinedArgs = Array.isArray(args) ? args.join(' ') : ''; + const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation( + (command, args) => { + const joinedArgs = Array.isArray(args) ? args.join(' ') : ''; - if (joinedArgs.includes('--help')) { - return 'Codex CLI\n -c, --config \n'; + if (joinedArgs.includes('--help')) { + return 'Codex CLI\n -c, --config \n'; + } + + if (joinedArgs.includes('-c') && joinedArgs.includes('--version')) { + throw new Error('unsupported'); + } + + return 'codex-cli 0.119.0-alpha.1'; } - - if (joinedArgs.includes('-c') && joinedArgs.includes('--version')) { - throw new Error('unsupported'); - } - - return 'codex-cli 0.119.0-alpha.1'; - }); + ); const info = getCodexBinaryInfo(); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 318219c6..8e916942 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -116,18 +116,32 @@ if (out) { } const envOut = process.env.CCS_TEST_CODEX_ENV_OUT; if (envOut) { + const loggedEnv = { + CODEX_HOME: process.env.CODEX_HOME, + CODEX_CI: process.env.CODEX_CI, + CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN, + CODEX_THREAD_ID: process.env.CODEX_THREAD_ID, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR, + CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR, + CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL, + }; + if ( + process.env.CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY === '1' && + process.env.CLIPROXY_API_KEY !== undefined + ) { + loggedEnv.CLIPROXY_API_KEY = process.env.CLIPROXY_API_KEY; + } + const extraEnvKeys = (process.env.CCS_TEST_CODEX_LOG_ENV_KEYS || '') + .split(',') + .map((key) => key.trim()) + .filter(Boolean); + for (const key of extraEnvKeys) { + loggedEnv[key] = process.env[key]; + } fs.appendFileSync( envOut, - JSON.stringify({ - CODEX_HOME: process.env.CODEX_HOME, - CODEX_CI: process.env.CODEX_CI, - CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN, - CODEX_THREAD_ID: process.env.CODEX_THREAD_ID, - ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, - CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR, - CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR, - CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL, - }) + '\\n' + JSON.stringify(loggedEnv) + '\\n' ); } const configFlagIndex = cliArgs.findIndex((arg) => arg === '-c' || arg === '--config'); @@ -396,6 +410,7 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, @@ -413,6 +428,7 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, @@ -647,6 +663,7 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, @@ -668,21 +685,24 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1', CODEX_HOME: inheritedCodexHome, }); expect(result.status).toBe(0); expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ { - CODEX_HOME: path.join(os.homedir(), '.codex'), + CODEX_HOME: path.join(tmpHome, '.codex'), CODEX_CI: undefined, CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', CCS_BROWSER_USER_DATA_DIR: undefined, CCS_BROWSER_PROFILE_DIR: undefined, CCS_BROWSER_DEVTOOLS_WS_URL: undefined, @@ -697,10 +717,112 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1', + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ + ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + ]); + const codexConfig = fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf8'); + expect(codexConfig).toContain('[model_providers.cliproxy]'); + expect(codexConfig).toContain('env_key = "CLIPROXY_API_KEY"'); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: path.join(tmpHome, '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); + }); + + it('loads the configured cliproxy provider env_key for ccsxp launches', () => { + if (process.platform === 'win32') return; + + const codexHome = path.join(tmpHome, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + path.join(codexHome, 'config.toml'), + `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "http://localhost:8317/api/provider/codex" +env_key = "CCS_CUSTOM_CLIPROXY_TOKEN" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +`, + 'utf8' + ); + + const result = runCcsxpAlias(['fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + HOME: tmpHome, + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_LOG_ENV_KEYS: 'CCS_CUSTOM_CLIPROXY_TOKEN', + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ + ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + ]); + const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + expect(codexConfig).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"'); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: codexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_CUSTOM_CLIPROXY_TOKEN: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); + }); + + it('keeps ccsxp native when the CCS default profile is a Claude account', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 2', + 'default: work', + 'accounts:', + ' work:', + ' created: "2026-01-01"', + ' last_used: "2026-01-01"', + ].join('\n') + ); + + const result = runCcsxpAlias(['fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + HOME: tmpHome, + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1', }); expect(result.status).toBe(0); @@ -709,7 +831,51 @@ process.exit(0); ]); expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ { - CODEX_HOME: path.join(os.homedir(), '.codex'), + CODEX_HOME: path.join(tmpHome, '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); + }); + + it('keeps implicit ccs --target codex launches native when the CCS default is a Claude account', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 2', + 'default: work', + 'accounts:', + ' work:', + ' created: "2026-01-01"', + ' last_used: "2026-01-01"', + ].join('\n') + ); + + const result = runCcs(['--target', 'codex'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + HOME: tmpHome, + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_THINKING: '8192', + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([[]]); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: undefined, CODEX_CI: undefined, CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, @@ -721,6 +887,35 @@ process.exit(0); ]); }); + it('still rejects an explicit Claude account profile on the Codex target', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 2', + 'accounts:', + ' work:', + ' created: "2026-01-01"', + ' last_used: "2026-01-01"', + ].join('\n') + ); + + const result = runCcs(['work', '--target', 'codex', 'fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Codex CLI does not support Claude account-based profiles.'); + expect(result.stderr).toContain('CLIProxy Codex pool: ccs codex --target codex or ccsxp'); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([]); + }); + it('rejects conflicting native provider config overrides for ccsxp', () => { if (process.platform === 'win32') return; @@ -728,6 +923,7 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, @@ -745,6 +941,7 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, @@ -767,6 +964,7 @@ process.exit(0); CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1', CODEX_HOME: path.join(tmpHome, 'inherited-codex-home'), CCSXP_CODEX_HOME: explicitCodexHome, }); @@ -779,6 +977,7 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', CCS_BROWSER_USER_DATA_DIR: undefined, CCS_BROWSER_PROFILE_DIR: undefined, CCS_BROWSER_DEVTOOLS_WS_URL: undefined, diff --git a/tests/unit/targets/target-runtime-compatibility.test.ts b/tests/unit/targets/target-runtime-compatibility.test.ts index 60a2d1fa..923de185 100644 --- a/tests/unit/targets/target-runtime-compatibility.test.ts +++ b/tests/unit/targets/target-runtime-compatibility.test.ts @@ -87,16 +87,21 @@ describe('evaluateTargetRuntimeCompatibility', () => { profileType: 'settings', }); expect(genericSettingsCompatibility.supported).toBe(false); - expect(genericSettingsCompatibility.reason).toMatch(/currently supports native default sessions/); + expect(genericSettingsCompatibility.reason).toMatch( + /currently supports native default sessions/ + ); }); test('rejects account, copilot, and cursor profiles on Codex target', () => { - expect( - evaluateTargetRuntimeCompatibility({ - target: 'codex', - profileType: 'account', - }).supported - ).toBe(false); + const accountCompatibility = evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'account', + }); + expect(accountCompatibility.supported).toBe(false); + expect(accountCompatibility.suggestion).toContain('ccs --target codex'); + expect(accountCompatibility.suggestion).toContain('ccs codex --target codex'); + expect(accountCompatibility.suggestion).toContain('ccs cliproxy routing'); + expect( evaluateTargetRuntimeCompatibility({ target: 'codex', diff --git a/tests/unit/utils/claude-subcommand-detector.test.ts b/tests/unit/utils/claude-subcommand-detector.test.ts new file mode 100644 index 00000000..866e7604 --- /dev/null +++ b/tests/unit/utils/claude-subcommand-detector.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'bun:test'; +import { + isClaudeSubcommandInvocation, + stripClaudeCodeFeatureBlockingEnv, + stripClaudeSubcommandSessionArgs, +} from '../../../src/utils/claude-subcommand-detector'; +import { appendThirdPartyWebSearchToolArgs } from '../../../src/utils/websearch/claude-tool-args'; +import { appendThirdPartyImageAnalysisToolArgs } from '../../../src/utils/image-analysis/claude-tool-args'; +import { appendBrowserToolArgs } from '../../../src/utils/browser/claude-tool-args'; + +describe('isClaudeSubcommandInvocation', () => { + it('returns false for an empty arg list', () => { + expect(isClaudeSubcommandInvocation([])).toBe(false); + }); + + it('returns false for a prompt-only invocation', () => { + expect(isClaudeSubcommandInvocation(['fix the failing test'])).toBe(false); + }); + + it('detects bare subcommand', () => { + for (const cmd of [ + 'agents', + 'auth', + 'doctor', + 'mcp', + 'plugin', + 'plugins', + 'project', + 'setup-token', + 'ultrareview', + 'update', + 'upgrade', + 'auto-mode', + 'install', + ]) { + expect(isClaudeSubcommandInvocation([cmd])).toBe(true); + } + }); + + it('skips past value-taking flags before the positional', () => { + expect(isClaudeSubcommandInvocation(['--model', 'sonnet', 'agents'])).toBe(true); + expect(isClaudeSubcommandInvocation(['--settings', '/tmp/s.json', 'doctor'])).toBe(true); + expect( + isClaudeSubcommandInvocation([ + '--dangerously-skip-permissions', + '--teammate-mode', + 'in-process', + 'agents', + ]) + ).toBe(true); + }); + + it('does not treat a flag value matching a subcommand name as a subcommand', () => { + // `--name auth` sets the session display name to "auth" — still an interactive launch. + expect(isClaudeSubcommandInvocation(['--name', 'auth'])).toBe(false); + }); + + it('handles --flag=value forms', () => { + expect(isClaudeSubcommandInvocation(['--model=sonnet', 'agents'])).toBe(true); + }); + + it('stops scanning at the -- terminator', () => { + expect(isClaudeSubcommandInvocation(['--', 'agents'])).toBe(false); + }); + + it('ignores subcommand-named tokens that come AFTER the first positional prompt', () => { + // First positional is the prompt; "agents" later is just a word in the prompt context. + expect(isClaudeSubcommandInvocation(['talk to me about', 'agents'])).toBe(false); + }); +}); + +describe('stripClaudeCodeFeatureBlockingEnv', () => { + it('removes telemetry disable env that blocks Claude Code background features', () => { + const env = stripClaudeCodeFeatureBlockingEnv({ + DISABLE_TELEMETRY: '1', + DISABLE_BUG_COMMAND: '1', + DISABLE_ERROR_REPORTING: '1', + ANTHROPIC_MODEL: 'gpt-5.5', + }); + + expect(env.DISABLE_TELEMETRY).toBeUndefined(); + expect(env.DISABLE_BUG_COMMAND).toBe('1'); + expect(env.DISABLE_ERROR_REPORTING).toBe('1'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5'); + }); +}); + +describe('stripClaudeSubcommandSessionArgs', () => { + it('removes session-only flags before a Claude subcommand', () => { + expect( + stripClaudeSubcommandSessionArgs([ + '--dangerously-skip-permissions', + '--teammate-mode', + 'in-process', + 'agents', + ]) + ).toEqual(['agents']); + }); + + it('removes session-only flags after a Claude subcommand while preserving subcommand flags', () => { + expect( + stripClaudeSubcommandSessionArgs([ + 'agents', + '--dangerously-skip-permissions', + '--teammate-mode', + 'in-process', + '--setting-sources', + 'user', + ]) + ).toEqual(['agents', '--setting-sources', 'user']); + }); + + it('leaves non-subcommand interactive launches unchanged', () => { + expect( + stripClaudeSubcommandSessionArgs([ + '--dangerously-skip-permissions', + '--teammate-mode', + 'in-process', + 'fix the bug', + ]) + ).toEqual(['--dangerously-skip-permissions', '--teammate-mode', 'in-process', 'fix the bug']); + }); +}); + +describe('subcommand passthrough — injectors short-circuit', () => { + it('appendThirdPartyWebSearchToolArgs returns args unchanged for subcommand invocations', () => { + expect(appendThirdPartyWebSearchToolArgs(['agents'])).toEqual(['agents']); + expect(appendThirdPartyWebSearchToolArgs(['doctor'])).toEqual(['doctor']); + expect(appendThirdPartyWebSearchToolArgs(['mcp', 'list'])).toEqual(['mcp', 'list']); + }); + + it('appendThirdPartyImageAnalysisToolArgs returns args unchanged for subcommand invocations', () => { + expect(appendThirdPartyImageAnalysisToolArgs(['agents'])).toEqual(['agents']); + }); + + it('appendBrowserToolArgs returns args unchanged for subcommand invocations', () => { + expect(appendBrowserToolArgs(['agents'])).toEqual(['agents']); + }); + + it('injectors still inject for non-subcommand interactive launches', () => { + const out = appendThirdPartyWebSearchToolArgs(['fix the bug']); + expect(out).toContain('--append-system-prompt'); + expect(out).toContain('--disallowedTools'); + }); +}); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 767609ca..d3f819cd 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -405,7 +405,7 @@ describe('CLAUDECODE environment stripping', () => { expect(spawnCalls.length).toBeGreaterThan(0); const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); - expect(spawnCalls[0].options?.shell).toBe('cmd.exe'); + expect(spawnCalls[0].options?.shell).toBe('C:\\Windows\\System32\\cmd.exe'); }); it('execClaude sets DISABLE_AUTOUPDATER=1 when preferences.auto_update is false', () => { @@ -827,9 +827,9 @@ describe('CLAUDECODE environment stripping', () => { expect(persistedLaunchSettings.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); expect(persistedLaunchSettings.env?.ANTHROPIC_API_KEY).toBeUndefined(); expect(persistedLaunchSettings.env?.ANTHROPIC_MODEL).toBe('gpt-5.4'); - expect( - persistedLaunchSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command - ).toBe('echo headless-bridge-hook'); + expect(persistedLaunchSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command).toBe( + 'echo headless-bridge-hook' + ); expect(fs.existsSync(launchSettingsPath)).toBe(false); }); diff --git a/tests/unit/utils/shell-executor.test.ts b/tests/unit/utils/shell-executor.test.ts index f3b7e600..f09dc52a 100644 --- a/tests/unit/utils/shell-executor.test.ts +++ b/tests/unit/utils/shell-executor.test.ts @@ -82,41 +82,103 @@ describe('escapeShellArg', () => { expect(escapeShellArg('hello!')).toBe('"hello^^!"'); }); - it('prefers ComSpec when resolving the escaped command shell', async () => { + it('accepts ComSpec only when it resolves to the trusted system cmd.exe', async () => { const originalComSpec = process.env.ComSpec; const originalCOMSPEC = process.env.COMSPEC; + const originalSystemRoot = process.env.SystemRoot; try { + process.env.SystemRoot = 'C:\\Windows'; process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; delete process.env.COMSPEC; - const { getWindowsEscapedCommandShell } = await import( - '../../../src/utils/shell-executor' - ); + const { getWindowsEscapedCommandShell } = await import('../../../src/utils/shell-executor'); expect(getWindowsEscapedCommandShell()).toBe('C:\\Windows\\System32\\cmd.exe'); } finally { if (originalComSpec === undefined) delete process.env.ComSpec; else process.env.ComSpec = originalComSpec; if (originalCOMSPEC === undefined) delete process.env.COMSPEC; else process.env.COMSPEC = originalCOMSPEC; + if (originalSystemRoot === undefined) delete process.env.SystemRoot; + else process.env.SystemRoot = originalSystemRoot; } }); - it('falls back to cmd.exe when ComSpec is unavailable', async () => { + it('rejects relative or non-system ComSpec values', async () => { const originalComSpec = process.env.ComSpec; const originalCOMSPEC = process.env.COMSPEC; + const originalSystemRoot = process.env.SystemRoot; try { - delete process.env.ComSpec; - delete process.env.COMSPEC; - const { getWindowsEscapedCommandShell } = await import( - '../../../src/utils/shell-executor' - ); - expect(getWindowsEscapedCommandShell()).toBe('cmd.exe'); + process.env.SystemRoot = 'C:\\Windows'; + process.env.ComSpec = 'cmd.exe'; + process.env.COMSPEC = 'C:\\Temp\\cmd.exe'; + const { getWindowsEscapedCommandShell } = await import('../../../src/utils/shell-executor'); + expect(getWindowsEscapedCommandShell()).toBe('C:\\Windows\\System32\\cmd.exe'); } finally { if (originalComSpec === undefined) delete process.env.ComSpec; else process.env.ComSpec = originalComSpec; if (originalCOMSPEC === undefined) delete process.env.COMSPEC; else process.env.COMSPEC = originalCOMSPEC; + if (originalSystemRoot === undefined) delete process.env.SystemRoot; + else process.env.SystemRoot = originalSystemRoot; + } + }); + + it('rejects relative Windows root environment values', async () => { + const originalComSpec = process.env.ComSpec; + const originalCOMSPEC = process.env.COMSPEC; + const originalSystemRoot = process.env.SystemRoot; + const originalSYSTEMROOT = process.env.SYSTEMROOT; + const originalWindir = process.env.windir; + + try { + process.env.SystemRoot = 'Windows'; + delete process.env.SYSTEMROOT; + delete process.env.windir; + process.env.ComSpec = 'Windows\\System32\\cmd.exe'; + process.env.COMSPEC = 'C:\\Temp\\cmd.exe'; + const { getWindowsEscapedCommandShell } = await import('../../../src/utils/shell-executor'); + expect(getWindowsEscapedCommandShell()).toBe('C:\\Windows\\System32\\cmd.exe'); + } finally { + if (originalComSpec === undefined) delete process.env.ComSpec; + else process.env.ComSpec = originalComSpec; + if (originalCOMSPEC === undefined) delete process.env.COMSPEC; + else process.env.COMSPEC = originalCOMSPEC; + if (originalSystemRoot === undefined) delete process.env.SystemRoot; + else process.env.SystemRoot = originalSystemRoot; + if (originalSYSTEMROOT === undefined) delete process.env.SYSTEMROOT; + else process.env.SYSTEMROOT = originalSYSTEMROOT; + if (originalWindir === undefined) delete process.env.windir; + else process.env.windir = originalWindir; + } + }); + + it('falls back to the absolute default system cmd.exe when Windows root env is unavailable', async () => { + const originalComSpec = process.env.ComSpec; + const originalCOMSPEC = process.env.COMSPEC; + const originalSystemRoot = process.env.SystemRoot; + const originalSYSTEMROOT = process.env.SYSTEMROOT; + const originalWindir = process.env.windir; + + try { + delete process.env.ComSpec; + delete process.env.COMSPEC; + delete process.env.SystemRoot; + delete process.env.SYSTEMROOT; + delete process.env.windir; + const { getWindowsEscapedCommandShell } = await import('../../../src/utils/shell-executor'); + expect(getWindowsEscapedCommandShell()).toBe('C:\\Windows\\System32\\cmd.exe'); + } finally { + if (originalComSpec === undefined) delete process.env.ComSpec; + else process.env.ComSpec = originalComSpec; + if (originalCOMSPEC === undefined) delete process.env.COMSPEC; + else process.env.COMSPEC = originalCOMSPEC; + if (originalSystemRoot === undefined) delete process.env.SystemRoot; + else process.env.SystemRoot = originalSystemRoot; + if (originalSYSTEMROOT === undefined) delete process.env.SYSTEMROOT; + else process.env.SYSTEMROOT = originalSYSTEMROOT; + if (originalWindir === undefined) delete process.env.windir; + else process.env.windir = originalWindir; } }); }); diff --git a/tests/unit/web-server/api-routes-remote-write-guard.test.ts b/tests/unit/web-server/api-routes-remote-write-guard.test.ts index 730c8a62..e1cbe9d9 100644 --- a/tests/unit/web-server/api-routes-remote-write-guard.test.ts +++ b/tests/unit/web-server/api-routes-remote-write-guard.test.ts @@ -84,6 +84,21 @@ describe('api-routes remote write guard', () => { expect(response.status).toBe(200); }); + it('blocks remote Codex raw config reads when dashboard auth is disabled', async () => { + const response = await fetch(`${baseUrl}/api/codex/config/raw`); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Codex configuration endpoints require localhost access when dashboard auth is disabled.', + }); + }); + + it('allows remote Codex diagnostics when dashboard auth is disabled', async () => { + const response = await fetch(`${baseUrl}/api/codex/diagnostics`); + + expect(response.status).toBe(200); + }); + it('blocks remote profile creation when dashboard auth is disabled', async () => { const response = await fetch(`${baseUrl}/api/profiles`, { method: 'POST', diff --git a/tests/unit/web-server/auth-middleware.test.ts b/tests/unit/web-server/auth-middleware.test.ts index 5e3a30e3..a252cda2 100644 --- a/tests/unit/web-server/auth-middleware.test.ts +++ b/tests/unit/web-server/auth-middleware.test.ts @@ -9,16 +9,29 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getDashboardAuthConfig } from '../../../src/config/unified-config-loader'; +import { + isDashboardWebSocketOriginAllowed, + getDashboardWebSocketRejectionStatus, + isDashboardWebSocketUpgradeAllowed, +} from '../../../src/web-server/middleware/auth-middleware'; import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; describe('Dashboard Auth', () => { let tempDir = ''; + let originalDashboardAuthEnabled: string | undefined; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-dashboard-auth-')); + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; }); afterEach(() => { + if (originalDashboardAuthEnabled === undefined) { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } else { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } + if (tempDir && fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -149,6 +162,96 @@ describe('Dashboard Auth', () => { }); }); + describe('websocket upgrade access', () => { + function makeUpgradeRequest( + remoteAddress: string, + authenticated = false, + headers: Record = {} + ) { + return { + headers, + socket: { remoteAddress }, + session: authenticated ? { authenticated: true } : { authenticated: false }, + } as never; + } + + it('allows loopback websocket upgrades when dashboard auth is disabled', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + + expect(isDashboardWebSocketUpgradeAllowed(makeUpgradeRequest('127.0.0.1'))).toBe(true); + expect(isDashboardWebSocketUpgradeAllowed(makeUpgradeRequest('::1'))).toBe(true); + }); + + it('blocks remote websocket upgrades when dashboard auth is disabled', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + const request = makeUpgradeRequest('10.10.0.24'); + + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false); + expect(getDashboardWebSocketRejectionStatus()).toBe(403); + }); + + it('blocks cross-site websocket origins when dashboard auth is disabled', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + const request = makeUpgradeRequest('127.0.0.1', false, { + host: '127.0.0.1:3001', + origin: 'https://evil.example.test', + }); + + expect(isDashboardWebSocketOriginAllowed(request)).toBe(false); + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false); + expect(getDashboardWebSocketRejectionStatus(request)).toBe(403); + }); + + it('requires an authenticated session for websocket upgrades when auth is enabled', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + + expect(isDashboardWebSocketUpgradeAllowed(makeUpgradeRequest('127.0.0.1'))).toBe(false); + expect( + isDashboardWebSocketUpgradeAllowed( + makeUpgradeRequest('10.10.0.24', true, { + host: 'dashboard.internal:3001', + origin: 'https://dashboard.internal:3001', + }) + ) + ).toBe(true); + expect(getDashboardWebSocketRejectionStatus()).toBe(401); + }); + + it('allows same-origin websocket upgrades when auth is enabled', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + const request = makeUpgradeRequest('10.10.0.24', true, { + host: 'dashboard.example.test:3001', + origin: 'https://dashboard.example.test:3001', + }); + + expect(isDashboardWebSocketOriginAllowed(request)).toBe(true); + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(true); + }); + + it('allows loopback host aliases on the same dashboard port', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + const request = makeUpgradeRequest('127.0.0.1', true, { + host: '127.0.0.1:3001', + origin: 'http://localhost:3001', + }); + + expect(isDashboardWebSocketOriginAllowed(request)).toBe(true); + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(true); + }); + + it('blocks cross-site websocket origins even with an authenticated session', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + const request = makeUpgradeRequest('127.0.0.1', true, { + host: '127.0.0.1:3001', + origin: 'https://evil.example.test', + }); + + expect(isDashboardWebSocketOriginAllowed(request)).toBe(false); + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false); + expect(getDashboardWebSocketRejectionStatus(request)).toBe(403); + }); + }); + describe('rate limiting config', () => { it('rate limit window is 15 minutes', () => { const windowMs = 15 * 60 * 1000; diff --git a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts index 89b83904..dfd476ab 100644 --- a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts @@ -207,6 +207,52 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { expect(requests[0]?.url).toContain('/v0/management/cursor-auth-url?is_webui=true'); }); + it('returns actionable Codex guidance when manual OAuth start cannot reach CLIProxy', async () => { + mockFetch([ + { + url: /\/v0\/management\/codex-auth-url\?is_webui=true$/, + status: 503, + response: 'connect ECONNREFUSED 127.0.0.1:8317', + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {}); + + expect(startResponse.status).toBe(503); + expect(startResponse.body).toMatchObject({ + error: 'cliproxy_oauth_start_failed', + provider: 'codex', + message: expect.stringContaining('OpenAI Codex OAuth could not start'), + endpoint: 'http://127.0.0.1:8317/v0/management/codex-auth-url?is_webui=true', + retryCommand: 'ccs codex --auth --paste-callback', + portForwardCommand: 'ssh -L 1455:localhost:1455 @', + }); + expect( + (startResponse.body as { hints?: string[] }).hints?.some((hint) => + hint.includes('ccs codex --auth --port-forward') + ) + ).toBe(true); + }); + + it('does not label malformed OAuth start responses as proxy recovery guidance', async () => { + mockFetch([ + { + url: /\/v0\/management\/codex-auth-url\?is_webui=true$/, + response: 'not-json', + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {}); + + expect(startResponse.status).toBe(502); + expect(startResponse.body).toMatchObject({ + error: 'cliproxy_oauth_start_invalid_response', + provider: 'codex', + message: 'CLIProxyAPI returned an invalid OAuth start response.', + }); + expect((startResponse.body as { hints?: string[] }).hints).toBeUndefined(); + }); + it('returns wait after callback submission when the local token is not yet available', async () => { mockFetch([ { diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts index 2352830c..b97413ec 100644 --- a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -47,6 +47,24 @@ function writeProfileSettings( return settingsPath; } +function createSymlinkIfAvailable( + targetPath: string, + linkPath: string, + type?: fs.symlink.Type +): boolean { + try { + fs.symlinkSync(targetPath, linkPath, type); + return true; + } catch (error) { + const code = (error as { code?: string }).code; + if (process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES')) { + console.warn('Skipping symlink escape regression: symlink creation unavailable on Windows.'); + return false; + } + throw error; + } +} + describe('settings-routes image-analysis status', () => { let server: Server; let baseUrl = ''; @@ -199,7 +217,7 @@ describe('settings-routes image-analysis status', () => { }); it('uses the configured custom settings path for status and persistence diagnostics', async () => { - const customSettingsPath = path.join(tempHome, 'profiles', 'foo.bar.settings.json'); + const customSettingsPath = path.join(tempHome, '.ccs', 'profiles', 'foo.bar.settings.json'); writeJson(path.join(tempHome, '.ccs', 'config.json'), { profiles: { 'foo.bar': customSettingsPath, @@ -231,6 +249,196 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.hookInstalled).toBe(true); }); + it('resolves configured profile and variant relative settings paths under the CCS directory', async () => { + const profileSettingsPath = path.join(tempHome, '.ccs', 'profiles', 'relative.settings.json'); + const variantSettingsPath = path.join( + tempHome, + '.ccs', + 'variants', + 'relative-var.settings.json' + ); + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: { + relative: 'profiles/relative.settings.json', + }, + cliproxy: { + 'relative-var': { + provider: 'gemini', + settings: 'variants/relative-var.settings.json', + }, + }, + }); + writeProfileSettings( + tempHome, + 'relative', + { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'relative-profile-key', + }, + profileSettingsPath + ); + writeProfileSettings( + tempHome, + 'relative-var', + { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'relative-variant-key', + }, + variantSettingsPath + ); + + const profileResponse = await fetch(`${baseUrl}/api/settings/relative/raw`); + expect(profileResponse.status).toBe(200); + const profileBody = (await profileResponse.json()) as { path: string }; + expect(profileBody.path).toBe(profileSettingsPath); + + const variantResponse = await fetch(`${baseUrl}/api/settings/relative-var/raw`); + expect(variantResponse.status).toBe(200); + const variantBody = (await variantResponse.json()) as { path: string }; + expect(variantBody.path).toBe(variantSettingsPath); + }); + + it('rejects configured profile settings paths outside the CCS directory', async () => { + const outsideSettingsPath = path.join(tempHome, 'outside', 'escaped.settings.json'); + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: { + escaped: outsideSettingsPath, + }, + }); + writeJson(outsideSettingsPath, { + env: { + ANTHROPIC_AUTH_TOKEN: 'outside-secret', + }, + }); + + const readResponse = await fetch(`${baseUrl}/api/settings/escaped/raw`); + expect(readResponse.status).toBe(500); + + const writeResponse = await fetch(`${baseUrl}/api/settings/escaped`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: 'overwritten-secret', + }, + }, + }), + }); + expect(writeResponse.status).toBe(500); + + const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as { + env: { ANTHROPIC_AUTH_TOKEN: string }; + }; + expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('outside-secret'); + }); + + it('rejects raw reads through settings-file symlink escapes', async () => { + const outsideSettingsPath = path.join(tempHome, 'outside-raw.settings.json'); + const linkedSettingsPath = path.join(tempHome, '.ccs', 'linked.settings.json'); + writeJson(outsideSettingsPath, { + env: { + ANTHROPIC_AUTH_TOKEN: 'raw-outside-secret', + }, + }); + fs.mkdirSync(path.dirname(linkedSettingsPath), { recursive: true }); + if (!createSymlinkIfAvailable(outsideSettingsPath, linkedSettingsPath)) { + return; + } + + const response = await fetch(`${baseUrl}/api/settings/linked/raw`); + expect(response.status).toBe(500); + + const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as { + env: { ANTHROPIC_AUTH_TOKEN: string }; + }; + expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('raw-outside-secret'); + }); + + it('rejects variant settings paths outside the CCS directory', async () => { + const outsideSettingsPath = path.join(tempHome, 'outside-variant', 'escaped.settings.json'); + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: {}, + cliproxy: { + evilvar: { + provider: 'gemini', + settings: outsideSettingsPath, + }, + }, + }); + writeJson(outsideSettingsPath, { + env: { + ANTHROPIC_AUTH_TOKEN: 'variant-outside-secret', + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/evilvar/raw`); + expect(response.status).toBe(500); + + const writeResponse = await fetch(`${baseUrl}/api/settings/evilvar`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: 'variant-overwritten-secret', + }, + }, + }), + }); + expect(writeResponse.status).toBe(500); + + const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as { + env: { ANTHROPIC_AUTH_TOKEN: string }; + }; + expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('variant-outside-secret'); + }); + + it('rejects variant PUT settings paths that escape through symlinked parents', async () => { + const outsideDir = path.join(tempHome, 'outside-variant-link'); + const outsideSettingsPath = path.join(outsideDir, 'escaped.settings.json'); + const linkDir = path.join(tempHome, '.ccs', 'variant-link'); + const linkedSettingsPath = path.join(linkDir, 'escaped.settings.json'); + fs.mkdirSync(outsideDir, { recursive: true }); + writeJson(outsideSettingsPath, { + env: { + ANTHROPIC_AUTH_TOKEN: 'variant-link-original', + }, + }); + fs.mkdirSync(path.dirname(linkDir), { recursive: true }); + if (!createSymlinkIfAvailable(outsideDir, linkDir, 'dir')) { + return; + } + + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: {}, + cliproxy: { + 'linked-var': { + provider: 'gemini', + settings: linkedSettingsPath, + }, + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/linked-var`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: 'variant-link-overwritten', + }, + }, + }), + }); + expect(response.status).toBe(500); + + const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as { + env: { ANTHROPIC_AUTH_TOKEN: string }; + }; + expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('variant-link-original'); + }); + it('previews image-analysis status from unsaved editor settings', async () => { writeProfileSettings(tempHome, 'glm', { ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', diff --git a/tests/unit/web-server/start-server-host.test.ts b/tests/unit/web-server/start-server-host.test.ts index e0a48464..bcfa5b69 100644 --- a/tests/unit/web-server/start-server-host.test.ts +++ b/tests/unit/web-server/start-server-host.test.ts @@ -5,6 +5,33 @@ import { startServer } from '../../../src/web-server'; const instances: Array>> = []; +class MockUpgradeSocket { + data = ''; + destroyed = false; + + write(chunk: string | Buffer): boolean { + this.data += chunk.toString(); + return true; + } + + destroy(): void { + this.destroyed = true; + } +} + +function dispatchUpgrade(instance: Awaited>, url: string) { + const listener = instance.server.listeners('upgrade')[0] as ( + request: unknown, + socket: MockUpgradeSocket, + head: Buffer + ) => void; + const socket = new MockUpgradeSocket(); + + listener({ url, headers: {}, socket: { remoteAddress: '127.0.0.1' } }, socket, Buffer.alloc(0)); + + return socket; +} + afterEach(async () => { while (instances.length > 0) { const instance = instances.pop(); @@ -20,12 +47,13 @@ afterEach(async () => { }); describe('startServer host binding', () => { - it('binds with system-default host when no host is provided', async () => { + it('binds to localhost by default when no host is provided', async () => { const instance = await startServer({ port: 0 }); instances.push(instance); const address = instance.server.address() as AddressInfo; expect(address.port).toBeGreaterThan(0); + expect(['127.0.0.1', '::1']).toContain(address.address); }); it('binds to an explicit loopback host', async () => { @@ -66,4 +94,24 @@ describe('startServer host binding', () => { expect(serverConfig?.middlewareMode).toBe(true); expect(serverConfig?.hmr?.server).toBe(instance.server); }); + + it('rejects unsupported production websocket upgrade paths', async () => { + const instance = await startServer({ port: 0 }); + instances.push(instance); + + const socket = dispatchUpgrade(instance, '/vite-hmr'); + + expect(socket.data.startsWith('HTTP/1.1 404')).toBe(true); + expect(socket.destroyed).toBe(true); + }); + + it('rejects malformed websocket upgrade targets', async () => { + const instance = await startServer({ port: 0 }); + instances.push(instance); + + const socket = dispatchUpgrade(instance, 'http://[bad'); + + expect(socket.data.startsWith('HTTP/1.1 400')).toBe(true); + expect(socket.destroyed).toBe(true); + }); }); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 403ba88f..12cfda55 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -378,13 +378,13 @@ export function AddAccountDialog({ return ( { // Prevent accidental close by clicking outside during auth if (showAuthUI) e.preventDefault(); }} > - + {t('addAccountDialog.title', { displayName })} {isKiro @@ -395,7 +395,7 @@ export function AddAccountDialog({ -
+
{requiresAgyResponsibilityFlow && !showAuthUI && ( {errorMessage}

} + {errorMessage && ( +

+ {errorMessage} +

+ )} {/* Kiro import loading */} {kiroImportMutation.isPending && ( @@ -744,12 +748,17 @@ export function AddAccountDialog({ )} {/* Action buttons */} -
- {isKiro && !showAuthUI && ( -
diff --git a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx index 62dd91f6..7ffacba1 100644 --- a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx +++ b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx @@ -23,6 +23,7 @@ interface RawConfigEditorPanelProps { onRefresh: () => Promise | void; onDiscard?: () => void; language?: 'json' | 'yaml' | 'toml'; + exactText?: boolean; loadingLabel?: string; parseWarningLabel?: string; ownershipNotice?: ReactNode; @@ -44,6 +45,7 @@ export function RawConfigEditorPanel({ onRefresh, onDiscard, language = 'json', + exactText = false, loadingLabel = 'Loading settings.json...', parseWarningLabel = 'Parse warning', ownershipNotice, @@ -128,6 +130,7 @@ export function RawConfigEditorPanel({ onChange={onChange} language={language} readonly={readOnly} + exactText={exactText} minHeight="100%" heightMode="fill-parent" /> diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx index aca2a3fa..f911da32 100644 --- a/ui/src/components/setup/wizard/index.tsx +++ b/ui/src/components/setup/wizard/index.tsx @@ -177,7 +177,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { return ( { if (step !== 'success' && step !== 'provider') { e.preventDefault(); @@ -189,8 +189,8 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { } }} > - - + + {i18n.t('setupWizard.title')} @@ -203,7 +203,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { -
+
{step === 'provider' && ( )} @@ -254,7 +254,9 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { {step === 'success' && }
- +
+ +
); diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 45a1639f..394212de 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -73,12 +73,12 @@ export function VariantStep({ return (
{selectedAccount && ( -
- -
+
+ +
{t('setupVariant.using')}{' '} - + {selectedAccountIdentity?.email} @@ -167,16 +167,18 @@ export function VariantStep({ value={catalogModels.some((m) => m.id === modelName) ? modelName : ''} onValueChange={handleModelSelect} > - + - + {catalogModels.map((m) => ( -
- {m.name} +
+ {m.name} {m.description && ( - - {m.description} + + - {m.description} + )}
@@ -196,12 +198,12 @@ export function VariantStep({
-
- -
+
diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx index 311e0371..2f028edc 100644 --- a/ui/src/components/shared/code-editor.tsx +++ b/ui/src/components/shared/code-editor.tsx @@ -4,7 +4,7 @@ * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB) */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useRef, type UIEvent } from 'react'; import Editor from 'react-simple-code-editor'; import { Highlight, themes } from 'prism-react-renderer'; import Prism from 'prismjs'; @@ -24,6 +24,7 @@ interface CodeEditorProps { onChange: (value: string) => void; language?: 'json' | 'yaml' | 'toml'; readonly?: boolean; + exactText?: boolean; className?: string; minHeight?: string; heightMode?: 'content' | 'fill-parent'; @@ -95,6 +96,7 @@ export function CodeEditor({ onChange, language = 'json', readonly = false, + exactText = false, className, minHeight = '300px', heightMode = 'content', @@ -170,6 +172,11 @@ export function CodeEditor({ } const tokenProps = getTokenProps({ token }); + // Prism TOML emits a `table` class, which collides with Tailwind's layout utility. + tokenProps.style = { + ...tokenProps.style, + display: 'inline', + }; if (isSensitive && isMasked) { tokenProps.className = cn( @@ -189,6 +196,14 @@ export function CodeEditor({ ), [isDark, language, validation.line, isMasked] ); + const highlightLayerRef = useRef(null); + const syncHighlightScroll = useCallback((event: UIEvent) => { + const layer = highlightLayerRef.current; + if (!layer) return; + + const { scrollTop } = event.currentTarget; + layer.style.transform = `translateY(${-scrollTop}px)`; + }, []); return (
- {} : onChange} - highlight={highlightCode} - key={isDark ? 'dark-editor' : 'light-editor'} - padding={12} - disabled={readonly} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - textareaClassName={cn( - 'focus:outline-none font-mono text-sm', - readonly && 'cursor-not-allowed' - )} - preClassName="font-mono text-sm" - style={{ - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', - fontSize: '0.875rem', - minHeight, - }} - /> + {exactText ? ( + // Exact mode keeps the native textarea as the editable/copyable source of truth while + // rendering Prism colors behind it with the same soft-wrap layout contract. +
+ +