fix(browser): harden runtime policy edge cases

This commit is contained in:
Tam Nhu Tran
2026-04-20 21:01:19 -04:00
parent 7d02f55f9f
commit 039ed63a39
16 changed files with 394 additions and 28 deletions
+3
View File
@@ -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`
+11 -12
View File
@@ -463,6 +463,16 @@ async function main(): Promise<void> {
}
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<void> {
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<void> {
? resolveBrowserExposure(
{
enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled,
policy: claudeAttachConfig?.overrideActive ? 'auto' : browserConfig.claude.policy,
policy: browserConfig.claude.policy,
},
browserLaunchOverride
)
+13 -4
View File
@@ -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
);
+2
View File
@@ -305,6 +305,8 @@ export const PROVIDER_FLAGS = [
'--effort',
'--1m',
'--no-1m',
'--browser',
'--no-browser',
'--logout',
'--headless',
'--port-forward',
+15
View File
@@ -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']);
}
+8 -1
View File
@@ -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.');
+2 -1
View File
@@ -65,7 +65,6 @@ async function buildClaudeBrowserStatus(
): Promise<ClaudeBrowserStatus> {
const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const launchCommands = buildBrowserLaunchCommands(effective.userDataDir, effective.devtoolsPort);
const managedBootstrap = ensureManagedBrowserUserDataDir(effective);
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
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,
+37 -9
View File
@@ -20,6 +20,10 @@ interface BrowserRouteBody {
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
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<void> => {
});
router.put('/', async (req: Request, res: Response): Promise<void> => {
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<string, unknown>;
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;
@@ -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();
}
});
});
@@ -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', () => {
@@ -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;
@@ -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;
}
}
});
});
@@ -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;
}
}
});
});
@@ -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,
@@ -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();
}
@@ -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.',
});
});
});