feat(browser): add explicit runtime policy controls

This commit is contained in:
Tam Nhu Tran committed 2026-04-20 21:01:19 -04:00
1 parent d6c45fef04
commit 7d02f55f9f
25 files changed
+795 -50

No files matched your search

+50 -1
View File
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, test, spyOn } from 'bun:test';
import { afterEach, beforeEach, describe, expect, test, spyOn } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as browserUtils from '../../../src/utils/browser';
import { handleBrowserCommand } from '../../../src/commands/browser-command';
import { getBrowserConfig } from '../../../src/config/unified-config-loader';
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
@@ -20,7 +24,22 @@ function currentPlatform(): 'darwin' | 'linux' | 'win32' {
}
describe('browser command', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-command-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
rmSync(tempHome, { recursive: true, force: true });
process.exitCode = 0;
});
@@ -28,6 +47,7 @@ describe('browser command', () => {
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
claude: {
enabled: true,
policy: 'auto',
source: 'config',
overrideActive: false,
state: 'ready',
@@ -54,6 +74,7 @@ describe('browser command', () => {
},
codex: {
enabled: true,
policy: 'auto',
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'CCS can inject the managed Playwright MCP overrides.',
@@ -75,6 +96,7 @@ describe('browser command', () => {
);
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('DevTools endpoint: http://127.0.0.1:9222')).toBe(true);
} finally {
statusSpy.mockRestore();
@@ -91,6 +113,7 @@ describe('browser command', () => {
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
claude: {
enabled: true,
policy: 'manual',
source: 'CCS_BROWSER_PROFILE_DIR',
overrideActive: true,
state: 'browser_not_running',
@@ -106,6 +129,7 @@ describe('browser command', () => {
},
codex: {
enabled: true,
policy: 'manual',
state: 'unsupported_build',
title: 'Codex Browser Tools need a Codex build with --config override support.',
detail: 'Detected Codex at /usr/local/bin/codex, but it does not advertise --config overrides.',
@@ -121,6 +145,7 @@ describe('browser command', () => {
const rendered = await renderLines(['doctor']);
expect(rendered.includes('Result: action required')).toBe(true);
expect(rendered.includes('Default launch behavior: hidden until `--browser`')).toBe(true);
expect(rendered.includes('Source: CCS_BROWSER_PROFILE_DIR (env override active)')).toBe(
true
);
@@ -140,6 +165,7 @@ describe('browser command', () => {
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
claude: {
enabled: false,
policy: 'auto',
source: 'config',
overrideActive: false,
state: 'disabled',
@@ -162,6 +188,7 @@ describe('browser command', () => {
},
codex: {
enabled: true,
policy: 'auto',
state: 'unsupported_build',
title: 'Codex Browser Tools need a Codex build with --config override support.',
detail: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.',
@@ -194,6 +221,7 @@ describe('browser command', () => {
status: {
claude: {
enabled: true,
policy: 'auto',
source: 'config',
overrideActive: false,
state: 'ready',
@@ -220,6 +248,7 @@ describe('browser command', () => {
},
codex: {
enabled: true,
policy: 'auto',
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'CCS can inject the managed Playwright MCP overrides.',
@@ -255,10 +284,30 @@ describe('browser command', () => {
expect(process.exitCode).toBe(1);
});
test('policy shows and updates the saved browser exposure mode', async () => {
const rendered = await renderLines(['policy', '--all', 'manual']);
expect(rendered.includes('ccs browser policy')).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);
const rendered = await renderLines(['enable', 'codex']);
expect(rendered.includes('Updated codex browser lane.')).toBe(true);
expect(getBrowserConfig().codex.enabled).toBe(true);
});
test('literal browser help still renders the help page', async () => {
const rendered = await renderLines(['help']);
expect(rendered.includes('CCS Browser Help')).toBe(true);
expect(rendered.includes('ccs browser setup')).toBe(true);
expect(rendered.includes('ccs browser policy')).toBe(true);
expect(rendered.includes('--browser')).toBe(true);
});
});
@@ -142,7 +142,7 @@ describe('completion backend', () => {
test('suggests browser subcommands and fix/setup flags', () => {
expect(suggestionValues(['browser'])).toEqual(
expect.arrayContaining(['setup', 'status', 'doctor'])
expect.arrayContaining(['setup', 'status', 'doctor', 'policy', 'enable', 'disable'])
);
expect(suggestionValues(['browser', 'setup'])).toEqual(expect.arrayContaining(['--help', '-h']));
expect(suggestionValues(['browser', 'doctor'])).toEqual(
@@ -81,6 +81,8 @@ describe('help command parity', () => {
expect(rendered.includes('ccs browser setup')).toBe(true);
expect(rendered.includes('ccs browser status')).toBe(true);
expect(rendered.includes('ccs browser doctor')).toBe(true);
expect(rendered.includes('ccs browser policy')).toBe(true);
expect(rendered.includes('--browser')).toBe(true);
});
test('completion topic documents install and verification paths', async () => {
@@ -205,11 +205,13 @@ process.exit(0);
config.browser = {
claude: {
enabled: false,
policy: 'auto',
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: false,
policy: 'auto',
},
};
});
@@ -235,6 +237,114 @@ process.exit(0);
}
});
it('keeps Codex browser MCP overrides off by default when policy is manual', () => {
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: 'auto',
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: true,
policy: 'manual',
},
};
});
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,
});
expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls).toEqual([['fix failing tests']]);
} finally {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
it('forces Codex browser MCP overrides on for one launch when --browser is passed', () => {
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: 'auto',
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: true,
policy: 'manual',
},
};
});
const result = runCcs(['default', '--target', 'codex', '--browser', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
});
expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls[1]).toEqual(
expect.arrayContaining([
'mcp_servers.ccs_browser.enabled=true',
'fix failing tests',
])
);
} finally {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
it('suppresses Codex browser MCP overrides for one launch when --no-browser is passed', () => {
if (process.platform === 'win32') return;
const result = runCcs(['default', '--target', 'codex', '--no-browser', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
});
expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls).toEqual([['fix failing tests']]);
});
it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => {
if (process.platform === 'win32') return;
@@ -270,11 +270,13 @@ server.listen(0, '127.0.0.1', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: '',
devtools_port: 43123,
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
@@ -321,11 +323,13 @@ server.listen(0, '127.0.0.1', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: '',
devtools_port: unreachablePort,
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
@@ -396,11 +400,13 @@ server.listen(0, '127.0.0.1', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: browserProfileDir,
devtools_port: Number.parseInt(port, 10),
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
@@ -221,11 +221,13 @@ server.listen(0, '127.0.0.1', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: '',
devtools_port: 43123,
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
@@ -273,11 +275,13 @@ server.listen(0, '127.0.0.1', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: '',
devtools_port: unreachablePort,
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
@@ -354,11 +358,13 @@ server.listen(0, '127.0.0.1', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: browserProfileDir,
devtools_port: Number.parseInt(port, 10),
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
+9 -3
View File
@@ -158,16 +158,22 @@ describe('resolveTargetType', () => {
it('should detect internal entry target for dedicated package bin entrypoints', () => {
process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid';
process.argv = ['node', 'ccs'];
process.argv = ['node', '/usr/local/lib/ccs/dist/bin/droid-runtime.js'];
expect(resolveTargetType([])).toBe('droid');
});
it('should detect internal entry target for codex runtime bins', () => {
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
process.argv = ['node', 'ccs'];
process.argv = ['node', '/usr/local/lib/ccs/dist/bin/codex-runtime.js'];
expect(resolveTargetType([])).toBe('codex');
});
it('should ignore leaked internal entry target env on the main ccs entrypoint', () => {
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
process.argv = ['node', 'ccs.js'];
expect(resolveTargetType([])).toBe('claude');
});
it('should normalize argv[0] and custom aliases case-insensitively', () => {
process.env.CCS_DROID_ALIASES = 'DroidCaps';
process.argv = ['node', 'DROIDCAPS'];
@@ -242,7 +248,7 @@ describe('resolveTargetType', () => {
it('should prioritize internal entry target over profile config', () => {
process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid';
process.argv = ['node', 'ccs'];
process.argv = ['node', '/usr/local/lib/ccs/dist/bin/droid-runtime.js'];
expect(resolveTargetType([], { target: 'claude' })).toBe('droid');
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'bun:test';
import {
resolveBrowserExposure,
resolveBrowserLaunchFlagResolution,
} from '../../../../src/utils/browser/browser-policy';
describe('browser policy', () => {
it('strips browser launch flags and records the override', () => {
expect(resolveBrowserLaunchFlagResolution(['glm', '--browser', 'check app'])).toEqual({
override: 'force-enable',
argsWithoutFlags: ['glm', 'check app'],
});
expect(resolveBrowserLaunchFlagResolution(['glm', '--no-browser', 'check app'])).toEqual({
override: 'force-disable',
argsWithoutFlags: ['glm', 'check app'],
});
});
it('rejects conflicting browser launch flags', () => {
expect(() => resolveBrowserLaunchFlagResolution(['--browser', '--no-browser'])).toThrow(
'Use either `--browser` or `--no-browser`, not both.'
);
});
it('resolves browser exposure from saved policy and one-run overrides', () => {
expect(resolveBrowserExposure({ enabled: true, policy: 'auto' })).toMatchObject({
exposeByDefault: true,
exposeForLaunch: true,
});
expect(resolveBrowserExposure({ enabled: true, policy: 'manual' })).toMatchObject({
exposeByDefault: false,
exposeForLaunch: false,
});
expect(
resolveBrowserExposure({ enabled: true, policy: 'manual' }, 'force-enable')
).toMatchObject({
exposeByDefault: false,
exposeForLaunch: true,
});
expect(
resolveBrowserExposure({ enabled: true, policy: 'auto' }, 'force-disable')
).toMatchObject({
exposeByDefault: true,
exposeForLaunch: false,
});
});
});
@@ -98,11 +98,13 @@ describe('browser status', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
@@ -138,11 +140,13 @@ describe('browser status', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: '/config-browser',
devtools_port: 9333,
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
@@ -185,11 +189,13 @@ describe('browser status', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
@@ -227,11 +233,13 @@ describe('browser status', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: '/tmp/browser-profile',
devtools_port: 9222,
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
@@ -294,11 +302,13 @@ describe('browser status', () => {
config.browser = {
claude: {
enabled: true,
policy: 'auto',
user_data_dir: '/tmp/config-browser',
devtools_port: 9222,
},
codex: {
enabled: true,
policy: 'auto',
},
};
});
@@ -93,11 +93,13 @@ describe('browser routes', () => {
expect(payload.config).toMatchObject({
claude: {
enabled: false,
policy: 'auto',
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9222,
},
codex: {
enabled: true,
policy: 'auto',
},
});
expect(payload.status.claude).toMatchObject({
@@ -117,11 +119,13 @@ describe('browser routes', () => {
body: JSON.stringify({
claude: {
enabled: true,
policy: 'manual',
userDataDir: '/tmp/ccs-browser',
devtoolsPort: 9333,
},
codex: {
enabled: false,
policy: 'manual',
},
}),
});
@@ -131,11 +135,13 @@ describe('browser routes', () => {
expect(payload.browser.config).toMatchObject({
claude: {
enabled: true,
policy: 'manual',
userDataDir: '/tmp/ccs-browser',
devtoolsPort: 9333,
},
codex: {
enabled: false,
policy: 'manual',
},
});
@@ -143,11 +149,13 @@ describe('browser routes', () => {
expect(config.browser).toMatchObject({
claude: {
enabled: true,
policy: 'manual',
user_data_dir: '/tmp/ccs-browser',
devtools_port: 9333,
},
codex: {
enabled: false,
policy: 'manual',
},
});
});
@@ -159,6 +167,7 @@ describe('browser routes', () => {
body: JSON.stringify({
claude: {
enabled: true,
policy: 'manual',
userDataDir: '/tmp/ccs-browser-custom',
devtoolsPort: 9333,
},
@@ -181,6 +190,7 @@ describe('browser routes', () => {
const payload = await resetResponse.json();
expect(payload.browser.config.claude).toMatchObject({
enabled: true,
policy: 'manual',
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9333,
});
@@ -192,6 +202,7 @@ describe('browser routes', () => {
expect(config.browser).toMatchObject({
claude: {
enabled: true,
policy: 'manual',
user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtools_port: 9333,
},
@@ -214,4 +225,21 @@ describe('browser routes', () => {
error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.',
});
});
it('rejects invalid browser policy values at the route boundary', async () => {
const response = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
codex: {
policy: 'always',
},
}),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
error: 'Invalid value for codex.policy. Must be auto or manual.',
});
});
});