diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index bafcea63..07bb96a4 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -28,7 +28,7 @@ import { import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status'; import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; -import { stripClaudeCodeEnv } from '../../utils/shell-executor'; +import { stripBrowserEnv, stripClaudeCodeEnv } from '../../utils/shell-executor'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; @@ -383,8 +383,11 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record v !== undefined) + const baseEnv = stripBrowserEnv( + Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined)) as Record< + string, + string + > ) as Record; const effectiveEnvVarsFiltered = Object.fromEntries( diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index a3548fc6..07b79708 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -37,6 +37,7 @@ import type { OfficialChannelId, DashboardAuthConfig, BrowserConfig, + BrowserEvalMode, BrowserToolPolicy, ImageAnalysisConfig, LoggingConfig, @@ -76,17 +77,37 @@ function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy { return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy; } -function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig { +function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode { + if (value === 'disabled' || value === 'readonly' || value === 'readwrite') { + return value; + } + + return DEFAULT_BROWSER_CONFIG.claude.eval_mode ?? 'readonly'; +} + +function canonicalizeBrowserConfig( + config?: BrowserConfig, + fallback: BrowserConfig = DEFAULT_BROWSER_CONFIG +): BrowserConfig { + const claudeUserDataDir = + config?.claude?.user_data_dir === undefined + ? fallback.claude.user_data_dir || getRecommendedBrowserUserDataDir() + : config.claude.user_data_dir.trim() || getRecommendedBrowserUserDataDir(); + return { claude: { - enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled, - policy: normalizeBrowserPolicy(config?.claude?.policy), - user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(), - devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port), + enabled: config?.claude?.enabled ?? fallback.claude.enabled, + policy: normalizeBrowserPolicy(config?.claude?.policy ?? fallback.claude.policy), + user_data_dir: claudeUserDataDir, + devtools_port: normalizeBrowserDevtoolsPort( + config?.claude?.devtools_port ?? fallback.claude.devtools_port + ), + eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode ?? fallback.claude.eval_mode), }, codex: { - enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled, - policy: normalizeBrowserPolicy(config?.codex?.policy), + enabled: config?.codex?.enabled ?? fallback.codex.enabled, + policy: normalizeBrowserPolicy(config?.codex?.policy ?? fallback.codex.policy), + eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode ?? fallback.codex.eval_mode), }, }; } @@ -1131,7 +1152,13 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig { return withConfigWriteLock(() => { const current = loadUnifiedConfigWithLockHeld(); + const previousBrowser = current.browser + ? canonicalizeBrowserConfig(current.browser) + : undefined; mutator(current); + if (current.browser) { + current.browser = canonicalizeBrowserConfig(current.browser, previousBrowser); + } writeUnifiedConfigWithLockHeld(current); return current; }); diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 6b294857..3d92af79 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -822,6 +822,7 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { * Controls Claude browser attach and Codex browser tooling. */ export type BrowserToolPolicy = 'auto' | 'manual'; +export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; export interface BrowserClaudeConfig { /** Enable Claude browser attach (default: false) */ @@ -832,6 +833,8 @@ export interface BrowserClaudeConfig { user_data_dir: string; /** DevTools port used for attach mode (default: 9222) */ devtools_port: number; + /** Eval access mode exposed through browser settings/status surfaces */ + eval_mode?: BrowserEvalMode; } export interface BrowserCodexConfig { @@ -839,6 +842,8 @@ export interface BrowserCodexConfig { enabled: boolean; /** Control whether Codex browser tooling is exposed automatically or only via --browser */ policy: BrowserToolPolicy; + /** Eval access mode exposed through browser settings/status surfaces */ + eval_mode?: BrowserEvalMode; } export interface BrowserConfig { @@ -852,10 +857,12 @@ export const DEFAULT_BROWSER_CONFIG: BrowserConfig = { policy: 'manual', user_data_dir: '', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: false, policy: 'manual', + eval_mode: 'readonly', }, }; diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index d1e8c57c..397ce6e1 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -12,6 +12,7 @@ import type { ProfileType } from '../types/profile'; import { escapeShellArg, getWindowsEscapedCommandShell, + stripBrowserEnv, stripAnthropicEnv, stripClaudeCodeEnv, } from '../utils/shell-executor'; @@ -60,10 +61,10 @@ export class ClaudeAdapter implements TargetAdapter { ? stripAnthropicEnv(process.env) : process.env; - const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv }; + const env: NodeJS.ProcessEnv = { ...stripBrowserEnv(baseEnv), ...webSearchEnv }; if (creds.envVars) { - Object.assign(env, creds.envVars); + Object.assign(env, stripBrowserEnv(creds.envVars)); } if (creds.browserRuntimeEnv) { Object.assign(env, creds.browserRuntimeEnv); diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 3b74976b..f82aa23b 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -7,6 +7,7 @@ import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { escapeShellArg, getWindowsEscapedCommandShell, + stripBrowserEnv, stripAnthropicEnv, stripCodexSessionEnv, } from '../utils/shell-executor'; @@ -252,7 +253,7 @@ export class CodexAdapter implements TargetAdapter { buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { - ...stripCodexSessionEnv(stripAnthropicEnv(process.env)), + ...stripBrowserEnv(stripCodexSessionEnv(stripAnthropicEnv(process.env))), }; delete env[CODEX_RUNTIME_ENV_KEY]; if (profileType !== 'default') { diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index be8838d7..89faa134 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { BrowserConfig } from '../../config/unified-config-types'; +import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types'; import { getCcsDir, getCcsPathDisplay } from '../config-manager'; import { expandPath } from '../helpers'; import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; @@ -15,6 +15,7 @@ export interface EffectiveClaudeBrowserAttachConfig { userDataDir: string; devtoolsPort: number; hasExplicitDevtoolsPort: boolean; + evalMode: BrowserEvalMode; } export interface BrowserLaunchCommands { @@ -230,15 +231,17 @@ export function getEffectiveClaudeBrowserAttachConfig( const configUserDataDir = resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir(); const configPort = normalizeDevtoolsPort(config.claude.devtools_port); + const configEvalMode = config.claude.eval_mode ?? 'readonly'; if (override.userDataDir) { return { - enabled: true, + enabled: config.claude.enabled, source: override.source as BrowserOverrideSource, overrideActive: true, userDataDir: override.userDataDir, devtoolsPort: override.devtoolsPort ?? configPort, hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, + evalMode: configEvalMode, }; } @@ -249,6 +252,7 @@ export function getEffectiveClaudeBrowserAttachConfig( userDataDir: configUserDataDir, devtoolsPort: configPort, hasExplicitDevtoolsPort: true, + evalMode: configEvalMode, }; } @@ -267,11 +271,15 @@ export async function resolveOptionalBrowserAttachRuntime( } try { + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir: config.userDataDir, + devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined, + }); return { - runtimeEnv: await resolveBrowserRuntimeEnv({ - profileDir: config.userDataDir, - devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined, - }), + runtimeEnv: { + ...runtimeEnv, + CCS_BROWSER_EVAL_MODE: config.evalMode, + }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index 42b72222..2fbe14ef 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -1,5 +1,9 @@ import * as path from 'path'; -import type { BrowserConfig, BrowserToolPolicy } from '../../config/unified-config-types'; +import type { + BrowserConfig, + BrowserEvalMode, + BrowserToolPolicy, +} from '../../config/unified-config-types'; import { getBrowserConfig, loadUnifiedConfig } from '../../config/unified-config-loader'; import { getCcsPathDisplay } from '../config-manager'; import { getCodexBinaryInfo } from '../../targets/codex-detector'; @@ -19,6 +23,7 @@ import { export interface ClaudeBrowserStatus { enabled: boolean; policy: BrowserToolPolicy; + evalMode: BrowserEvalMode; source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; overrideActive: boolean; state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready'; @@ -37,6 +42,7 @@ export interface ClaudeBrowserStatus { export interface CodexBrowserStatus { enabled: boolean; policy: BrowserToolPolicy; + evalMode: BrowserEvalMode; state: 'disabled' | 'enabled' | 'unsupported_build'; title: string; detail: string; @@ -69,6 +75,12 @@ function resolveSafeBrowserPolicy(policy: BrowserToolPolicy | undefined): Browse return policy === 'auto' || policy === 'manual' ? policy : 'manual'; } +function resolveSafeBrowserEvalMode(evalMode: BrowserEvalMode | undefined): BrowserEvalMode { + return evalMode === 'disabled' || evalMode === 'readonly' || evalMode === 'readwrite' + ? evalMode + : 'readonly'; +} + export function getUserFacingBrowserConfig(): BrowserConfig { const canonical = getBrowserConfig(); const persisted = loadUnifiedConfig()?.browser as PersistedBrowserConfig | undefined; @@ -79,11 +91,13 @@ export function getUserFacingBrowserConfig(): BrowserConfig { ...canonical.claude, enabled: false, policy: 'manual', + eval_mode: resolveSafeBrowserEvalMode(canonical.claude.eval_mode), }, codex: { ...canonical.codex, enabled: false, policy: 'manual', + eval_mode: resolveSafeBrowserEvalMode(canonical.codex.eval_mode), }, }; } @@ -93,11 +107,17 @@ export function getUserFacingBrowserConfig(): BrowserConfig { ...canonical.claude, enabled: persisted.claude?.enabled ?? false, policy: resolveSafeBrowserPolicy(persisted.claude?.policy), + eval_mode: resolveSafeBrowserEvalMode( + persisted.claude?.eval_mode ?? canonical.claude.eval_mode + ), }, codex: { ...canonical.codex, enabled: persisted.codex?.enabled ?? false, policy: resolveSafeBrowserPolicy(persisted.codex?.policy), + eval_mode: resolveSafeBrowserEvalMode( + persisted.codex?.eval_mode ?? canonical.codex.eval_mode + ), }, }; } @@ -110,6 +130,7 @@ async function buildClaudeBrowserStatus( const base: Omit = { enabled: effective.enabled, policy: browserConfig.claude.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.claude.eval_mode), source: effective.source, overrideActive: effective.overrideActive, effectiveUserDataDir: effective.userDataDir, @@ -224,6 +245,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()): return { enabled: false, policy: browserConfig.codex.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode), state: 'disabled', title: 'Codex Browser Tools are disabled.', detail: @@ -242,6 +264,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()): return { enabled: true, policy: browserConfig.codex.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode), state: 'unsupported_build', title: 'Codex Browser Tools need a Codex build with --config override support.', detail: binaryInfo @@ -258,6 +281,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()): return { enabled: true, policy: browserConfig.codex.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode), state: 'enabled', title: 'Codex Browser Tools are enabled.', detail: @@ -283,5 +307,6 @@ export function getManagedBrowserSetupHint(): string { userDataDir: getRecommendedBrowserUserDataDir(), devtoolsPort: 9222, hasExplicitDevtoolsPort: true, + evalMode: 'readonly', }).join('\n'); } diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index b6f26ab2..c239b3f7 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -26,6 +26,22 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } +/** + * Strip inherited browser attach/runtime env vars from a process environment. + * + * Browser capability is opt-in and launch-scoped. Stale CCS_BROWSER_* values + * from the parent process must never bleed into a browser-off child launch. + */ +export function stripBrowserEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const key of Object.keys(env)) { + if (!key.toUpperCase().startsWith('CCS_BROWSER_')) { + result[key] = env[key]; + } + } + return result; +} + /** * Strip Claude Code nested-session guard env var from a process environment. * @@ -142,10 +158,11 @@ export function execClaude( // with native Claude API routing. Settings-based profiles explicitly inject // their own ANTHROPIC_* values, so they don't need this protection. const profileType = envVars?.CCS_PROFILE_TYPE; - const baseEnv = + const inheritedEnv = profileType === 'account' || profileType === 'default' ? stripAnthropicEnv(process.env) : process.env; + const baseEnv = stripBrowserEnv(inheritedEnv); // Prepare environment (merge with base env if envVars provided) const mergedEnv = envVars diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index c42272a2..fdab88c9 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -3,7 +3,7 @@ * Session-based auth with httpOnly cookies for CCS dashboard. */ -import type { Request, Response, NextFunction } from 'express'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; import session from 'express-session'; import rateLimit from 'express-rate-limit'; import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader'; @@ -84,7 +84,7 @@ export const loginRateLimiter = rateLimit({ /** * Create session middleware configured for CCS dashboard. */ -export function createSessionMiddleware() { +export function createSessionMiddleware(): RequestHandler { const authConfig = getDashboardAuthConfig(); const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000; diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts index 215960fb..f25dcc2f 100644 --- a/src/web-server/routes/browser-routes.ts +++ b/src/web-server/routes/browser-routes.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { mutateUnifiedConfig } from '../../config/unified-config-loader'; -import type { BrowserConfig } from '../../config/unified-config-types'; +import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types'; import { getBrowserStatus } from '../../utils/browser'; import { getUserFacingBrowserConfig } from '../../utils/browser/browser-status'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; @@ -15,10 +15,12 @@ interface BrowserRouteBody { policy?: 'auto' | 'manual'; userDataDir?: string; devtoolsPort?: number; + evalMode?: BrowserEvalMode; }; codex?: { enabled?: boolean; policy?: 'auto' | 'manual'; + evalMode?: BrowserEvalMode; }; } @@ -30,6 +32,10 @@ function isValidBrowserPolicy(value: string): value is 'auto' | 'manual' { return value === 'auto' || value === 'manual'; } +function isValidBrowserEvalMode(value: string): value is BrowserEvalMode { + return value === 'disabled' || value === 'readonly' || value === 'readwrite'; +} + function isValidDevtoolsPort(value: number): boolean { return Number.isInteger(value) && value >= 1 && value <= 65535; } @@ -88,7 +94,11 @@ router.put('/', async (req: Request, res: Response): Promise => { } const unknownClaudeKeys = Object.keys(claude ?? {}).filter( (key) => - key !== 'enabled' && key !== 'policy' && key !== 'userDataDir' && key !== 'devtoolsPort' + key !== 'enabled' && + key !== 'policy' && + key !== 'userDataDir' && + key !== 'devtoolsPort' && + key !== 'evalMode' ); if (unknownClaudeKeys.length > 0) { res.status(400).json({ @@ -97,7 +107,7 @@ router.put('/', async (req: Request, res: Response): Promise => { return; } const unknownCodexKeys = Object.keys(codex ?? {}).filter( - (key) => key !== 'enabled' && key !== 'policy' + (key) => key !== 'enabled' && key !== 'policy' && key !== 'evalMode' ); if (unknownCodexKeys.length > 0) { res.status(400).json({ @@ -129,6 +139,15 @@ router.put('/', async (req: Request, res: Response): Promise => { }); return; } + if ( + claude?.evalMode !== undefined && + (typeof claude.evalMode !== 'string' || !isValidBrowserEvalMode(claude.evalMode)) + ) { + res.status(400).json({ + error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + return; + } if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') { res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' }); return; @@ -140,6 +159,15 @@ router.put('/', async (req: Request, res: Response): Promise => { res.status(400).json({ error: 'Invalid value for codex.policy. Must be auto or manual.' }); return; } + if ( + codex?.evalMode !== undefined && + (typeof codex.evalMode !== 'string' || !isValidBrowserEvalMode(codex.evalMode)) + ) { + res.status(400).json({ + error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + return; + } try { const current = getUserFacingBrowserConfig(); @@ -152,10 +180,12 @@ router.put('/', async (req: Request, res: Response): Promise => { policy: claude?.policy ?? current.claude.policy, user_data_dir: nextClaudeUserDataDir, devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port, + eval_mode: claude?.evalMode ?? current.claude.eval_mode, }, codex: { enabled: codex?.enabled ?? current.codex.enabled, policy: codex?.policy ?? current.codex.policy, + eval_mode: codex?.evalMode ?? current.codex.eval_mode, }, }; }); @@ -181,10 +211,12 @@ function toBrowserRouteConfig(config: BrowserConfig) { policy: config.claude.policy, userDataDir: config.claude.user_data_dir, devtoolsPort: config.claude.devtools_port, + evalMode: config.claude.eval_mode ?? 'readonly', }, codex: { enabled: config.codex.enabled, policy: config.codex.policy, + evalMode: config.codex.eval_mode ?? 'readonly', }, }; } diff --git a/tests/unit/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts index 90a3d460..0beb80f1 100644 --- a/tests/unit/config/migration-manager.test.ts +++ b/tests/unit/config/migration-manager.test.ts @@ -202,10 +202,12 @@ describe('migration-manager legacy kimi compatibility', () => { policy: 'manual', user_data_dir: '', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: false, policy: 'manual', + eval_mode: 'readonly', }, }); }); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 474e1429..9d79ebce 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -124,6 +124,9 @@ if (envOut) { 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' ); } @@ -173,19 +176,28 @@ process.exit(0); CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, CCS_THINKING: '8192', - CCS_BROWSER_USER_DATA_DIR: '', - CCS_BROWSER_PROFILE_DIR: '', - CCS_BROWSER_DEVTOOLS_HOST: '', - CCS_BROWSER_DEVTOOLS_PORT: '', - CCS_BROWSER_DEVTOOLS_HTTP_URL: '', - CCS_BROWSER_DEVTOOLS_WS_URL: '', - CCS_BROWSER_EVAL_MODE: '', + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-codex-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-codex-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-codex-env', }); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); expect(calls).toEqual([['fix failing tests']]); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: undefined, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); }); it('injects browser MCP runtime overrides when Codex browser policy is explicitly auto-enabled', () => { @@ -500,6 +512,9 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, }, ]); }); @@ -540,6 +555,9 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, }); }); @@ -618,6 +636,9 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, }, ]); }); diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index 728a9b2b..28d92d3a 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -119,6 +119,7 @@ describe('default profile browser launch', () => { printf "%s\n" "$@" > "${claudeArgsLogPath}" { printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "legacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" @@ -266,6 +267,30 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); }); + it('scrubs inherited CCS_BROWSER_* env from browser-off default Claude launches', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['default', 'smoke'], { + ...baseEnv, + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-browser-legacy', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9555', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9555', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-default-env', + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).not.toContain('/tmp/stale-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/stale-browser-legacy'); + expect(launchedEnv).not.toContain('9555'); + expect(launchedEnv).not.toContain('stale-default-env'); + }); + it('skips managed browser attach when the default CCS browser profile directory is missing', () => { if (process.platform === 'win32') return; diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index f5af261a..bc3e00cc 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -131,6 +131,7 @@ describe('settings profile browser launch', () => { printf "%s\n" "$@" > "${claudeArgsLogPath}" { printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "legacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" @@ -253,6 +254,30 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); }); + it('scrubs inherited CCS_BROWSER_* env from browser-off settings-profile launches', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-settings-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-settings-browser-legacy', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9666', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9666', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-settings-env', + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).not.toContain('/tmp/stale-settings-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/stale-settings-browser-legacy'); + expect(launchedEnv).not.toContain('9666'); + expect(launchedEnv).not.toContain('stale-settings-env'); + }); + it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => { if (process.platform === 'win32') return; diff --git a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts index 110d1123..ee2d7187 100644 --- a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts +++ b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts @@ -1,10 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from 'bun:test'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read'; +setDefaultTimeout(30000); interface RunResult { status: number | null; @@ -98,6 +99,9 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY" printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL" printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH" + printf "browserUserDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "browserLegacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" + printf "browserWsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" } > "${claudeEnvLogPath}" exit 0 `, @@ -261,6 +265,24 @@ exit 0 expect(launchedEnv).not.toContain('runtimeApiKey=stale-token'); }); + it('scrubs stale CCS_BROWSER_* env while preserving settings-profile image analysis runtime', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-image-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-image-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-image-env', + }); + + expect(result.status).toBe(0); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).not.toContain('stale-token'); + expect(launchedEnv).not.toContain('/tmp/stale-image-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/stale-image-browser-legacy'); + expect(launchedEnv).not.toContain('stale-image-env'); + }); + it('pins direct settings image analysis to the current local CLIProxy auth token', () => { if (process.platform === 'win32') return; diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 65c037d5..1fc660c5 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -126,10 +126,12 @@ describe('unified-config-types', () => { policy: 'manual', user_data_dir: '', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: false, policy: 'manual', + eval_mode: 'readonly', }, }); }); diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index abaeaaff..9add8441 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -261,6 +261,35 @@ describe('browser status', () => { expect(resolution.warning).toContain('ccs browser doctor'); }); + it('keeps env override paths from implicitly enabling Claude browser attach when config is disabled', () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: false, + policy: 'manual', + user_data_dir: '/config-browser', + devtools_port: 9333, + }, + codex: { + enabled: false, + policy: 'manual', + }, + }; + }); + process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser'; + process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444'; + + const effective = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); + + expect(effective).toMatchObject({ + enabled: false, + source: 'CCS_BROWSER_USER_DATA_DIR', + overrideActive: true, + userDataDir: '/env-browser', + devtoolsPort: 9444, + }); + }); + it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => { const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data'); mkdirSync(managedDir, { recursive: true }); @@ -318,6 +347,20 @@ describe('browser status', () => { }); it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + policy: 'manual', + user_data_dir: '/config-browser', + devtools_port: 9333, + }, + codex: { + enabled: false, + policy: 'manual', + }, + }; + }); process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser'; const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts index 128bec04..46482358 100644 --- a/tests/unit/web-server/browser-routes.test.ts +++ b/tests/unit/web-server/browser-routes.test.ts @@ -153,26 +153,64 @@ describe('browser routes', () => { policy: 'manual', userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9222, + evalMode: 'readonly', }, codex: { enabled: false, policy: 'manual', + evalMode: 'readonly', }, }); expect(payload.status.claude).toMatchObject({ state: 'disabled', policy: 'manual', + evalMode: 'readonly', managedMcpServerName: 'ccs-browser', }); expect(payload.status.codex).toMatchObject({ enabled: false, state: 'disabled', policy: 'manual', + evalMode: 'readonly', serverName: 'ccs_browser', }); expect(payload.status.codex.detail).toContain('off by default'); }); + it('returns evalMode through the standalone browser status route', async () => { + const updateResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + enabled: true, + policy: 'manual', + evalMode: 'readwrite', + }, + codex: { + enabled: true, + policy: 'auto', + evalMode: 'disabled', + }, + }), + }); + + expect(updateResponse.status).toBe(200); + + const response = await fetch(`${baseUrl}/api/browser/status`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + claude: { + policy: 'manual', + evalMode: 'readwrite', + }, + codex: { + policy: 'auto', + evalMode: 'disabled', + }, + }); + }); + it('updates the saved browser config through the dashboard route', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', @@ -183,10 +221,12 @@ describe('browser routes', () => { policy: 'manual', userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, + evalMode: 'readwrite', }, codex: { - enabled: false, - policy: 'manual', + enabled: true, + policy: 'auto', + evalMode: 'disabled', }, }), }); @@ -199,10 +239,22 @@ describe('browser routes', () => { policy: 'manual', userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, + evalMode: 'readwrite', }, codex: { - enabled: false, + enabled: true, + policy: 'auto', + evalMode: 'disabled', + }, + }); + expect(payload.browser.status).toMatchObject({ + claude: { policy: 'manual', + evalMode: 'readwrite', + }, + codex: { + policy: 'auto', + evalMode: 'disabled', }, }); @@ -213,14 +265,57 @@ describe('browser routes', () => { policy: 'manual', user_data_dir: '/tmp/ccs-browser', devtools_port: 9333, + eval_mode: 'readwrite', }, codex: { - enabled: false, - policy: 'manual', + enabled: true, + policy: 'auto', + eval_mode: 'disabled', }, }); }); + it('updates evalMode without changing the saved policy', async () => { + const firstResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + codex: { + enabled: true, + policy: 'auto', + evalMode: 'readonly', + }, + }), + }); + + expect(firstResponse.status).toBe(200); + + const secondResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + codex: { + evalMode: 'readwrite', + }, + }), + }); + + expect(secondResponse.status).toBe(200); + const payload = await secondResponse.json(); + expect(payload.browser.config.codex).toMatchObject({ + enabled: true, + policy: 'auto', + evalMode: 'readwrite', + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.browser?.codex).toMatchObject({ + enabled: true, + policy: 'auto', + eval_mode: 'readwrite', + }); + }); + it('treats a blank user-data directory as a reset to the recommended path', async () => { const firstResponse = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', @@ -231,6 +326,7 @@ describe('browser routes', () => { policy: 'manual', userDataDir: '/tmp/ccs-browser-custom', devtoolsPort: 9333, + evalMode: 'readonly', }, }), }); @@ -254,9 +350,11 @@ describe('browser routes', () => { policy: 'manual', userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9333, + evalMode: 'readonly', }); expect(payload.browser.status.claude.state).toBe('browser_not_running'); expect(payload.browser.status.claude.detail).toContain('CCS created the managed browser profile'); + expect(payload.browser.status.claude.evalMode).toBe('readonly'); expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true); const config = loadOrCreateUnifiedConfig(); @@ -266,6 +364,7 @@ describe('browser routes', () => { policy: 'manual', user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtools_port: 9333, + eval_mode: 'readonly', }, }); }); @@ -336,6 +435,23 @@ describe('browser routes', () => { }); }); + it('rejects invalid browser evalMode values at the route boundary', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + evalMode: 'always', + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + }); + it('rejects unknown nested browser lane fields instead of silently ignoring them', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index f7219616..38e43778 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -234,17 +234,20 @@ export interface UpdateImageAnalysisSettingsPayload { profileBackends?: Record; } +export type BrowserToolPolicy = 'auto' | 'manual'; export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; export interface BrowserSettingsConfig { claude: { enabled: boolean; + policy: BrowserToolPolicy; userDataDir: string; devtoolsPort: number; evalMode: BrowserEvalMode; }; codex: { enabled: boolean; + policy: BrowserToolPolicy; evalMode: BrowserEvalMode; }; } @@ -257,6 +260,7 @@ export interface BrowserLaunchCommands { export interface ClaudeBrowserStatus { enabled: boolean; + policy: BrowserToolPolicy; source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; overrideActive: boolean; state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready'; @@ -275,6 +279,7 @@ export interface ClaudeBrowserStatus { export interface CodexBrowserStatus { enabled: boolean; + policy: BrowserToolPolicy; state: 'disabled' | 'enabled' | 'unsupported_build'; title: string; detail: string; diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 3ff14bd4..9784c8dc 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -5,6 +5,7 @@ import type { BrowserEvalMode, + BrowserToolPolicy, BrowserSettingsConfig, BrowserStatusPayload, CliproxyServerConfig, @@ -199,7 +200,7 @@ export interface ThinkingConfig { // === Re-exports from api-client === -export type { BrowserEvalMode, CliproxyServerConfig, RemoteProxyStatus }; +export type { BrowserEvalMode, BrowserToolPolicy, CliproxyServerConfig, RemoteProxyStatus }; export type BrowserConfig = BrowserSettingsConfig; export type BrowserStatus = BrowserStatusPayload; export type BrowserSavePayload = UpdateBrowserSettingsPayload;