diff --git a/docs/browser-automation.md b/docs/browser-automation.md index f4610195..a8ceb4ad 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -8,6 +8,8 @@ CCS provides browser automation through two separate runtime paths: - **Codex Browser Tools**: injects Playwright MCP tooling into Codex-target launches These are related, but they are not the same implementation and they do not promise a shared browser session. +On new installs, and on upgrades that do not already have explicit browser settings, both lanes +start **disabled** and **manual** so browser tooling is not auto-exposed until you opt in. ## How Browser Automation Works @@ -48,7 +50,8 @@ The Browser screen exposes two sections: Browser policy controls are CLI-first in this release. The dashboard remains the shared setup and status surface, while `ccs browser policy` is the authoritative place to decide whether browser -tooling is auto-exposed or kept manual by default. +tooling is auto-exposed or kept manual by default. Fresh installs, plus upgrades without an +existing browser section, surface both lanes as off/manual until you explicitly enable them. ### Via CLI @@ -63,7 +66,8 @@ ccs browser policy --all manual Use `ccs browser setup` for the primary one-command setup path. Use `ccs browser status` for the current state, `ccs browser doctor` for read-only troubleshooting guidance, and -`ccs browser policy` to control default browser exposure. +`ccs browser policy` to control default browser exposure. If you only want browser access for one +run, keep policy manual and add `--browser` to that launch. ### Via Config File @@ -73,12 +77,12 @@ Edit `~/.ccs/config.yaml`: browser: claude: enabled: false - policy: auto + policy: manual user_data_dir: "~/.ccs/browser/chrome-user-data" devtools_port: 9222 codex: - enabled: true - policy: auto + enabled: false + policy: manual ``` Notes: @@ -87,6 +91,7 @@ Notes: - `claude.user_data_dir` is a **Chrome user-data directory**, not a display-name browser profile - `claude.devtools_port` is the expected remote debugging port for attach mode - `codex.enabled` controls whether CCS injects browser tooling into Codex-target launches +- New installs, plus upgrades without saved browser settings, default both lanes to `enabled: false` and `policy: manual` - `manual` keeps the lane configured but hidden until a launch explicitly opts in with `--browser` ## Runtime Policy Controls @@ -94,7 +99,7 @@ Notes: CCS now separates **lane enablement** from **default exposure policy**: - `enabled: false` - - the lane is off + - the lane is off; this is the default for both lanes on new installs and upgrades without saved browser settings - `enabled: true` + `policy: auto` - the lane is exposed automatically on matching launches - `enabled: true` + `policy: manual` @@ -159,10 +164,11 @@ ccs browser setup That flow: 1. enables Claude Browser Attach in the saved CCS browser config -2. keeps the configured DevTools port normalized -3. creates the configured browser user-data directory if needed -4. prints the exact browser launch command for the current platform -5. re-checks readiness and reports the next step if Chrome still needs manual attention +2. leaves launch exposure under the saved policy, so `policy: manual` still requires `--browser` +3. keeps the configured DevTools port normalized +4. creates the configured browser user-data directory if needed +5. prints the exact browser launch command for the current platform +6. re-checks readiness and reports the next step if Chrome still needs manual attention ## Launching Chrome For Claude Attach diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index c8457946..a08866ec 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in. - **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser..policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config. - **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default. - **2026-04-18**: **#1038** Legacy OpenAI-compatible provider writes no longer self-destruct on the next `ccs cliproxy restart`. CCS now preserves AI-provider-managed top-level sections such as `openai-compatibility` during CLIProxy config regeneration, and the legacy `openai-compat` manager now rewrites only its own YAML section instead of dumping the whole file and stripping the generated version header. Regression coverage now proves the legacy helper keeps the generated header intact and that OpenAI-compatible connectors survive regeneration. diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts index 2536c496..3633f67e 100644 --- a/src/commands/browser-command.ts +++ b/src/commands/browser-command.ts @@ -48,13 +48,13 @@ function writeCommandTable(writeLine: HelpWriter): void { ` ${color('ccs browser doctor', 'command')} Explain what is missing and how to fix it` ); writeLine( - ` ${color('ccs browser policy', 'command')} Show the saved browser exposure policy` + ` ${color('ccs browser policy', 'command')} Show the saved browser exposure policy and safe defaults` ); writeLine( ` ${color('ccs browser policy --all manual', 'command')} Keep browser tooling hidden unless a launch uses --browser` ); writeLine( - ` ${color('ccs browser enable ', 'command')} Turn a browser lane on` + ` ${color('ccs browser enable ', 'command')} Turn a browser lane on without forcing auto-exposure` ); writeLine( ` ${color('ccs browser disable ', 'command')} Turn a browser lane off` @@ -67,6 +67,9 @@ function writeIntro(writeLine: HelpWriter): void { writeLine( ' Codex Browser Tools inject managed Playwright MCP overrides into Codex-target launches.' ); + writeLine( + ' New installs, plus upgrades without saved browser settings, keep both lanes off by default; enable a lane and use `--browser` when you want browser access.' + ); writeLine(''); writeLine(subheader('Launch Overrides')); writeLine( @@ -158,6 +161,10 @@ function writePolicySummary(writeLine: HelpWriter): void { writeLine(header('ccs browser policy')); writeLine(''); writeIntro(writeLine); + writeLine( + ' New installs and upgrades without saved browser settings: both lanes start disabled and manual.' + ); + writeLine(''); writeLine(subheader('Claude Browser Attach')); writeLine(` Enabled: ${config.claude.enabled ? 'yes' : 'no'}`); writeLaunchPolicy(config.claude.policy, writeLine); @@ -191,6 +198,11 @@ function writeToggleSummary( writeLine(''); writeLine(` Updated ${lane} browser lane${lane === 'all' ? 's' : ''}.`); writeLine(` Browser lanes are now ${verb} as requested.`); + if (subcommand === 'enable') { + writeLine( + ' Enabled lanes still respect policy, so browser access stays hidden until `--browser` while policy is manual.' + ); + } writeLine(''); writeLine(subheader('Current State')); writeLine(` Claude enabled: ${config.claude.enabled ? 'yes' : 'no'}`); @@ -296,6 +308,9 @@ export async function showBrowserHelp(writeLine: HelpWriter = console.log): Prom writeLine(subheader('What Each Lane Does')); writeLine(' Claude Browser Attach expects a Chrome user-data dir and remote debugging port.'); writeLine(' Codex Browser Tools depend on a Codex build that supports --config overrides.'); + writeLine( + ' New installs and upgrades without saved browser settings keep both lanes off by default, so enabling a lane does not auto-expose browser tooling unless policy is set to auto.' + ); writeLine(''); writeLine(subheader('Examples')); writeLine( diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index cd95335e..a3548fc6 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -73,7 +73,7 @@ function normalizeBrowserDevtoolsPort(value: number | undefined): number { } function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy { - return value === 'manual' ? 'manual' : DEFAULT_BROWSER_CONFIG.claude.policy; + return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy; } function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig { diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 3ead57cf..6b294857 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -26,8 +26,9 @@ import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; * Version 10 = Exa + Tavily WebSearch backends * Version 11 = Discord Channels runtime auto-enable preferences * Version 12 = Official Channels multi-provider support (Telegram, Discord, iMessage) + * Version 13 = Browser automation defaults to safe manual/off exposure */ -export const UNIFIED_CONFIG_VERSION = 12; +export const UNIFIED_CONFIG_VERSION = 13; /** * Supported CLIProxy providers. @@ -834,7 +835,7 @@ export interface BrowserClaudeConfig { } export interface BrowserCodexConfig { - /** Enable Codex browser tooling injection (default: true) */ + /** Enable Codex browser tooling injection (default: false) */ enabled: boolean; /** Control whether Codex browser tooling is exposed automatically or only via --browser */ policy: BrowserToolPolicy; @@ -848,13 +849,13 @@ export interface BrowserConfig { export const DEFAULT_BROWSER_CONFIG: BrowserConfig = { claude: { enabled: false, - policy: 'auto', + policy: 'manual', user_data_dir: '', devtools_port: 9222, }, codex: { - enabled: true, - policy: 'auto', + enabled: false, + policy: 'manual', }, }; diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index c8adebfc..42b72222 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -1,6 +1,6 @@ import * as path from 'path'; -import type { BrowserToolPolicy } from '../../config/unified-config-types'; -import { getBrowserConfig } from '../../config/unified-config-loader'; +import type { BrowserConfig, 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'; import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; @@ -53,15 +53,57 @@ export interface BrowserStatusPayload { } export async function getBrowserStatus(): Promise { - const browserConfig = getBrowserConfig(); + const browserConfig = getUserFacingBrowserConfig(); return { claude: await buildClaudeBrowserStatus(browserConfig), codex: buildCodexBrowserStatus(browserConfig), }; } +type PersistedBrowserConfig = { + claude?: Partial; + codex?: Partial; +}; + +function resolveSafeBrowserPolicy(policy: BrowserToolPolicy | undefined): BrowserToolPolicy { + return policy === 'auto' || policy === 'manual' ? policy : 'manual'; +} + +export function getUserFacingBrowserConfig(): BrowserConfig { + const canonical = getBrowserConfig(); + const persisted = loadUnifiedConfig()?.browser as PersistedBrowserConfig | undefined; + + if (!persisted) { + return { + claude: { + ...canonical.claude, + enabled: false, + policy: 'manual', + }, + codex: { + ...canonical.codex, + enabled: false, + policy: 'manual', + }, + }; + } + + return { + claude: { + ...canonical.claude, + enabled: persisted.claude?.enabled ?? false, + policy: resolveSafeBrowserPolicy(persisted.claude?.policy), + }, + codex: { + ...canonical.codex, + enabled: persisted.codex?.enabled ?? false, + policy: resolveSafeBrowserPolicy(persisted.codex?.policy), + }, + }; +} + async function buildClaudeBrowserStatus( - browserConfig = getBrowserConfig() + browserConfig = getUserFacingBrowserConfig() ): Promise { const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig); const launchCommands = buildBrowserLaunchCommands(effective.userDataDir, effective.devtoolsPort); @@ -84,8 +126,8 @@ async function buildClaudeBrowserStatus( state: 'disabled', title: 'Claude Browser Attach is disabled.', detail: - 'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.', - nextStep: `Enable Claude Browser Attach in Settings > Browser or in ${getCcsPathDisplay('config.yaml')}, then run \`ccs browser setup\`.`, + 'CCS keeps Claude Browser Attach off by default and will not provision the managed browser MCP runtime until this lane is enabled.', + nextStep: `Enable Claude Browser Attach in Settings > Browser or in ${getCcsPathDisplay('config.yaml')}, then run \`ccs browser setup\` when you are ready to opt in.`, }; } @@ -122,8 +164,13 @@ async function buildClaudeBrowserStatus( state: 'ready', title: 'Claude Browser Attach is ready.', detail: - 'CCS can reach the configured Chrome DevTools endpoint for the current attach session.', - nextStep: 'Launch a Claude-target CCS session to use the managed browser MCP runtime.', + browserConfig.claude.policy === 'manual' + ? 'CCS can reach the configured Chrome DevTools endpoint, and the lane stays hidden until a launch uses `--browser`.' + : 'CCS can reach the configured Chrome DevTools endpoint for the current attach session.', + nextStep: + browserConfig.claude.policy === 'manual' + ? 'Launch a Claude-target CCS session with `--browser` to use the managed browser MCP runtime.' + : 'Launch a Claude-target CCS session to use the managed browser MCP runtime.', runtimeEnv, }; } catch (error) { @@ -172,16 +219,17 @@ async function buildClaudeBrowserStatus( } } -function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrowserStatus { +function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()): CodexBrowserStatus { if (!browserConfig.codex.enabled) { return { enabled: false, policy: browserConfig.codex.policy, state: 'disabled', title: 'Codex Browser Tools are disabled.', - detail: 'CCS will not inject Playwright MCP browser tooling into Codex-target launches.', + detail: + 'CCS keeps Codex Browser Tools off by default and will not inject Playwright MCP browser tooling until this lane is enabled.', nextStep: - 'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.', + 'Enable Codex Browser Tools in Settings > Browser when you want browser access on Codex-target launches.', serverName: 'ccs_browser', supportsConfigOverrides: false, binaryPath: null, @@ -212,8 +260,14 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows policy: browserConfig.codex.policy, state: 'enabled', title: 'Codex Browser Tools are enabled.', - detail: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.', - nextStep: 'Use a Codex-target CCS launch to access browser tools.', + detail: + browserConfig.codex.policy === 'manual' + ? 'CCS can inject the managed Playwright MCP overrides when a Codex-target launch opts in with `--browser`.' + : 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.', + nextStep: + browserConfig.codex.policy === 'manual' + ? 'Use `--browser` on a Codex-target CCS launch to access browser tools.' + : 'Use a Codex-target CCS launch to access browser tools.', serverName: 'ccs_browser', supportsConfigOverrides, binaryPath: binaryInfo.path, diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts index e23e8910..215960fb 100644 --- a/src/web-server/routes/browser-routes.ts +++ b/src/web-server/routes/browser-routes.ts @@ -1,6 +1,8 @@ import { Router, type Request, type Response } from 'express'; -import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { mutateUnifiedConfig } from '../../config/unified-config-loader'; +import type { BrowserConfig } from '../../config/unified-config-types'; import { getBrowserStatus } from '../../utils/browser'; +import { getUserFacingBrowserConfig } from '../../utils/browser/browser-status'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); @@ -40,7 +42,7 @@ router.use((req: Request, res: Response, next) => { router.get('/', async (_req: Request, res: Response): Promise => { try { - const config = getBrowserConfig(); + const config = getUserFacingBrowserConfig(); const status = await getBrowserStatus(); res.json({ config: toBrowserRouteConfig(config), @@ -140,7 +142,7 @@ router.put('/', async (req: Request, res: Response): Promise => { } try { - const current = getBrowserConfig(); + const current = getUserFacingBrowserConfig(); const nextClaudeUserDataDir = claude?.userDataDir === undefined ? current.claude.user_data_dir : claude.userDataDir.trim(); mutateUnifiedConfig((config) => { @@ -158,7 +160,7 @@ router.put('/', async (req: Request, res: Response): Promise => { }; }); - const config = getBrowserConfig(); + const config = getUserFacingBrowserConfig(); const status = await getBrowserStatus(); res.json({ success: true, @@ -172,7 +174,7 @@ router.put('/', async (req: Request, res: Response): Promise => { } }); -function toBrowserRouteConfig(config: ReturnType) { +function toBrowserRouteConfig(config: BrowserConfig) { return { claude: { enabled: config.claude.enabled, diff --git a/tests/unit/commands/browser-command.test.ts b/tests/unit/commands/browser-command.test.ts index 809c9f6e..df83e075 100644 --- a/tests/unit/commands/browser-command.test.ts +++ b/tests/unit/commands/browser-command.test.ts @@ -47,7 +47,7 @@ describe('browser command', () => { const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ claude: { enabled: true, - policy: 'auto', + policy: 'manual', source: 'config', overrideActive: false, state: 'ready', @@ -74,7 +74,7 @@ describe('browser command', () => { }, codex: { enabled: true, - policy: 'auto', + policy: 'manual', state: 'enabled', title: 'Codex Browser Tools are enabled.', detail: 'CCS can inject the managed Playwright MCP overrides.', @@ -94,9 +94,15 @@ describe('browser command', () => { expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe( true ); + expect( + rendered.includes( + 'New installs, plus upgrades without saved browser settings, keep both lanes off by default' + ) + ).toBe(true); expect(rendered.includes('Managed MCP: ccs-browser')).toBe(true); expect(rendered.includes('Managed server: ccs_browser')).toBe(true); - expect(rendered.includes('Policy: auto')).toBe(true); + expect(rendered.includes('Policy: manual')).toBe(true); + expect(rendered.includes('Default launch behavior: hidden until `--browser`')).toBe(true); expect(rendered.includes('DevTools endpoint: http://127.0.0.1:9222')).toBe(true); } finally { statusSpy.mockRestore(); @@ -288,18 +294,37 @@ describe('browser command', () => { const rendered = await renderLines(['policy', '--all', 'manual']); expect(rendered.includes('ccs browser policy')).toBe(true); + expect( + rendered.includes( + 'New installs and upgrades without saved browser settings: both lanes start disabled and manual.' + ) + ).toBe(true); expect(rendered.includes('Default launch behavior: hidden until `--browser`')).toBe(true); expect(getBrowserConfig().claude.policy).toBe('manual'); expect(getBrowserConfig().codex.policy).toBe('manual'); }); - test('enable updates a single browser lane', async () => { - await renderLines(['disable', 'codex']); - expect(getBrowserConfig().codex.enabled).toBe(false); + test('policy shows safe default-off browser settings on a fresh install', async () => { + const rendered = await renderLines(['policy']); + expect(rendered.includes('ccs browser policy')).toBe(true); + expect( + rendered.includes( + 'New installs and upgrades without saved browser settings: both lanes start disabled and manual.' + ) + ).toBe(true); + expect(rendered.includes('Claude Browser Attach')).toBe(true); + expect(rendered.includes('Codex Browser Tools')).toBe(true); + expect(rendered.includes('Enabled: no')).toBe(true); + expect(rendered.includes('Policy: manual')).toBe(true); + expect(rendered.includes('Default launch behavior: hidden until `--browser`')).toBe(true); + }); + + test('enable updates a single browser lane', async () => { const rendered = await renderLines(['enable', 'codex']); expect(rendered.includes('Updated codex browser lane.')).toBe(true); expect(getBrowserConfig().codex.enabled).toBe(true); + expect(getBrowserConfig().codex.policy).toBe('manual'); }); test('literal browser help still renders the help page', async () => { @@ -309,5 +334,10 @@ describe('browser command', () => { expect(rendered.includes('ccs browser setup')).toBe(true); expect(rendered.includes('ccs browser policy')).toBe(true); expect(rendered.includes('--browser')).toBe(true); + expect( + rendered.includes( + 'New installs and upgrades without saved browser settings keep both lanes off by default' + ) + ).toBe(true); }); }); diff --git a/tests/unit/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts index 92a8ec3a..90a3d460 100644 --- a/tests/unit/config/migration-manager.test.ts +++ b/tests/unit/config/migration-manager.test.ts @@ -177,6 +177,39 @@ describe('migration-manager legacy kimi compatibility', () => { expect(unified?.accounts.personal.continuity_mode).toBeUndefined(); }); + it('applies safe browser defaults when migrating legacy config files', async () => { + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify( + { + profiles: { + glm: '~/.ccs/glm.settings.json', + }, + }, + null, + 2 + ) + ); + fs.writeFileSync(path.join(ccsDir, 'glm.settings.json'), JSON.stringify({ env: {} })); + + const result = await migrate(false); + expect(result.success).toBe(true); + + const unified = loadUnifiedConfig(); + expect(unified?.browser).toEqual({ + claude: { + enabled: false, + policy: 'manual', + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: false, + policy: 'manual', + }, + }); + }); + it('normalizes valid legacy shared groups and drops invalid ones during migration', async () => { fs.writeFileSync( path.join(ccsDir, 'profiles.json'), diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 40a70d0d..833332d7 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -163,7 +163,7 @@ process.exit(0); fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('injects browser MCP runtime overrides for native Codex default launches', () => { + it('keeps browser MCP runtime overrides off for untouched Codex launches', () => { if (process.platform === 'win32') return; const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { @@ -178,20 +178,64 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls).toEqual([ - ['-c', 'model="gpt-5"', '--version'], - [ - '-c', - `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, - '-c', - `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, - '-c', - 'mcp_servers.ccs_browser.enabled=true', - '-c', - 'mcp_servers.ccs_browser.tool_timeout_sec=30', - 'fix failing tests', - ], - ]); + expect(calls).toEqual([['fix failing tests']]); + }); + + it('injects browser MCP runtime overrides when Codex browser policy is explicitly auto-enabled', () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: false, + policy: 'manual', + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: true, + policy: 'auto', + }, + }; + }); + + const result = runCcs(['default', '--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, + CCS_THINKING: '8192', + }); + + expect(result.status).toBe(0); + const calls = readLoggedCodexCalls(codexArgsLogPath); + expect(calls).toEqual([ + ['-c', 'model="gpt-5"', '--version'], + [ + '-c', + `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + '-c', + `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + 'fix failing tests', + ], + ]); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } }); it('skips Codex browser MCP overrides when browser tooling is disabled in config', () => { @@ -345,7 +389,7 @@ process.exit(0); expect(calls).toEqual([['fix failing tests']]); }); - it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => { + it('keeps browser MCP runtime overrides off when CCS_THINKING is ignored for native Codex default mode', () => { if (process.platform === 'win32') return; const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { @@ -360,20 +404,7 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls).toEqual([ - ['-c', 'model="gpt-5"', '--version'], - [ - '-c', - `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, - '-c', - `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, - '-c', - 'mcp_servers.ccs_browser.enabled=true', - '-c', - 'mcp_servers.ccs_browser.tool_timeout_sec=30', - 'fix failing tests', - ], - ]); + expect(calls).toEqual([['fix failing tests']]); }); for (const versionFlag of ['--version', '-v']) { @@ -488,14 +519,6 @@ process.exit(0); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], [ - '-c', - 'mcp_servers.ccs_browser.command="npx"', - '-c', - 'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]', - '-c', - 'mcp_servers.ccs_browser.enabled=true', - '-c', - 'mcp_servers.ccs_browser.tool_timeout_sec=30', '-c', 'model_reasoning_effort="high"', 'fix failing tests', @@ -690,14 +713,6 @@ process.exit(0); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], [ - '-c', - 'mcp_servers.ccs_browser.command="npx"', - '-c', - 'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]', - '-c', - 'mcp_servers.ccs_browser.enabled=true', - '-c', - 'mcp_servers.ccs_browser.tool_timeout_sec=30', '-c', 'model_reasoning_effort="high"', 'fix failing tests', diff --git a/tests/unit/targets/codex-settings-bridge-launch.test.ts b/tests/unit/targets/codex-settings-bridge-launch.test.ts index edec0748..6bf1c75c 100644 --- a/tests/unit/targets/codex-settings-bridge-launch.test.ts +++ b/tests/unit/targets/codex-settings-bridge-launch.test.ts @@ -114,7 +114,7 @@ exit 0 fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('launches Codex bridge settings profiles and injects runtime overrides', () => { + it('launches Codex bridge settings profiles without browser overrides unless the lane is enabled', () => { if (process.platform === 'win32') return; const result = runCcs(['codex-api', '--target', 'codex', '--effort', 'high', 'smoke'], baseEnv); @@ -126,8 +126,8 @@ exit 0 expect(argsLog).toContain('model_provider="ccs_runtime"'); expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"'); expect(argsLog).toContain('model_reasoning_effort="high"'); - expect(argsLog).toContain('mcp_servers.ccs_browser.command='); - expect(argsLog).toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]'); + expect(argsLog).not.toContain('mcp_servers.ccs_browser.command='); + expect(argsLog).not.toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]'); expect(argsLog).toContain('smoke'); expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token'); }); diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index ae10e829..be81261b 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -198,7 +198,7 @@ exit 0 expect(launchedEnv).not.toContain('devtools/browser/stale-default'); }); - it('passes browser runtime env through default Claude launches when reuse is configured', async () => { + it('does not auto-enable browser runtime for default Claude launches from env overrides alone', async () => { if (process.platform === 'win32') return; const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); @@ -249,14 +249,14 @@ server.listen(0, '127.0.0.1', () => { expect(result.stderr).not.toContain('Chrome reuse metadata not found'); expect(result.status).toBe(0); const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); - expect(launchedArgs).toContain('--append-system-prompt'); - expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + expect(launchedArgs).not.toContain('--append-system-prompt'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); - expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); - expect(launchedEnv).toContain(`port=${port}`); - expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); - expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); + expect(launchedEnv).not.toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).not.toContain(`port=${port}`); + expect(launchedEnv).not.toContain(`httpUrl=http://127.0.0.1:${port}`); + expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); }); it('skips managed browser attach when the default CCS browser profile directory is missing', () => { diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index bb3bb140..1a343f62 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from 'bun:test'; +import { request as httpRequest } from 'http'; import { spawn, spawnSync, type ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; @@ -6,6 +7,7 @@ import * as path from 'path'; import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; +setDefaultTimeout(30000); interface RunResult { status: number | null; @@ -40,6 +42,40 @@ function reserveClosedPort(): number { return port; } +async function waitForDevtoolsVersionEndpoint(port: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() <= deadline) { + try { + await new Promise((resolve, reject) => { + const req = httpRequest( + { + hostname: '127.0.0.1', + port: Number.parseInt(port, 10), + path: '/json/version', + method: 'GET', + }, + (res) => { + res.resume(); + if (res.statusCode === 200) { + resolve(); + return; + } + reject(new Error(`Unexpected status: ${res.statusCode ?? 'unknown'}`)); + } + ); + req.on('error', reject); + req.end(); + }); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + + throw new Error('Timed out waiting for mock DevTools endpoint to become ready'); +} + describe('settings profile browser launch', () => { let tmpHome = ''; let ccsDir = ''; @@ -128,7 +164,7 @@ exit 0 fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('fails before Claude launch when browser reuse cannot resolve DevToolsActivePort', () => { + it('does not block settings-profile launches when browser reuse cannot resolve DevToolsActivePort and the lane stays default-off', () => { if (process.platform === 'win32') return; fs.mkdirSync(browserProfileDir, { recursive: true }); @@ -138,12 +174,12 @@ exit 0 CCS_BROWSER_PROFILE_DIR: browserProfileDir, }); - expect(result.status).toBe(1); - expect(result.stderr).toContain('DevToolsActivePort'); - expect(fs.existsSync(claudeArgsLogPath)).toBe(false); + expect(result.status).toBe(0); + expect(result.stderr).not.toContain('DevToolsActivePort'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(true); }); - it('passes browser reuse env and steering prompt into the Claude launch', async () => { + it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => { if (process.platform === 'win32') return; const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); @@ -182,6 +218,7 @@ server.listen(0, '127.0.0.1', () => { await new Promise((resolve) => setTimeout(resolve, 25)); } const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + await waitForDevtoolsVersionEndpoint(port); fs.mkdirSync(browserProfileDir, { recursive: true }); fs.writeFileSync( @@ -200,14 +237,13 @@ server.listen(0, '127.0.0.1', () => { expect(result.stderr).not.toContain('Chrome reuse metadata not found'); expect(result.status).toBe(0); const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); - expect(launchedArgs).toContain('--append-system-prompt'); - expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); - expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); - expect(launchedEnv).toContain(`port=${port}`); - expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); - expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); + expect(launchedEnv).not.toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).not.toContain(`port=${port}`); + expect(launchedEnv).not.toContain(`httpUrl=http://127.0.0.1:${port}`); + expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); }); it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => { @@ -346,6 +382,7 @@ server.listen(0, '127.0.0.1', () => { await new Promise((resolve) => setTimeout(resolve, 25)); } const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + await waitForDevtoolsVersionEndpoint(port); fs.mkdirSync(browserProfileDir, { recursive: true }); fs.writeFileSync( @@ -429,6 +466,7 @@ server.listen(0, '127.0.0.1', () => { await new Promise((resolve) => setTimeout(resolve, 25)); } const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + await waitForDevtoolsVersionEndpoint(port); fs.mkdirSync(browserProfileDir, { recursive: true }); fs.writeFileSync( diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 13c5661c..65c037d5 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -117,6 +117,22 @@ describe('unified-config-types', () => { expect(config.cliproxy.providers).toContain('gemini'); expect(config.cliproxy.providers).toContain('codex'); }); + + it('should default browser automation to disabled and manual for both lanes', () => { + const config = createEmptyUnifiedConfig(); + expect(config.browser).toEqual({ + claude: { + enabled: false, + policy: 'manual', + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: false, + policy: 'manual', + }, + }); + }); }); describe('isUnifiedConfig', () => { @@ -337,3 +353,85 @@ describe('official-channels-config', () => { } }); }); + +describe('browser-config', () => { + it('fills in safe browser defaults when the browser section is missing on older configs', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-defaults-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: false', ''].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.browser).toMatchObject({ + claude: { + enabled: false, + policy: 'manual', + }, + codex: { + enabled: false, + policy: 'manual', + }, + }); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('preserves explicit browser enablement while defaulting missing policies to manual', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-policy-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'browser:', + ' claude:', + ' enabled: true', + ' user_data_dir: "/tmp/claude-browser"', + ' devtools_port: 9333', + ' codex:', + ' enabled: true', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.browser).toMatchObject({ + claude: { + enabled: true, + policy: 'manual', + user_data_dir: '/tmp/claude-browser', + devtools_port: 9333, + }, + codex: { + enabled: true, + policy: 'manual', + }, + }); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index bda30626..abaeaaff 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -5,7 +5,9 @@ import { join } from 'node:path'; import { getBrowserConfig, mutateUnifiedConfig, + saveUnifiedConfig, } from '../../../../src/config/unified-config-loader'; +import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types'; import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse'; import { getBrowserStatus } from '../../../../src/utils/browser/browser-status'; import { @@ -62,7 +64,7 @@ describe('browser status', () => { rmSync(tempHome, { recursive: true, force: true }); }); - it('returns a disabled Claude lane with the recommended managed user-data dir by default', async () => { + it('returns disabled/manual browser lanes with the recommended managed user-data dir by default', async () => { const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', needsShell: false, @@ -76,24 +78,72 @@ describe('browser status', () => { expect(status.claude).toMatchObject({ enabled: false, state: 'disabled', + policy: 'manual', source: 'config', effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9222, managedMcpServerName: 'ccs-browser', }); expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222'); + expect(status.claude.detail).toContain('off by default'); expect(status.codex).toMatchObject({ - enabled: true, - state: 'enabled', + enabled: false, + policy: 'manual', + state: 'disabled', serverName: 'ccs_browser', - supportsConfigOverrides: true, + supportsConfigOverrides: false, }); + expect(status.codex.detail).toContain('off by default'); expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(false); } finally { codexSpy.mockRestore(); } }); + it('resolves missing saved browser policies to manual while preserving explicit enabled values', async () => { + const config = createEmptyUnifiedConfig(); + config.browser = { + claude: { + enabled: true, + user_data_dir: '/tmp/explicit-claude', + devtools_port: 9333, + } as typeof config.browser.claude, + codex: { + enabled: true, + } as typeof config.browser.codex, + }; + saveUnifiedConfig(config); + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue( + new Error('Chrome reuse metadata not found: /tmp/explicit-claude/DevToolsActivePort') + ); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(status.claude).toMatchObject({ + enabled: true, + policy: 'manual', + effectiveUserDataDir: '/tmp/explicit-claude', + devtoolsPort: 9333, + }); + expect(status.codex).toMatchObject({ + enabled: true, + policy: 'manual', + state: 'enabled', + }); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); + it('bootstraps the managed default browser profile dir before reporting attach readiness', async () => { mutateUnifiedConfig((config) => { config.browser = { diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts index 11db1b5e..d8a4e1fd 100644 --- a/tests/unit/web-server/browser-routes.test.ts +++ b/tests/unit/web-server/browser-routes.test.ts @@ -93,23 +93,27 @@ describe('browser routes', () => { expect(payload.config).toMatchObject({ claude: { enabled: false, - policy: 'auto', + policy: 'manual', userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9222, }, codex: { - enabled: true, - policy: 'auto', + enabled: false, + policy: 'manual', }, }); expect(payload.status.claude).toMatchObject({ state: 'disabled', + policy: 'manual', managedMcpServerName: 'ccs-browser', }); expect(payload.status.codex).toMatchObject({ - enabled: true, + enabled: false, + state: 'disabled', + policy: 'manual', serverName: 'ccs_browser', }); + expect(payload.status.codex.detail).toContain('off by default'); }); it('updates the saved browser config through the dashboard route', async () => {