fix(browser): preserve opt-in browser settings and env isolation

This commit is contained in:
Tam Nhu Tran committed 2026-04-22 22:35:02 -04:00
1 parent 7732b2039a
commit c24033dc6b
20 files changed
+423 -40

No files matched your search

+6 -3
View File
@@ -28,7 +28,7 @@ import {
import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status'; import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status';
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; 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 { CodexReasoningProxy } from '../codex-reasoning-proxy';
import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy';
@@ -383,8 +383,11 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
const imageAnalysisEnv = resolvedImageAnalysisEnv ?? getImageAnalysisHookEnv(provider); const imageAnalysisEnv = resolvedImageAnalysisEnv ?? getImageAnalysisHookEnv(provider);
// Merge all environment variables (filter undefined values) // Merge all environment variables (filter undefined values)
const baseEnv = Object.fromEntries( const baseEnv = stripBrowserEnv(
Object.entries(process.env).filter(([, v]) => v !== undefined) Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined)) as Record<
string,
string
>
) as Record<string, string>; ) as Record<string, string>;
const effectiveEnvVarsFiltered = Object.fromEntries( const effectiveEnvVarsFiltered = Object.fromEntries(
+34 -7
View File
@@ -37,6 +37,7 @@ import type {
OfficialChannelId, OfficialChannelId,
DashboardAuthConfig, DashboardAuthConfig,
BrowserConfig, BrowserConfig,
BrowserEvalMode,
BrowserToolPolicy, BrowserToolPolicy,
ImageAnalysisConfig, ImageAnalysisConfig,
LoggingConfig, LoggingConfig,
@@ -76,17 +77,37 @@ function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy {
return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy; 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 { return {
claude: { claude: {
enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled, enabled: config?.claude?.enabled ?? fallback.claude.enabled,
policy: normalizeBrowserPolicy(config?.claude?.policy), policy: normalizeBrowserPolicy(config?.claude?.policy ?? fallback.claude.policy),
user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(), user_data_dir: claudeUserDataDir,
devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port), devtools_port: normalizeBrowserDevtoolsPort(
config?.claude?.devtools_port ?? fallback.claude.devtools_port
),
eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode ?? fallback.claude.eval_mode),
}, },
codex: { codex: {
enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled, enabled: config?.codex?.enabled ?? fallback.codex.enabled,
policy: normalizeBrowserPolicy(config?.codex?.policy), 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 { export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig {
return withConfigWriteLock(() => { return withConfigWriteLock(() => {
const current = loadUnifiedConfigWithLockHeld(); const current = loadUnifiedConfigWithLockHeld();
const previousBrowser = current.browser
? canonicalizeBrowserConfig(current.browser)
: undefined;
mutator(current); mutator(current);
if (current.browser) {
current.browser = canonicalizeBrowserConfig(current.browser, previousBrowser);
}
writeUnifiedConfigWithLockHeld(current); writeUnifiedConfigWithLockHeld(current);
return current; return current;
}); });
+7
View File
@@ -822,6 +822,7 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
* Controls Claude browser attach and Codex browser tooling. * Controls Claude browser attach and Codex browser tooling.
*/ */
export type BrowserToolPolicy = 'auto' | 'manual'; export type BrowserToolPolicy = 'auto' | 'manual';
export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite';
export interface BrowserClaudeConfig { export interface BrowserClaudeConfig {
/** Enable Claude browser attach (default: false) */ /** Enable Claude browser attach (default: false) */
@@ -832,6 +833,8 @@ export interface BrowserClaudeConfig {
user_data_dir: string; user_data_dir: string;
/** DevTools port used for attach mode (default: 9222) */ /** DevTools port used for attach mode (default: 9222) */
devtools_port: number; devtools_port: number;
/** Eval access mode exposed through browser settings/status surfaces */
eval_mode?: BrowserEvalMode;
} }
export interface BrowserCodexConfig { export interface BrowserCodexConfig {
@@ -839,6 +842,8 @@ export interface BrowserCodexConfig {
enabled: boolean; enabled: boolean;
/** Control whether Codex browser tooling is exposed automatically or only via --browser */ /** Control whether Codex browser tooling is exposed automatically or only via --browser */
policy: BrowserToolPolicy; policy: BrowserToolPolicy;
/** Eval access mode exposed through browser settings/status surfaces */
eval_mode?: BrowserEvalMode;
} }
export interface BrowserConfig { export interface BrowserConfig {
@@ -852,10 +857,12 @@ export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
policy: 'manual', policy: 'manual',
user_data_dir: '', user_data_dir: '',
devtools_port: 9222, devtools_port: 9222,
eval_mode: 'readonly',
}, },
codex: { codex: {
enabled: false, enabled: false,
policy: 'manual', policy: 'manual',
eval_mode: 'readonly',
}, },
}; };
+3 -2
View File
@@ -12,6 +12,7 @@ import type { ProfileType } from '../types/profile';
import { import {
escapeShellArg, escapeShellArg,
getWindowsEscapedCommandShell, getWindowsEscapedCommandShell,
stripBrowserEnv,
stripAnthropicEnv, stripAnthropicEnv,
stripClaudeCodeEnv, stripClaudeCodeEnv,
} from '../utils/shell-executor'; } from '../utils/shell-executor';
@@ -60,10 +61,10 @@ export class ClaudeAdapter implements TargetAdapter {
? stripAnthropicEnv(process.env) ? stripAnthropicEnv(process.env)
: process.env; : process.env;
const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv }; const env: NodeJS.ProcessEnv = { ...stripBrowserEnv(baseEnv), ...webSearchEnv };
if (creds.envVars) { if (creds.envVars) {
Object.assign(env, creds.envVars); Object.assign(env, stripBrowserEnv(creds.envVars));
} }
if (creds.browserRuntimeEnv) { if (creds.browserRuntimeEnv) {
Object.assign(env, creds.browserRuntimeEnv); Object.assign(env, creds.browserRuntimeEnv);
+2 -1
View File
@@ -7,6 +7,7 @@ import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { import {
escapeShellArg, escapeShellArg,
getWindowsEscapedCommandShell, getWindowsEscapedCommandShell,
stripBrowserEnv,
stripAnthropicEnv, stripAnthropicEnv,
stripCodexSessionEnv, stripCodexSessionEnv,
} from '../utils/shell-executor'; } from '../utils/shell-executor';
@@ -252,7 +253,7 @@ export class CodexAdapter implements TargetAdapter {
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv { buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { const env: NodeJS.ProcessEnv = {
...stripCodexSessionEnv(stripAnthropicEnv(process.env)), ...stripBrowserEnv(stripCodexSessionEnv(stripAnthropicEnv(process.env))),
}; };
delete env[CODEX_RUNTIME_ENV_KEY]; delete env[CODEX_RUNTIME_ENV_KEY];
if (profileType !== 'default') { if (profileType !== 'default') {
+14 -6
View File
@@ -1,6 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; 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 { getCcsDir, getCcsPathDisplay } from '../config-manager';
import { expandPath } from '../helpers'; import { expandPath } from '../helpers';
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
@@ -15,6 +15,7 @@ export interface EffectiveClaudeBrowserAttachConfig {
userDataDir: string; userDataDir: string;
devtoolsPort: number; devtoolsPort: number;
hasExplicitDevtoolsPort: boolean; hasExplicitDevtoolsPort: boolean;
evalMode: BrowserEvalMode;
} }
export interface BrowserLaunchCommands { export interface BrowserLaunchCommands {
@@ -230,15 +231,17 @@ export function getEffectiveClaudeBrowserAttachConfig(
const configUserDataDir = const configUserDataDir =
resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir(); resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir();
const configPort = normalizeDevtoolsPort(config.claude.devtools_port); const configPort = normalizeDevtoolsPort(config.claude.devtools_port);
const configEvalMode = config.claude.eval_mode ?? 'readonly';
if (override.userDataDir) { if (override.userDataDir) {
return { return {
enabled: true, enabled: config.claude.enabled,
source: override.source as BrowserOverrideSource, source: override.source as BrowserOverrideSource,
overrideActive: true, overrideActive: true,
userDataDir: override.userDataDir, userDataDir: override.userDataDir,
devtoolsPort: override.devtoolsPort ?? configPort, devtoolsPort: override.devtoolsPort ?? configPort,
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
evalMode: configEvalMode,
}; };
} }
@@ -249,6 +252,7 @@ export function getEffectiveClaudeBrowserAttachConfig(
userDataDir: configUserDataDir, userDataDir: configUserDataDir,
devtoolsPort: configPort, devtoolsPort: configPort,
hasExplicitDevtoolsPort: true, hasExplicitDevtoolsPort: true,
evalMode: configEvalMode,
}; };
} }
@@ -267,11 +271,15 @@ export async function resolveOptionalBrowserAttachRuntime(
} }
try { try {
const runtimeEnv = await resolveBrowserRuntimeEnv({
profileDir: config.userDataDir,
devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined,
});
return { return {
runtimeEnv: await resolveBrowserRuntimeEnv({ runtimeEnv: {
profileDir: config.userDataDir, ...runtimeEnv,
devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined, CCS_BROWSER_EVAL_MODE: config.evalMode,
}), },
}; };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
+26 -1
View File
@@ -1,5 +1,9 @@
import * as path from 'path'; 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 { getBrowserConfig, loadUnifiedConfig } from '../../config/unified-config-loader';
import { getCcsPathDisplay } from '../config-manager'; import { getCcsPathDisplay } from '../config-manager';
import { getCodexBinaryInfo } from '../../targets/codex-detector'; import { getCodexBinaryInfo } from '../../targets/codex-detector';
@@ -19,6 +23,7 @@ import {
export interface ClaudeBrowserStatus { export interface ClaudeBrowserStatus {
enabled: boolean; enabled: boolean;
policy: BrowserToolPolicy; policy: BrowserToolPolicy;
evalMode: BrowserEvalMode;
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
overrideActive: boolean; overrideActive: boolean;
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready'; state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
@@ -37,6 +42,7 @@ export interface ClaudeBrowserStatus {
export interface CodexBrowserStatus { export interface CodexBrowserStatus {
enabled: boolean; enabled: boolean;
policy: BrowserToolPolicy; policy: BrowserToolPolicy;
evalMode: BrowserEvalMode;
state: 'disabled' | 'enabled' | 'unsupported_build'; state: 'disabled' | 'enabled' | 'unsupported_build';
title: string; title: string;
detail: string; detail: string;
@@ -69,6 +75,12 @@ function resolveSafeBrowserPolicy(policy: BrowserToolPolicy | undefined): Browse
return policy === 'auto' || policy === 'manual' ? policy : 'manual'; 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 { export function getUserFacingBrowserConfig(): BrowserConfig {
const canonical = getBrowserConfig(); const canonical = getBrowserConfig();
const persisted = loadUnifiedConfig()?.browser as PersistedBrowserConfig | undefined; const persisted = loadUnifiedConfig()?.browser as PersistedBrowserConfig | undefined;
@@ -79,11 +91,13 @@ export function getUserFacingBrowserConfig(): BrowserConfig {
...canonical.claude, ...canonical.claude,
enabled: false, enabled: false,
policy: 'manual', policy: 'manual',
eval_mode: resolveSafeBrowserEvalMode(canonical.claude.eval_mode),
}, },
codex: { codex: {
...canonical.codex, ...canonical.codex,
enabled: false, enabled: false,
policy: 'manual', policy: 'manual',
eval_mode: resolveSafeBrowserEvalMode(canonical.codex.eval_mode),
}, },
}; };
} }
@@ -93,11 +107,17 @@ export function getUserFacingBrowserConfig(): BrowserConfig {
...canonical.claude, ...canonical.claude,
enabled: persisted.claude?.enabled ?? false, enabled: persisted.claude?.enabled ?? false,
policy: resolveSafeBrowserPolicy(persisted.claude?.policy), policy: resolveSafeBrowserPolicy(persisted.claude?.policy),
eval_mode: resolveSafeBrowserEvalMode(
persisted.claude?.eval_mode ?? canonical.claude.eval_mode
),
}, },
codex: { codex: {
...canonical.codex, ...canonical.codex,
enabled: persisted.codex?.enabled ?? false, enabled: persisted.codex?.enabled ?? false,
policy: resolveSafeBrowserPolicy(persisted.codex?.policy), 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<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = { const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
enabled: effective.enabled, enabled: effective.enabled,
policy: browserConfig.claude.policy, policy: browserConfig.claude.policy,
evalMode: resolveSafeBrowserEvalMode(browserConfig.claude.eval_mode),
source: effective.source, source: effective.source,
overrideActive: effective.overrideActive, overrideActive: effective.overrideActive,
effectiveUserDataDir: effective.userDataDir, effectiveUserDataDir: effective.userDataDir,
@@ -224,6 +245,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()):
return { return {
enabled: false, enabled: false,
policy: browserConfig.codex.policy, policy: browserConfig.codex.policy,
evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode),
state: 'disabled', state: 'disabled',
title: 'Codex Browser Tools are disabled.', title: 'Codex Browser Tools are disabled.',
detail: detail:
@@ -242,6 +264,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()):
return { return {
enabled: true, enabled: true,
policy: browserConfig.codex.policy, policy: browserConfig.codex.policy,
evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode),
state: 'unsupported_build', state: 'unsupported_build',
title: 'Codex Browser Tools need a Codex build with --config override support.', title: 'Codex Browser Tools need a Codex build with --config override support.',
detail: binaryInfo detail: binaryInfo
@@ -258,6 +281,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()):
return { return {
enabled: true, enabled: true,
policy: browserConfig.codex.policy, policy: browserConfig.codex.policy,
evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode),
state: 'enabled', state: 'enabled',
title: 'Codex Browser Tools are enabled.', title: 'Codex Browser Tools are enabled.',
detail: detail:
@@ -283,5 +307,6 @@ export function getManagedBrowserSetupHint(): string {
userDataDir: getRecommendedBrowserUserDataDir(), userDataDir: getRecommendedBrowserUserDataDir(),
devtoolsPort: 9222, devtoolsPort: 9222,
hasExplicitDevtoolsPort: true, hasExplicitDevtoolsPort: true,
evalMode: 'readonly',
}).join('\n'); }).join('\n');
} }
+18 -1
View File
@@ -26,6 +26,22 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return result; 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. * 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 // with native Claude API routing. Settings-based profiles explicitly inject
// their own ANTHROPIC_* values, so they don't need this protection. // their own ANTHROPIC_* values, so they don't need this protection.
const profileType = envVars?.CCS_PROFILE_TYPE; const profileType = envVars?.CCS_PROFILE_TYPE;
const baseEnv = const inheritedEnv =
profileType === 'account' || profileType === 'default' profileType === 'account' || profileType === 'default'
? stripAnthropicEnv(process.env) ? stripAnthropicEnv(process.env)
: process.env; : process.env;
const baseEnv = stripBrowserEnv(inheritedEnv);
// Prepare environment (merge with base env if envVars provided) // Prepare environment (merge with base env if envVars provided)
const mergedEnv = envVars const mergedEnv = envVars
+2 -2
View File
@@ -3,7 +3,7 @@
* Session-based auth with httpOnly cookies for CCS dashboard. * 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 session from 'express-session';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader'; import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader';
@@ -84,7 +84,7 @@ export const loginRateLimiter = rateLimit({
/** /**
* Create session middleware configured for CCS dashboard. * Create session middleware configured for CCS dashboard.
*/ */
export function createSessionMiddleware() { export function createSessionMiddleware(): RequestHandler {
const authConfig = getDashboardAuthConfig(); const authConfig = getDashboardAuthConfig();
const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000; const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000;
+35 -3
View File
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express'; import { Router, type Request, type Response } from 'express';
import { mutateUnifiedConfig } from '../../config/unified-config-loader'; 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 { getBrowserStatus } from '../../utils/browser';
import { getUserFacingBrowserConfig } from '../../utils/browser/browser-status'; import { getUserFacingBrowserConfig } from '../../utils/browser/browser-status';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
@@ -15,10 +15,12 @@ interface BrowserRouteBody {
policy?: 'auto' | 'manual'; policy?: 'auto' | 'manual';
userDataDir?: string; userDataDir?: string;
devtoolsPort?: number; devtoolsPort?: number;
evalMode?: BrowserEvalMode;
}; };
codex?: { codex?: {
enabled?: boolean; enabled?: boolean;
policy?: 'auto' | 'manual'; policy?: 'auto' | 'manual';
evalMode?: BrowserEvalMode;
}; };
} }
@@ -30,6 +32,10 @@ function isValidBrowserPolicy(value: string): value is 'auto' | 'manual' {
return value === 'auto' || value === '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 { function isValidDevtoolsPort(value: number): boolean {
return Number.isInteger(value) && value >= 1 && value <= 65535; return Number.isInteger(value) && value >= 1 && value <= 65535;
} }
@@ -88,7 +94,11 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
} }
const unknownClaudeKeys = Object.keys(claude ?? {}).filter( const unknownClaudeKeys = Object.keys(claude ?? {}).filter(
(key) => (key) =>
key !== 'enabled' && key !== 'policy' && key !== 'userDataDir' && key !== 'devtoolsPort' key !== 'enabled' &&
key !== 'policy' &&
key !== 'userDataDir' &&
key !== 'devtoolsPort' &&
key !== 'evalMode'
); );
if (unknownClaudeKeys.length > 0) { if (unknownClaudeKeys.length > 0) {
res.status(400).json({ res.status(400).json({
@@ -97,7 +107,7 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
return; return;
} }
const unknownCodexKeys = Object.keys(codex ?? {}).filter( const unknownCodexKeys = Object.keys(codex ?? {}).filter(
(key) => key !== 'enabled' && key !== 'policy' (key) => key !== 'enabled' && key !== 'policy' && key !== 'evalMode'
); );
if (unknownCodexKeys.length > 0) { if (unknownCodexKeys.length > 0) {
res.status(400).json({ res.status(400).json({
@@ -129,6 +139,15 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
}); });
return; 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') { if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') {
res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' }); res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' });
return; return;
@@ -140,6 +159,15 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
res.status(400).json({ error: 'Invalid value for codex.policy. Must be auto or manual.' }); res.status(400).json({ error: 'Invalid value for codex.policy. Must be auto or manual.' });
return; 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 { try {
const current = getUserFacingBrowserConfig(); const current = getUserFacingBrowserConfig();
@@ -152,10 +180,12 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
policy: claude?.policy ?? current.claude.policy, policy: claude?.policy ?? current.claude.policy,
user_data_dir: nextClaudeUserDataDir, user_data_dir: nextClaudeUserDataDir,
devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port, devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port,
eval_mode: claude?.evalMode ?? current.claude.eval_mode,
}, },
codex: { codex: {
enabled: codex?.enabled ?? current.codex.enabled, enabled: codex?.enabled ?? current.codex.enabled,
policy: codex?.policy ?? current.codex.policy, 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, policy: config.claude.policy,
userDataDir: config.claude.user_data_dir, userDataDir: config.claude.user_data_dir,
devtoolsPort: config.claude.devtools_port, devtoolsPort: config.claude.devtools_port,
evalMode: config.claude.eval_mode ?? 'readonly',
}, },
codex: { codex: {
enabled: config.codex.enabled, enabled: config.codex.enabled,
policy: config.codex.policy, policy: config.codex.policy,
evalMode: config.codex.eval_mode ?? 'readonly',
}, },
}; };
} }
@@ -202,10 +202,12 @@ describe('migration-manager legacy kimi compatibility', () => {
policy: 'manual', policy: 'manual',
user_data_dir: '', user_data_dir: '',
devtools_port: 9222, devtools_port: 9222,
eval_mode: 'readonly',
}, },
codex: { codex: {
enabled: false, enabled: false,
policy: 'manual', policy: 'manual',
eval_mode: 'readonly',
}, },
}); });
}); });
@@ -124,6 +124,9 @@ if (envOut) {
CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN, CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN,
CODEX_THREAD_ID: process.env.CODEX_THREAD_ID, CODEX_THREAD_ID: process.env.CODEX_THREAD_ID,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, 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' }) + '\\n'
); );
} }
@@ -173,19 +176,28 @@ process.exit(0);
CCS_HOME: tmpHome, CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath, CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
CCS_THINKING: '8192', CCS_THINKING: '8192',
CCS_BROWSER_USER_DATA_DIR: '', CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-codex-browser-runtime',
CCS_BROWSER_PROFILE_DIR: '', CCS_BROWSER_PROFILE_DIR: '/tmp/stale-codex-browser-legacy',
CCS_BROWSER_DEVTOOLS_HOST: '', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-codex-env',
CCS_BROWSER_DEVTOOLS_PORT: '',
CCS_BROWSER_DEVTOOLS_HTTP_URL: '',
CCS_BROWSER_DEVTOOLS_WS_URL: '',
CCS_BROWSER_EVAL_MODE: '',
}); });
expect(result.status).toBe(0); expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath); const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls).toEqual([['fix failing tests']]); 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', () => { 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_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined, CODEX_THREAD_ID: undefined,
ANTHROPIC_BASE_URL: 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_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined, CODEX_THREAD_ID: undefined,
ANTHROPIC_BASE_URL: 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_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined, CODEX_THREAD_ID: undefined,
ANTHROPIC_BASE_URL: undefined, ANTHROPIC_BASE_URL: undefined,
CCS_BROWSER_USER_DATA_DIR: undefined,
CCS_BROWSER_PROFILE_DIR: undefined,
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
}, },
]); ]);
}); });
@@ -119,6 +119,7 @@ describe('default profile browser launch', () => {
printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "%s\n" "$@" > "${claudeArgsLogPath}"
{ {
printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" 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 "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST"
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" 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'); 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', () => { it('skips managed browser attach when the default CCS browser profile directory is missing', () => {
if (process.platform === 'win32') return; if (process.platform === 'win32') return;
@@ -131,6 +131,7 @@ describe('settings profile browser launch', () => {
printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "%s\n" "$@" > "${claudeArgsLogPath}"
{ {
printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" 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 "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST"
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" 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'); 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', () => { it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => {
if (process.platform === 'win32') return; if (process.platform === 'win32') return;
@@ -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 { spawnSync } from 'child_process';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read'; const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read';
setDefaultTimeout(30000);
interface RunResult { interface RunResult {
status: number | null; status: number | null;
@@ -98,6 +99,9 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}"
printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY" printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY"
printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL" printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL"
printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH" 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}" } > "${claudeEnvLogPath}"
exit 0 exit 0
`, `,
@@ -261,6 +265,24 @@ exit 0
expect(launchedEnv).not.toContain('runtimeApiKey=stale-token'); 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', () => { it('pins direct settings image analysis to the current local CLIProxy auth token', () => {
if (process.platform === 'win32') return; if (process.platform === 'win32') return;
+2
View File
@@ -126,10 +126,12 @@ describe('unified-config-types', () => {
policy: 'manual', policy: 'manual',
user_data_dir: '', user_data_dir: '',
devtools_port: 9222, devtools_port: 9222,
eval_mode: 'readonly',
}, },
codex: { codex: {
enabled: false, enabled: false,
policy: 'manual', policy: 'manual',
eval_mode: 'readonly',
}, },
}); });
}); });
@@ -261,6 +261,35 @@ describe('browser status', () => {
expect(resolution.warning).toContain('ccs browser doctor'); 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 () => { it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => {
const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data'); const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data');
mkdirSync(managedDir, { recursive: true }); 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 () => { 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'; process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser';
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({
+121 -5
View File
@@ -153,26 +153,64 @@ describe('browser routes', () => {
policy: 'manual', policy: 'manual',
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9222, devtoolsPort: 9222,
evalMode: 'readonly',
}, },
codex: { codex: {
enabled: false, enabled: false,
policy: 'manual', policy: 'manual',
evalMode: 'readonly',
}, },
}); });
expect(payload.status.claude).toMatchObject({ expect(payload.status.claude).toMatchObject({
state: 'disabled', state: 'disabled',
policy: 'manual', policy: 'manual',
evalMode: 'readonly',
managedMcpServerName: 'ccs-browser', managedMcpServerName: 'ccs-browser',
}); });
expect(payload.status.codex).toMatchObject({ expect(payload.status.codex).toMatchObject({
enabled: false, enabled: false,
state: 'disabled', state: 'disabled',
policy: 'manual', policy: 'manual',
evalMode: 'readonly',
serverName: 'ccs_browser', serverName: 'ccs_browser',
}); });
expect(payload.status.codex.detail).toContain('off by default'); 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 () => { it('updates the saved browser config through the dashboard route', async () => {
const response = await fetch(`${baseUrl}/api/browser`, { const response = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT', method: 'PUT',
@@ -183,10 +221,12 @@ describe('browser routes', () => {
policy: 'manual', policy: 'manual',
userDataDir: '/tmp/ccs-browser', userDataDir: '/tmp/ccs-browser',
devtoolsPort: 9333, devtoolsPort: 9333,
evalMode: 'readwrite',
}, },
codex: { codex: {
enabled: false, enabled: true,
policy: 'manual', policy: 'auto',
evalMode: 'disabled',
}, },
}), }),
}); });
@@ -199,10 +239,22 @@ describe('browser routes', () => {
policy: 'manual', policy: 'manual',
userDataDir: '/tmp/ccs-browser', userDataDir: '/tmp/ccs-browser',
devtoolsPort: 9333, devtoolsPort: 9333,
evalMode: 'readwrite',
}, },
codex: { codex: {
enabled: false, enabled: true,
policy: 'auto',
evalMode: 'disabled',
},
});
expect(payload.browser.status).toMatchObject({
claude: {
policy: 'manual', policy: 'manual',
evalMode: 'readwrite',
},
codex: {
policy: 'auto',
evalMode: 'disabled',
}, },
}); });
@@ -213,14 +265,57 @@ describe('browser routes', () => {
policy: 'manual', policy: 'manual',
user_data_dir: '/tmp/ccs-browser', user_data_dir: '/tmp/ccs-browser',
devtools_port: 9333, devtools_port: 9333,
eval_mode: 'readwrite',
}, },
codex: { codex: {
enabled: false, enabled: true,
policy: 'manual', 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 () => { it('treats a blank user-data directory as a reset to the recommended path', async () => {
const firstResponse = await fetch(`${baseUrl}/api/browser`, { const firstResponse = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT', method: 'PUT',
@@ -231,6 +326,7 @@ describe('browser routes', () => {
policy: 'manual', policy: 'manual',
userDataDir: '/tmp/ccs-browser-custom', userDataDir: '/tmp/ccs-browser-custom',
devtoolsPort: 9333, devtoolsPort: 9333,
evalMode: 'readonly',
}, },
}), }),
}); });
@@ -254,9 +350,11 @@ describe('browser routes', () => {
policy: 'manual', policy: 'manual',
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9333, devtoolsPort: 9333,
evalMode: 'readonly',
}); });
expect(payload.browser.status.claude.state).toBe('browser_not_running'); 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.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); expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
const config = loadOrCreateUnifiedConfig(); const config = loadOrCreateUnifiedConfig();
@@ -266,6 +364,7 @@ describe('browser routes', () => {
policy: 'manual', policy: 'manual',
user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtools_port: 9333, 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 () => { it('rejects unknown nested browser lane fields instead of silently ignoring them', async () => {
const response = await fetch(`${baseUrl}/api/browser`, { const response = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT', method: 'PUT',
+5
View File
@@ -234,17 +234,20 @@ export interface UpdateImageAnalysisSettingsPayload {
profileBackends?: Record<string, string>; profileBackends?: Record<string, string>;
} }
export type BrowserToolPolicy = 'auto' | 'manual';
export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite';
export interface BrowserSettingsConfig { export interface BrowserSettingsConfig {
claude: { claude: {
enabled: boolean; enabled: boolean;
policy: BrowserToolPolicy;
userDataDir: string; userDataDir: string;
devtoolsPort: number; devtoolsPort: number;
evalMode: BrowserEvalMode; evalMode: BrowserEvalMode;
}; };
codex: { codex: {
enabled: boolean; enabled: boolean;
policy: BrowserToolPolicy;
evalMode: BrowserEvalMode; evalMode: BrowserEvalMode;
}; };
} }
@@ -257,6 +260,7 @@ export interface BrowserLaunchCommands {
export interface ClaudeBrowserStatus { export interface ClaudeBrowserStatus {
enabled: boolean; enabled: boolean;
policy: BrowserToolPolicy;
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
overrideActive: boolean; overrideActive: boolean;
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready'; state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
@@ -275,6 +279,7 @@ export interface ClaudeBrowserStatus {
export interface CodexBrowserStatus { export interface CodexBrowserStatus {
enabled: boolean; enabled: boolean;
policy: BrowserToolPolicy;
state: 'disabled' | 'enabled' | 'unsupported_build'; state: 'disabled' | 'enabled' | 'unsupported_build';
title: string; title: string;
detail: string; detail: string;
+2 -1
View File
@@ -5,6 +5,7 @@
import type { import type {
BrowserEvalMode, BrowserEvalMode,
BrowserToolPolicy,
BrowserSettingsConfig, BrowserSettingsConfig,
BrowserStatusPayload, BrowserStatusPayload,
CliproxyServerConfig, CliproxyServerConfig,
@@ -199,7 +200,7 @@ export interface ThinkingConfig {
// === Re-exports from api-client === // === Re-exports from api-client ===
export type { BrowserEvalMode, CliproxyServerConfig, RemoteProxyStatus }; export type { BrowserEvalMode, BrowserToolPolicy, CliproxyServerConfig, RemoteProxyStatus };
export type BrowserConfig = BrowserSettingsConfig; export type BrowserConfig = BrowserSettingsConfig;
export type BrowserStatus = BrowserStatusPayload; export type BrowserStatus = BrowserStatusPayload;
export type BrowserSavePayload = UpdateBrowserSettingsPayload; export type BrowserSavePayload = UpdateBrowserSettingsPayload;