From 039ed63a390da270fa5bb1685239508a21509d5c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 20 Apr 2026 00:10:04 -0400 Subject: [PATCH] fix(browser): harden runtime policy edge cases --- docs/browser-automation.md | 3 + src/ccs.ts | 23 +++-- src/cliproxy/executor/index.ts | 17 +++- src/commands/command-catalog.ts | 2 + src/commands/completion-backend.ts | 15 ++++ src/utils/browser/browser-policy.ts | 9 +- src/utils/browser/browser-status.ts | 3 +- src/web-server/routes/browser-routes.ts | 46 ++++++++-- .../cliproxy/executor-option-value.test.ts | 47 +++++++++- .../unit/commands/completion-backend.test.ts | 12 +++ .../targets/codex-runtime-integration.test.ts | 18 ++++ .../default-profile-browser-launch.test.ts | 82 +++++++++++++++++ .../settings-profile-browser-launch.test.ts | 88 +++++++++++++++++++ .../unit/utils/browser/browser-policy.test.ts | 7 ++ .../unit/utils/browser/browser-status.test.ts | 1 + tests/unit/web-server/browser-routes.test.ts | 49 +++++++++++ 16 files changed, 394 insertions(+), 28 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 8e3e72bd..f4610195 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -126,6 +126,9 @@ CCS still supports environment-variable overrides for backward compatibility. If an override is active, Browser status surfaces should report that the current session is being managed externally by environment variables. +The saved browser policy still controls default exposure. Env overrides change the effective attach +path/port for the current shell; they do not bypass `policy: manual`. + Override precedence is: 1. `CCS_BROWSER_USER_DATA_DIR` diff --git a/src/ccs.ts b/src/ccs.ts index 9bed9be9..1d8c5ad7 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -463,6 +463,16 @@ async function main(): Promise { } args = normalizeLegacyCursorArgs(args); + let browserLaunchOverride: BrowserLaunchOverride | undefined; + try { + const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args); + browserLaunchOverride = browserLaunchFlags.override; + args = browserLaunchFlags.argsWithoutFlags; + } catch (error) { + console.error(fail((error as Error).message)); + process.exit(1); + return; + } cliLogger.info('command.start', 'CLI invocation started', { command: args[0] || 'default', @@ -605,17 +615,6 @@ async function main(): Promise { const detector = new ProfileDetector(); try { - let browserLaunchOverride: BrowserLaunchOverride | undefined; - try { - const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args); - browserLaunchOverride = browserLaunchFlags.override; - args = browserLaunchFlags.argsWithoutFlags; - } catch (error) { - console.error(fail((error as Error).message)); - process.exit(1); - return; - } - // Detect profile (strip --target flags before profile detection) const cleanArgs = stripTargetFlag(args); const { profile, remainingArgs } = detectProfile(cleanArgs); @@ -736,7 +735,7 @@ async function main(): Promise { ? resolveBrowserExposure( { enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled, - policy: claudeAttachConfig?.overrideActive ? 'auto' : browserConfig.claude.policy, + policy: browserConfig.claude.policy, }, browserLaunchOverride ) diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 98d0d32d..2f534ff2 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -65,6 +65,7 @@ import { } from '../../utils/image-analysis'; import { appendBrowserToolArgs, + type BrowserLaunchOverride, ensureBrowserMcpOrThrow, getBlockedBrowserOverrideWarning, getEffectiveClaudeBrowserAttachConfig, @@ -248,15 +249,23 @@ export async function execClaudeWithCLIProxy( } : undefined, }); - const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy); - const browserLaunchOverride = browserLaunchFlags.override; - const argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags; + let browserLaunchOverride: BrowserLaunchOverride | undefined; + let argsWithoutBrowserFlags = argsWithoutProxy; + try { + const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy); + browserLaunchOverride = browserLaunchFlags.override; + argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags; + } catch (error) { + console.error(fail((error as Error).message)); + process.exit(1); + return; + } const browserConfig = getBrowserConfig(); const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig); const claudeBrowserExposure = resolveBrowserExposure( { enabled: browserAttachConfig.enabled, - policy: browserAttachConfig.overrideActive ? 'auto' : browserConfig.claude.policy, + policy: browserConfig.claude.policy, }, browserLaunchOverride ); diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 6707867c..a1ad558f 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -305,6 +305,8 @@ export const PROVIDER_FLAGS = [ '--effort', '--1m', '--no-1m', + '--browser', + '--no-browser', '--logout', '--headless', '--port-forward', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 25e05eee..c968c720 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -84,6 +84,15 @@ function completeSubcommands( return uniqueStrings([...values, ...flags]).map((value) => suggestion(value)); } +function completeBrowserPolicyArgs(tokensBeforeCurrent: string[]): CompletionSuggestion[] { + const lastToken = tokensBeforeCurrent[tokensBeforeCurrent.length - 1]; + if (lastToken === '--all' || lastToken === '--claude' || lastToken === '--codex') { + return completeSubcommands(['auto', 'manual'], ['--help', '-h']); + } + + return completeSubcommands(['--all', '--claude', '--codex'], ['--help', '-h']); +} + function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSuggestion[] { const [command, subcommand] = tokensBeforeCurrent; const lastToken = tokensBeforeCurrent[tokensBeforeCurrent.length - 1]; @@ -195,6 +204,12 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg ['--help', '-h'] ); } + if (subcommand === 'enable' || subcommand === 'disable') { + return completeSubcommands(['claude', 'codex', 'all'], ['--help', '-h']); + } + if (subcommand === 'policy') { + return completeBrowserPolicyArgs(tokensBeforeCurrent); + } if (subcommand === 'setup' || subcommand === 'doctor') { return completeSubcommands([], ['--help', '-h']); } diff --git a/src/utils/browser/browser-policy.ts b/src/utils/browser/browser-policy.ts index eec51409..61a20196 100644 --- a/src/utils/browser/browser-policy.ts +++ b/src/utils/browser/browser-policy.ts @@ -22,7 +22,14 @@ export function resolveBrowserLaunchFlagResolution(args: string[]): BrowserLaunc let override: BrowserLaunchOverride | undefined; const argsWithoutFlags: string[] = []; - for (const arg of args) { + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + + if (arg === '--') { + argsWithoutFlags.push(...args.slice(index)); + break; + } + if (arg === ENABLE_BROWSER_FLAG) { if (override === 'force-disable') { throw new Error('Use either `--browser` or `--no-browser`, not both.'); diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index 537d5a1b..c8adebfc 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -65,7 +65,6 @@ async function buildClaudeBrowserStatus( ): Promise { const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig); const launchCommands = buildBrowserLaunchCommands(effective.userDataDir, effective.devtoolsPort); - const managedBootstrap = ensureManagedBrowserUserDataDir(effective); const base: Omit = { enabled: effective.enabled, policy: browserConfig.claude.policy, @@ -90,6 +89,8 @@ async function buildClaudeBrowserStatus( }; } + const managedBootstrap = ensureManagedBrowserUserDataDir(effective); + if (managedBootstrap.createdProfileDir) { const managedMessage = describeManagedBrowserAttachNotReady( effective, diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts index 6fb05425..e23e8910 100644 --- a/src/web-server/routes/browser-routes.ts +++ b/src/web-server/routes/browser-routes.ts @@ -20,6 +20,10 @@ interface BrowserRouteBody { }; } +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function isValidBrowserPolicy(value: string): value is 'auto' | 'manual' { return value === 'auto' || value === 'manual'; } @@ -56,25 +60,49 @@ router.get('/status', async (_req: Request, res: Response): Promise => { }); router.put('/', async (req: Request, res: Response): Promise => { - if ( - req.body === null || - req.body === undefined || - typeof req.body !== 'object' || - Array.isArray(req.body) - ) { + if (!isPlainObject(req.body)) { res.status(400).json({ error: 'Invalid request body. Must be an object.' }); return; } - const { claude, codex } = req.body as BrowserRouteBody; - if (claude && (typeof claude !== 'object' || Array.isArray(claude))) { + const body = req.body as Record; + const rootKeys = Object.keys(body); + const unknownRootKeys = rootKeys.filter((key) => key !== 'claude' && key !== 'codex'); + if (unknownRootKeys.length > 0) { + res.status(400).json({ + error: `Unknown browser config field(s): ${unknownRootKeys.join(', ')}.`, + }); + return; + } + + const { claude, codex } = body as BrowserRouteBody; + if (Object.prototype.hasOwnProperty.call(body, 'claude') && !isPlainObject(claude)) { res.status(400).json({ error: 'Invalid value for claude. Must be an object.' }); return; } - if (codex && (typeof codex !== 'object' || Array.isArray(codex))) { + if (Object.prototype.hasOwnProperty.call(body, 'codex') && !isPlainObject(codex)) { res.status(400).json({ error: 'Invalid value for codex. Must be an object.' }); return; } + const unknownClaudeKeys = Object.keys(claude ?? {}).filter( + (key) => + key !== 'enabled' && key !== 'policy' && key !== 'userDataDir' && key !== 'devtoolsPort' + ); + if (unknownClaudeKeys.length > 0) { + res.status(400).json({ + error: `Unknown claude browser field(s): ${unknownClaudeKeys.join(', ')}.`, + }); + return; + } + const unknownCodexKeys = Object.keys(codex ?? {}).filter( + (key) => key !== 'enabled' && key !== 'policy' + ); + if (unknownCodexKeys.length > 0) { + res.status(400).json({ + error: `Unknown codex browser field(s): ${unknownCodexKeys.join(', ')}.`, + }); + return; + } if (claude?.enabled !== undefined && typeof claude.enabled !== 'boolean') { res.status(400).json({ error: 'Invalid value for claude.enabled. Must be a boolean.' }); return; diff --git a/tests/unit/cliproxy/executor-option-value.test.ts b/tests/unit/cliproxy/executor-option-value.test.ts index b6ff278e..d8259047 100644 --- a/tests/unit/cliproxy/executor-option-value.test.ts +++ b/tests/unit/cliproxy/executor-option-value.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { + execClaudeWithCLIProxy, hasGitLabTokenLoginFlag, readOptionValue, } from '../../../src/cliproxy/executor/index'; @@ -40,3 +44,44 @@ describe('readOptionValue', () => { expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false); }); }); + +describe('execClaudeWithCLIProxy browser flag validation', () => { + let tmpHome = ''; + let fakeClaudePath = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-executor-')); + fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); + fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + fs.chmodSync(fakeClaudePath, 0o755); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('exits cleanly when conflicting browser launch flags are provided', async () => { + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await execClaudeWithCLIProxy(fakeClaudePath, 'gemini', ['--browser', '--no-browser'], {}); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith('[X] Use either `--browser` or `--no-browser`, not both.'); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index a86d9628..b683043e 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -148,6 +148,18 @@ describe('completion backend', () => { expect(suggestionValues(['browser', 'doctor'])).toEqual( expect.arrayContaining(['--help', '-h']) ); + expect(suggestionValues(['browser', 'enable'])).toEqual( + expect.arrayContaining(['claude', 'codex', 'all']) + ); + expect(suggestionValues(['browser', 'policy'])).toEqual( + expect.arrayContaining(['--all', '--claude', '--codex']) + ); + expect(suggestionValues(['browser', 'policy', '--all'])).toEqual( + expect.arrayContaining(['auto', 'manual']) + ); + expect(suggestionValues(['codex'])).toEqual( + expect.arrayContaining(['--browser', '--no-browser']) + ); }); test('treats cursor as a provider shortcut in completion', () => { diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 9219ec83..40a70d0d 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -396,6 +396,24 @@ process.exit(0); }); } + it('strips browser launch flags before native codex passthrough diagnostics', () => { + if (process.platform === 'win32') return; + + const result = runCodexAlias(['--version', '--browser'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('codex-cli 9.9.9-test'); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]); + }); + for (const helpFlag of ['--help', '-h']) { it(`passes ccsx ${helpFlag} straight through to the native Codex binary`, () => { if (process.platform === 'win32') return; diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index a5ceeddc..ae10e829 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -431,4 +431,86 @@ server.listen(0, '127.0.0.1', () => { } } }); + + it('keeps Claude browser attach hidden under manual policy until --browser is passed', async () => { + if (process.platform === 'win32') return; + + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server-manual-default.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port-manual-default.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/manual-default-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const port = await waitForMockDevtoolsPort(mockServerPortPath); + await waitForDevtoolsVersionEndpoint(port); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/manual-default-target`, + 'utf8' + ); + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + policy: 'manual', + user_data_dir: browserProfileDir, + devtools_port: Number.parseInt(port, 10), + }, + codex: { + enabled: true, + policy: 'auto', + }, + }; + }); + + const hiddenResult = runCcs(['default', 'smoke'], { + ...baseEnv, + }); + expect(hiddenResult.status).toBe(0); + expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).not.toContain(BROWSER_PROMPT_SNIPPET); + expect(fs.readFileSync(claudeEnvLogPath, 'utf8')).not.toContain(browserProfileDir); + + const forcedResult = runCcs(['default', '--browser', 'smoke'], { + ...baseEnv, + }); + expect(forcedResult.status).toBe(0); + expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain(BROWSER_PROMPT_SNIPPET); + expect(fs.readFileSync(claudeEnvLogPath, 'utf8')).toContain(browserProfileDir); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); }); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index ff23bac8..bb3bb140 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -389,4 +389,92 @@ server.listen(0, '127.0.0.1', () => { } } }); + + it('keeps settings-profile Claude attach hidden under manual policy until --browser is passed', async () => { + if (process.platform === 'win32') return; + + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server-manual-settings.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port-manual-settings.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/manual-settings-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const startDeadline = Date.now() + 5000; + while (!fs.existsSync(mockServerPortPath)) { + if (Date.now() > startDeadline) { + throw new Error('Timed out waiting for mock DevTools server to start'); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/manual-settings-target`, + 'utf8' + ); + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + policy: 'manual', + user_data_dir: browserProfileDir, + devtools_port: Number.parseInt(port, 10), + }, + codex: { + enabled: true, + policy: 'auto', + }, + }; + }); + + const hiddenResult = runCcs(['glm', 'smoke'], { + ...baseEnv, + }); + expect(hiddenResult.status).toBe(0); + expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).not.toContain(BROWSER_PROMPT_SNIPPET); + expect(fs.readFileSync(claudeEnvLogPath, 'utf8')).not.toContain(browserProfileDir); + + const forcedResult = runCcs(['glm', '--browser', 'smoke'], { + ...baseEnv, + }); + expect(forcedResult.status).toBe(0); + expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain(BROWSER_PROMPT_SNIPPET); + expect(fs.readFileSync(claudeEnvLogPath, 'utf8')).toContain(browserProfileDir); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); }); diff --git a/tests/unit/utils/browser/browser-policy.test.ts b/tests/unit/utils/browser/browser-policy.test.ts index cf2c1b8d..03e35e19 100644 --- a/tests/unit/utils/browser/browser-policy.test.ts +++ b/tests/unit/utils/browser/browser-policy.test.ts @@ -22,6 +22,13 @@ describe('browser policy', () => { ); }); + it('stops parsing browser launch flags after the option terminator', () => { + expect(resolveBrowserLaunchFlagResolution(['glm', '--', '--browser', 'literal'])).toEqual({ + override: undefined, + argsWithoutFlags: ['glm', '--', '--browser', 'literal'], + }); + }); + it('resolves browser exposure from saved policy and one-run overrides', () => { expect(resolveBrowserExposure({ enabled: true, policy: 'auto' })).toMatchObject({ exposeByDefault: true, diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index 5ddceeaf..bda30626 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -88,6 +88,7 @@ describe('browser status', () => { serverName: 'ccs_browser', supportsConfigOverrides: true, }); + expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(false); } finally { codexSpy.mockRestore(); } diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts index e54e264c..11db1b5e 100644 --- a/tests/unit/web-server/browser-routes.test.ts +++ b/tests/unit/web-server/browser-routes.test.ts @@ -226,6 +226,38 @@ describe('browser routes', () => { }); }); + it('rejects null browser lane payloads instead of treating them as no-ops', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: null, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for claude. Must be an object.', + }); + }); + + it('rejects unknown browser config fields instead of silently ignoring them', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + codxe: { + enabled: true, + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Unknown browser config field(s): codxe.', + }); + }); + it('rejects invalid browser policy values at the route boundary', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', @@ -242,4 +274,21 @@ describe('browser routes', () => { error: 'Invalid value for codex.policy. Must be auto or manual.', }); }); + + it('rejects unknown nested browser lane fields instead of silently ignoring them', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + userDatDir: '/tmp/typo', + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Unknown claude browser field(s): userDatDir.', + }); + }); });