feat(browser): add explicit runtime policy controls

This commit is contained in:
Tam Nhu Tran
2026-04-20 21:01:19 -04:00
parent d6c45fef04
commit 7d02f55f9f
25 changed files with 795 additions and 50 deletions
+36 -1
View File
@@ -46,6 +46,10 @@ The Browser screen exposes two sections:
- enable/disable CCS-managed browser tooling for Codex-target launches
- review whether the detected Codex build supports managed browser overrides
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.
### Via CLI
```bash
@@ -53,10 +57,13 @@ ccs help browser
ccs browser setup
ccs browser status
ccs browser doctor
ccs browser policy
ccs browser policy --all manual
```
Use `ccs browser setup` for the primary one-command setup path. Use `ccs browser status` for
the current state and `ccs browser doctor` for read-only troubleshooting guidance.
the current state, `ccs browser doctor` for read-only troubleshooting guidance, and
`ccs browser policy` to control default browser exposure.
### Via Config File
@@ -66,17 +73,45 @@ Edit `~/.ccs/config.yaml`:
browser:
claude:
enabled: false
policy: auto
user_data_dir: "~/.ccs/browser/chrome-user-data"
devtools_port: 9222
codex:
enabled: true
policy: auto
```
Notes:
- `claude.policy` and `codex.policy` accept `auto` or `manual`
- `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
- `manual` keeps the lane configured but hidden until a launch explicitly opts in with `--browser`
## Runtime Policy Controls
CCS now separates **lane enablement** from **default exposure policy**:
- `enabled: false`
- the lane is off
- `enabled: true` + `policy: auto`
- the lane is exposed automatically on matching launches
- `enabled: true` + `policy: manual`
- the lane stays configured, but CCS keeps browser tooling hidden unless the current launch uses
`--browser`
One-run launch overrides:
```bash
ccs browser policy --all manual
ccs glm --browser "inspect the page"
ccs glm --no-browser "summarize the docs"
ccs default --target codex --browser "use the browser tools for this run"
```
- `--browser` forces browser tooling on for the current launch when that lane is enabled
- `--no-browser` suppresses browser tooling for the current launch even when policy is `auto`
## Environment Variable Overrides
+2 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-04-18
Last Updated: 2026-04-19
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser.<lane>.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.
- **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor.
+67 -15
View File
@@ -39,7 +39,10 @@ import {
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
getBlockedBrowserOverrideWarning,
getEffectiveClaudeBrowserAttachConfig,
resolveBrowserExposure,
resolveBrowserLaunchFlagResolution,
resolveOptionalBrowserAttachRuntime,
syncBrowserMcpToConfigDir,
} from './utils/browser';
@@ -83,6 +86,7 @@ import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
import type { ProfileDetectionResult } from './auth/profile-detector';
import type { BrowserLaunchOverride } from './utils/browser';
// Import target adapter system
import {
@@ -137,11 +141,21 @@ const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'hig
const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
function resolveCodexRuntimeConfigOverrides(
target: ReturnType<typeof resolveTargetType>
target: ReturnType<typeof resolveTargetType>,
browserLaunchOverride: BrowserLaunchOverride | undefined
): string[] {
if (target !== 'codex' || !getBrowserConfig().codex.enabled) {
if (target !== 'codex') {
return [];
}
const codexBrowserExposure = resolveBrowserExposure(
getBrowserConfig().codex,
browserLaunchOverride
);
if (!codexBrowserExposure.exposeForLaunch) {
return [];
}
return buildCodexBrowserMcpOverrides();
}
@@ -591,6 +605,17 @@ 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);
@@ -697,7 +722,38 @@ async function main(): Promise<void> {
// For non-claude targets, verify target binary exists once and pass it through.
const targetBinaryInfo = targetAdapter?.detectBinary() ?? null;
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(resolvedTarget);
const browserConfig = getBrowserConfig();
const claudeAttachConfig =
resolvedTarget === 'claude'
? getEffectiveClaudeBrowserAttachConfig(browserConfig)
: undefined;
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(
resolvedTarget,
browserLaunchOverride
);
const claudeBrowserExposure =
resolvedTarget === 'claude'
? resolveBrowserExposure(
{
enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled,
policy: claudeAttachConfig?.overrideActive ? 'auto' : browserConfig.claude.policy,
},
browserLaunchOverride
)
: undefined;
const codexBrowserExposure =
resolvedTarget === 'codex'
? resolveBrowserExposure(browserConfig.codex, browserLaunchOverride)
: undefined;
const blockedBrowserOverrideWarning =
resolvedTarget === 'claude' && claudeBrowserExposure
? getBlockedBrowserOverrideWarning('Claude Browser Attach', claudeBrowserExposure)
: resolvedTarget === 'codex' && codexBrowserExposure
? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure)
: undefined;
if (blockedBrowserOverrideWarning) {
console.error(warn(blockedBrowserOverrideWarning));
}
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
const displayName = targetAdapter?.displayName || resolvedTarget;
console.error(fail(`${displayName} CLI not found.`));
@@ -1057,13 +1113,11 @@ async function main(): Promise<void> {
// Settings-based profiles (glm, glmt) are third-party providers
const imageAnalysisMcpReady =
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
const browserAttachConfig =
resolvedTarget === 'claude'
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
: undefined;
const browserAttachRuntime =
resolvedTarget === 'claude' && browserAttachConfig?.enabled
? await resolveOptionalBrowserAttachRuntime(browserAttachConfig)
resolvedTarget === 'claude' &&
claudeBrowserExposure?.exposeForLaunch &&
claudeAttachConfig?.enabled
? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig)
: undefined;
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
if (browserAttachRuntime?.warning) {
@@ -1467,13 +1521,11 @@ async function main(): Promise<void> {
CCS_WEBSEARCH_SKIP: '1',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
const browserAttachConfig =
resolvedTarget === 'claude'
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
: undefined;
const browserAttachRuntime =
resolvedTarget === 'claude' && browserAttachConfig?.enabled
? await resolveOptionalBrowserAttachRuntime(browserAttachConfig)
resolvedTarget === 'claude' &&
claudeBrowserExposure?.exposeForLaunch &&
claudeAttachConfig?.enabled
? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig)
: undefined;
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
if (browserAttachRuntime?.warning) {
+35 -13
View File
@@ -66,7 +66,10 @@ import {
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
getBlockedBrowserOverrideWarning,
getEffectiveClaudeBrowserAttachConfig,
resolveBrowserExposure,
resolveBrowserLaunchFlagResolution,
resolveOptionalBrowserAttachRuntime,
syncBrowserMcpToConfigDir,
} from '../../utils/browser';
@@ -245,6 +248,25 @@ export async function execClaudeWithCLIProxy(
}
: undefined,
});
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy);
const browserLaunchOverride = browserLaunchFlags.override;
const argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags;
const browserConfig = getBrowserConfig();
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const claudeBrowserExposure = resolveBrowserExposure(
{
enabled: browserAttachConfig.enabled,
policy: browserAttachConfig.overrideActive ? 'auto' : browserConfig.claude.policy,
},
browserLaunchOverride
);
const blockedBrowserOverrideWarning = getBlockedBrowserOverrideWarning(
'Claude Browser Attach',
claudeBrowserExposure
);
if (blockedBrowserOverrideWarning) {
console.error(warn(blockedBrowserOverrideWarning));
}
// Port resolution and validation
if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) {
@@ -264,10 +286,10 @@ export async function execClaudeWithCLIProxy(
// Setup first-class CCS WebSearch runtime
ensureWebSearchMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig());
const browserAttachRuntime = browserAttachConfig.enabled
? await resolveOptionalBrowserAttachRuntime(browserAttachConfig)
: undefined;
const browserAttachRuntime =
browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch
? await resolveOptionalBrowserAttachRuntime(browserAttachConfig)
: undefined;
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
if (browserAttachRuntime?.warning) {
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
@@ -1260,7 +1282,7 @@ export async function execClaudeWithCLIProxy(
'--settings',
...PROXY_CLI_FLAGS,
];
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
const claudeArgs = argsWithoutBrowserFlags.filter((arg, idx) => {
if (ccsFlags.includes(arg)) return false;
if (arg.startsWith('--kiro-auth-method=')) return false;
if (arg.startsWith('--kiro-idc-start-url=')) return false;
@@ -1270,14 +1292,14 @@ export async function execClaudeWithCLIProxy(
if (arg.startsWith('--effort=')) return false;
if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false;
if (
argsWithoutProxy[idx - 1] === '--use' ||
argsWithoutProxy[idx - 1] === '--nickname' ||
argsWithoutProxy[idx - 1] === '--kiro-auth-method' ||
argsWithoutProxy[idx - 1] === '--kiro-idc-start-url' ||
argsWithoutProxy[idx - 1] === '--kiro-idc-region' ||
argsWithoutProxy[idx - 1] === '--kiro-idc-flow' ||
argsWithoutProxy[idx - 1] === '--thinking' ||
argsWithoutProxy[idx - 1] === '--effort'
argsWithoutBrowserFlags[idx - 1] === '--use' ||
argsWithoutBrowserFlags[idx - 1] === '--nickname' ||
argsWithoutBrowserFlags[idx - 1] === '--kiro-auth-method' ||
argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-start-url' ||
argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-region' ||
argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-flow' ||
argsWithoutBrowserFlags[idx - 1] === '--thinking' ||
argsWithoutBrowserFlags[idx - 1] === '--effort'
)
return false;
return true;
+222 -12
View File
@@ -1,9 +1,12 @@
import * as browserUtils from '../utils/browser';
import { getBrowserConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
import type { BrowserToolPolicy } from '../config/unified-config-types';
import { getCcsPathDisplay } from '../utils/config-manager';
import { getNodePlatformKey } from '../utils/browser/platform';
import { color, dim, header, initUI, subheader } from '../utils/ui';
type HelpWriter = (line: string) => void;
type BrowserLane = 'claude' | 'codex' | 'all';
function summarizeBrowserHealth(status: browserUtils.BrowserStatusPayload): {
label: 'ready' | 'partial' | 'action required';
@@ -21,16 +24,40 @@ function summarizeBrowserHealth(status: browserUtils.BrowserStatusPayload): {
return { label: 'ready', exitCode: 0 };
}
function isBrowserPolicy(value: string): value is BrowserToolPolicy {
return value === 'auto' || value === 'manual';
}
function parseBrowserLane(value: string | undefined): BrowserLane | undefined {
if (value === 'claude' || value === 'codex' || value === 'all') {
return value;
}
return undefined;
}
function writeCommandTable(writeLine: HelpWriter): void {
writeLine(subheader('Commands'));
writeLine(
` ${color('ccs browser setup', 'command')} Configure Claude Browser Attach and print the manual launch command`
` ${color('ccs browser setup', 'command')} Configure Claude Browser Attach and print the manual launch command`
);
writeLine(
` ${color('ccs browser status', 'command')} Show Claude attach and Codex browser readiness`
` ${color('ccs browser status', 'command')} Show Claude attach and Codex browser readiness`
);
writeLine(
` ${color('ccs browser doctor', 'command')} Explain what is missing and how to fix it`
` ${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`
);
writeLine(
` ${color('ccs browser policy --all manual', 'command')} Keep browser tooling hidden unless a launch uses --browser`
);
writeLine(
` ${color('ccs browser enable <claude|codex|all>', 'command')} Turn a browser lane on`
);
writeLine(
` ${color('ccs browser disable <claude|codex|all>', 'command')} Turn a browser lane off`
);
writeLine('');
}
@@ -41,6 +68,19 @@ function writeIntro(writeLine: HelpWriter): void {
' Codex Browser Tools inject managed Playwright MCP overrides into Codex-target launches.'
);
writeLine('');
writeLine(subheader('Launch Overrides'));
writeLine(
` ${color('--browser', 'command')} Force browser tooling on for the current launch when the lane is enabled`
);
writeLine(
` ${color('--no-browser', 'command')} Suppress browser tooling for the current launch even when policy is auto`
);
writeLine('');
}
function writeLaunchPolicy(policy: BrowserToolPolicy, writeLine: HelpWriter): void {
writeLine(` Policy: ${browserUtils.describeBrowserPolicy(policy)}`);
writeLine(` Default launch behavior: ${browserUtils.describeDefaultBrowserExposure(policy)}`);
}
function writeClaudeStatus(
@@ -56,6 +96,7 @@ function writeClaudeStatus(
writeLine(subheader('Claude Browser Attach'));
writeLine(` State: ${status.state}`);
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
writeLaunchPolicy(status.policy, writeLine);
writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`);
writeLine(` User data dir: ${userDataDirDisplay}`);
writeLine(` DevTools port: ${status.devtoolsPort}`);
@@ -80,6 +121,7 @@ function writeCodexStatus(
writeLine(subheader('Codex Browser Tools'));
writeLine(` State: ${status.state}`);
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
writeLaunchPolicy(status.policy, writeLine);
writeLine(` Managed server: ${status.serverName}`);
writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`);
writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`);
@@ -110,13 +152,144 @@ function writeSetupSummary(
writeLine('');
}
function writePolicySummary(writeLine: HelpWriter): void {
const config = getBrowserConfig();
writeLine(header('ccs browser policy'));
writeLine('');
writeIntro(writeLine);
writeLine(subheader('Claude Browser Attach'));
writeLine(` Enabled: ${config.claude.enabled ? 'yes' : 'no'}`);
writeLaunchPolicy(config.claude.policy, writeLine);
writeLine('');
writeLine(subheader('Codex Browser Tools'));
writeLine(` Enabled: ${config.codex.enabled ? 'yes' : 'no'}`);
writeLaunchPolicy(config.codex.policy, writeLine);
writeLine('');
writeLine(subheader('Examples'));
writeLine(
` ${color('ccs browser policy --all manual', 'command')} ${dim('# keep browser tooling hidden until a launch opts in')}`
);
writeLine(
` ${color('ccs glm --browser "open the site"', 'command')} ${dim('# one-run browser opt-in')}`
);
writeLine(
` ${color('ccs glm --no-browser "summarize the docs"', 'command')} ${dim('# one-run browser opt-out')}`
);
writeLine('');
}
function writeToggleSummary(
subcommand: 'enable' | 'disable',
lane: BrowserLane,
writeLine: HelpWriter
) {
const config = getBrowserConfig();
const verb = subcommand === 'enable' ? 'enabled' : 'disabled';
writeLine(header(`ccs browser ${subcommand}`));
writeLine('');
writeLine(` Updated ${lane} browser lane${lane === 'all' ? 's' : ''}.`);
writeLine(` Browser lanes are now ${verb} as requested.`);
writeLine('');
writeLine(subheader('Current State'));
writeLine(` Claude enabled: ${config.claude.enabled ? 'yes' : 'no'}`);
writeLine(` Claude policy: ${config.claude.policy}`);
writeLine(` Codex enabled: ${config.codex.enabled ? 'yes' : 'no'}`);
writeLine(` Codex policy: ${config.codex.policy}`);
writeLine('');
}
function updateBrowserPolicies(updates: {
claude?: BrowserToolPolicy;
codex?: BrowserToolPolicy;
}): void {
mutateUnifiedConfig((config) => {
const current = getBrowserConfig();
config.browser = {
claude: {
enabled: current.claude.enabled,
policy: updates.claude ?? current.claude.policy,
user_data_dir: current.claude.user_data_dir,
devtools_port: current.claude.devtools_port,
},
codex: {
enabled: current.codex.enabled,
policy: updates.codex ?? current.codex.policy,
},
};
});
}
function updateBrowserEnabled(subcommand: 'enable' | 'disable', lane: BrowserLane): void {
const nextEnabled = subcommand === 'enable';
mutateUnifiedConfig((config) => {
const current = getBrowserConfig();
config.browser = {
claude: {
enabled: lane === 'all' || lane === 'claude' ? nextEnabled : current.claude.enabled,
policy: current.claude.policy,
user_data_dir: current.claude.user_data_dir,
devtools_port: current.claude.devtools_port,
},
codex: {
enabled: lane === 'all' || lane === 'codex' ? nextEnabled : current.codex.enabled,
policy: current.codex.policy,
},
};
});
}
function parsePolicyArgs(args: string[]): {
claude?: BrowserToolPolicy;
codex?: BrowserToolPolicy;
error?: string;
} {
let claude: BrowserToolPolicy | undefined;
let codex: BrowserToolPolicy | undefined;
for (let index = 0; index < args.length; index += 2) {
const flag = args[index];
const value = args[index + 1];
if (!flag) {
break;
}
if (flag !== '--all' && flag !== '--claude' && flag !== '--codex') {
return { error: `Unknown browser policy argument: ${flag}` };
}
if (!value || value.startsWith('-')) {
return { error: `${flag} requires a value: auto or manual.` };
}
if (!isBrowserPolicy(value)) {
return { error: `${flag} must be one of: auto, manual.` };
}
if (flag === '--all' || flag === '--claude') {
claude = value;
}
if (flag === '--all' || flag === '--codex') {
codex = value;
}
}
return { claude, codex };
}
function isHelpRequest(args: string[]): boolean {
return args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h');
}
export async function showBrowserHelp(writeLine: HelpWriter = console.log): Promise<void> {
await initUI();
writeLine(header('CCS Browser Help'));
writeLine('');
writeIntro(writeLine);
writeLine(subheader('Usage'));
writeLine(` ${color('ccs browser <setup|status|doctor>', 'command')}`);
writeLine(` ${color('ccs browser <setup|status|doctor|policy|enable|disable>', 'command')}`);
writeLine(` ${color('ccs help browser', 'command')}`);
writeLine('');
writeCommandTable(writeLine);
@@ -126,21 +299,23 @@ export async function showBrowserHelp(writeLine: HelpWriter = console.log): Prom
writeLine('');
writeLine(subheader('Examples'));
writeLine(
` ${color('ccs browser setup', 'command')} ${dim('# Configure browser attach and print the manual launch command')}`
` ${color('ccs browser setup', 'command')} ${dim('# configure browser attach and print the manual launch command')}`
);
writeLine(
` ${color('ccs browser doctor', 'command')} ${dim('# Detailed troubleshooting output')}`
` ${color('ccs browser policy --all manual', 'command')} ${dim('# keep browser tooling hidden until a launch opts in')}`
);
writeLine(
` ${color('ccs config', 'command')} ${dim('# Open Settings > Browser in the dashboard')}`
` ${color('ccs glm --browser "inspect app"', 'command')} ${dim('# one-run browser opt-in')}`
);
writeLine(
` ${color('ccs glm --no-browser "summarize app"', 'command')} ${dim('# one-run browser opt-out')}`
);
writeLine(
` ${color('ccs config', 'command')} ${dim('# open Settings > Browser in the dashboard')}`
);
writeLine('');
}
function isHelpRequest(args: string[]): boolean {
return args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h');
}
export async function handleBrowserCommand(
args: string[],
writeLine: HelpWriter = console.log
@@ -175,6 +350,41 @@ export async function handleBrowserCommand(
return;
}
if (subcommand === 'policy') {
const parsed = parsePolicyArgs(args.slice(1));
await initUI();
if (parsed.error) {
writeLine(color(parsed.error, 'error'));
writeLine('');
process.exitCode = 1;
return;
}
if (parsed.claude || parsed.codex) {
updateBrowserPolicies(parsed);
}
writePolicySummary(writeLine);
return;
}
if (subcommand === 'enable' || subcommand === 'disable') {
const lane = parseBrowserLane(args[1]);
await initUI();
if (!lane || args.length > 2) {
writeLine(color(`Usage: ccs browser ${subcommand} <claude|codex|all>`, 'error'));
writeLine('');
process.exitCode = 1;
return;
}
updateBrowserEnabled(subcommand, lane);
writeToggleSummary(subcommand, lane, writeLine);
return;
}
if (subcommand === 'doctor' && (args.includes('--fix') || args.includes('-f'))) {
await initUI();
writeLine(color('`ccs browser doctor` is read-only.', 'error'));
@@ -188,7 +398,7 @@ export async function handleBrowserCommand(
await initUI();
writeLine(color(`Unknown browser subcommand: ${subcommand}`, 'error'));
writeLine('');
writeLine(` ${dim('Supported subcommands: setup, status, doctor')}`);
writeLine(` ${dim('Supported subcommands: setup, status, doctor, policy, enable, disable')}`);
writeLine('');
process.exitCode = 1;
return;
+2 -2
View File
@@ -123,7 +123,7 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
},
{
name: 'browser',
summary: 'Set up or inspect Claude Browser Attach and Codex Browser Tools readiness',
summary: 'Set up, inspect, and control Claude Browser Attach and Codex Browser Tools',
group: 'runtime',
visibility: 'public',
},
@@ -320,7 +320,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'],
cursor: ['--help', '-h'],
doctor: ['--fix', '-f', '--help', '-h'],
browser: ['setup', 'status', 'doctor', '--help', '-h'],
browser: ['setup', 'status', 'doctor', 'policy', 'enable', 'disable', '--help', '-h'],
docker: ['--help', '-h', '--host'],
env: ['--format', '--shell', '--ide', '--help', '-h'],
migrate: MIGRATE_FLAGS,
+4 -1
View File
@@ -190,7 +190,10 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker);
case 'browser':
if (!subcommand || subcommand.startsWith('-')) {
return completeSubcommands(['setup', 'status', 'doctor'], ['--help', '-h']);
return completeSubcommands(
['setup', 'status', 'doctor', 'policy', 'enable', 'disable'],
['--help', '-h']
);
}
if (subcommand === 'setup' || subcommand === 'doctor') {
return completeSubcommands([], ['--help', '-h']);
+7
View File
@@ -37,6 +37,7 @@ import type {
OfficialChannelId,
DashboardAuthConfig,
BrowserConfig,
BrowserToolPolicy,
ImageAnalysisConfig,
LoggingConfig,
CursorConfig,
@@ -71,15 +72,21 @@ function normalizeBrowserDevtoolsPort(value: number | undefined): number {
return port;
}
function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy {
return value === 'manual' ? 'manual' : DEFAULT_BROWSER_CONFIG.claude.policy;
}
function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig {
return {
claude: {
enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled,
policy: normalizeBrowserPolicy(config?.claude?.policy),
user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(),
devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port),
},
codex: {
enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled,
policy: normalizeBrowserPolicy(config?.codex?.policy),
},
};
}
+8
View File
@@ -820,9 +820,13 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
* Browser automation configuration.
* Controls Claude browser attach and Codex browser tooling.
*/
export type BrowserToolPolicy = 'auto' | 'manual';
export interface BrowserClaudeConfig {
/** Enable Claude browser attach (default: false) */
enabled: boolean;
/** Control whether Claude browser attach is exposed automatically or only via --browser */
policy: BrowserToolPolicy;
/** Chrome user-data directory used for attach mode */
user_data_dir: string;
/** DevTools port used for attach mode (default: 9222) */
@@ -832,6 +836,8 @@ export interface BrowserClaudeConfig {
export interface BrowserCodexConfig {
/** Enable Codex browser tooling injection (default: true) */
enabled: boolean;
/** Control whether Codex browser tooling is exposed automatically or only via --browser */
policy: BrowserToolPolicy;
}
export interface BrowserConfig {
@@ -842,11 +848,13 @@ export interface BrowserConfig {
export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
claude: {
enabled: false,
policy: 'auto',
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: true,
policy: 'auto',
},
};
+16
View File
@@ -29,6 +29,17 @@ const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES';
const LEGACY_TARGET_ALIAS_ENV_VARS: Partial<Record<TargetType, string>> =
getLegacyTargetAliasEnvVars();
const RESERVED_BIN_NAMES = new Set<string>(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]);
const INTERNAL_RUNTIME_ENTRY_BASENAMES = new Set([
'droid-runtime',
'droid-runtime.ts',
'droid-runtime.js',
'codex-runtime',
'codex-runtime.ts',
'codex-runtime.js',
'ccsxp-runtime',
'ccsxp-runtime.ts',
'ccsxp-runtime.js',
]);
function addAliasToMap(map: Record<string, TargetType>, alias: string, target: TargetType): void {
const normalizedAlias = alias.trim().toLowerCase();
@@ -102,6 +113,11 @@ function resolveEntrypointTarget(): TargetType | null {
return null;
}
const entryScript = path.basename(process.argv[1] || '');
if (!INTERNAL_RUNTIME_ENTRY_BASENAMES.has(entryScript)) {
return null;
}
const normalizedTarget = rawTarget.trim().toLowerCase();
return isRuntimeTargetType(normalizedTarget) ? normalizedTarget : null;
}
+86
View File
@@ -0,0 +1,86 @@
import type { BrowserToolPolicy } from '../../config/unified-config-types';
export type BrowserLaunchOverride = 'force-enable' | 'force-disable';
export interface BrowserLaunchFlagResolution {
override?: BrowserLaunchOverride;
argsWithoutFlags: string[];
}
export interface ResolvedBrowserExposure {
enabled: boolean;
policy: BrowserToolPolicy;
override?: BrowserLaunchOverride;
exposeByDefault: boolean;
exposeForLaunch: boolean;
}
const ENABLE_BROWSER_FLAG = '--browser';
const DISABLE_BROWSER_FLAG = '--no-browser';
export function resolveBrowserLaunchFlagResolution(args: string[]): BrowserLaunchFlagResolution {
let override: BrowserLaunchOverride | undefined;
const argsWithoutFlags: string[] = [];
for (const arg of args) {
if (arg === ENABLE_BROWSER_FLAG) {
if (override === 'force-disable') {
throw new Error('Use either `--browser` or `--no-browser`, not both.');
}
override = 'force-enable';
continue;
}
if (arg === DISABLE_BROWSER_FLAG) {
if (override === 'force-enable') {
throw new Error('Use either `--browser` or `--no-browser`, not both.');
}
override = 'force-disable';
continue;
}
argsWithoutFlags.push(arg);
}
return {
override,
argsWithoutFlags,
};
}
export function resolveBrowserExposure(
config: { enabled: boolean; policy: BrowserToolPolicy },
override?: BrowserLaunchOverride
): ResolvedBrowserExposure {
const exposeByDefault = config.enabled && config.policy === 'auto';
const exposeForLaunch =
config.enabled &&
(override === 'force-enable' || (override !== 'force-disable' && exposeByDefault));
return {
enabled: config.enabled,
policy: config.policy,
override,
exposeByDefault,
exposeForLaunch,
};
}
export function describeBrowserPolicy(policy: BrowserToolPolicy): string {
return policy === 'manual' ? 'manual' : 'auto';
}
export function describeDefaultBrowserExposure(policy: BrowserToolPolicy): string {
return policy === 'manual' ? 'hidden until `--browser`' : 'auto-exposed';
}
export function getBlockedBrowserOverrideWarning(
laneLabel: string,
exposure: ResolvedBrowserExposure
): string | undefined {
if (exposure.override === 'force-enable' && !exposure.enabled) {
return `Browser tooling was requested with \`--browser\`, but ${laneLabel} is disabled in CCS config.`;
}
return undefined;
}
+2
View File
@@ -88,11 +88,13 @@ function persistBrowserSetupConfig(deps: BrowserSetupDeps, currentConfig: Browse
config.browser = {
claude: {
enabled: true,
policy: existingBrowser.claude.policy,
user_data_dir: currentUserDataDir || getRecommendedBrowserUserDataDir(),
devtools_port: currentConfig.claude.devtools_port,
},
codex: {
enabled: existingBrowser.codex.enabled,
policy: existingBrowser.codex.policy,
},
};
});
+7
View File
@@ -1,4 +1,5 @@
import * as path from 'path';
import type { BrowserToolPolicy } from '../../config/unified-config-types';
import { getBrowserConfig } from '../../config/unified-config-loader';
import { getCcsPathDisplay } from '../config-manager';
import { getCodexBinaryInfo } from '../../targets/codex-detector';
@@ -17,6 +18,7 @@ import {
export interface ClaudeBrowserStatus {
enabled: boolean;
policy: BrowserToolPolicy;
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
overrideActive: boolean;
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
@@ -34,6 +36,7 @@ export interface ClaudeBrowserStatus {
export interface CodexBrowserStatus {
enabled: boolean;
policy: BrowserToolPolicy;
state: 'disabled' | 'enabled' | 'unsupported_build';
title: string;
detail: string;
@@ -65,6 +68,7 @@ async function buildClaudeBrowserStatus(
const managedBootstrap = ensureManagedBrowserUserDataDir(effective);
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
enabled: effective.enabled,
policy: browserConfig.claude.policy,
source: effective.source,
overrideActive: effective.overrideActive,
effectiveUserDataDir: effective.userDataDir,
@@ -171,6 +175,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows
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.',
@@ -187,6 +192,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows
if (!binaryInfo || !supportsConfigOverrides) {
return {
enabled: true,
policy: browserConfig.codex.policy,
state: 'unsupported_build',
title: 'Codex Browser Tools need a Codex build with --config override support.',
detail: binaryInfo
@@ -202,6 +208,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows
return {
enabled: true,
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.',
+8
View File
@@ -16,6 +16,14 @@ export {
} from './mcp-installer';
export { appendBrowserToolArgs } from './claude-tool-args';
export {
describeBrowserPolicy,
describeDefaultBrowserExposure,
getBlockedBrowserOverrideWarning,
resolveBrowserExposure,
resolveBrowserLaunchFlagResolution,
} from './browser-policy';
export type { BrowserLaunchOverride, BrowserLaunchFlagResolution } from './browser-policy';
export {
buildBrowserLaunchCommands,
+24
View File
@@ -10,14 +10,20 @@ const BROWSER_LOCAL_ACCESS_ERROR =
interface BrowserRouteBody {
claude?: {
enabled?: boolean;
policy?: 'auto' | 'manual';
userDataDir?: string;
devtoolsPort?: number;
};
codex?: {
enabled?: boolean;
policy?: 'auto' | 'manual';
};
}
function isValidBrowserPolicy(value: string): value is 'auto' | 'manual' {
return value === 'auto' || value === 'manual';
}
function isValidDevtoolsPort(value: number): boolean {
return Number.isInteger(value) && value >= 1 && value <= 65535;
}
@@ -73,6 +79,13 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
res.status(400).json({ error: 'Invalid value for claude.enabled. Must be a boolean.' });
return;
}
if (
claude?.policy !== undefined &&
(typeof claude.policy !== 'string' || !isValidBrowserPolicy(claude.policy))
) {
res.status(400).json({ error: 'Invalid value for claude.policy. Must be auto or manual.' });
return;
}
if (claude?.userDataDir !== undefined && typeof claude.userDataDir !== 'string') {
res.status(400).json({ error: 'Invalid value for claude.userDataDir. Must be a string.' });
return;
@@ -90,6 +103,13 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' });
return;
}
if (
codex?.policy !== undefined &&
(typeof codex.policy !== 'string' || !isValidBrowserPolicy(codex.policy))
) {
res.status(400).json({ error: 'Invalid value for codex.policy. Must be auto or manual.' });
return;
}
try {
const current = getBrowserConfig();
@@ -99,11 +119,13 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
config.browser = {
claude: {
enabled: claude?.enabled ?? current.claude.enabled,
policy: claude?.policy ?? current.claude.policy,
user_data_dir: nextClaudeUserDataDir,
devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port,
},
codex: {
enabled: codex?.enabled ?? current.codex.enabled,
policy: codex?.policy ?? current.codex.policy,
},
};
});
@@ -126,11 +148,13 @@ function toBrowserRouteConfig(config: ReturnType<typeof getBrowserConfig>) {
return {
claude: {
enabled: config.claude.enabled,
policy: config.claude.policy,
userDataDir: config.claude.user_data_dir,
devtoolsPort: config.claude.devtools_port,
},
codex: {
enabled: config.codex.enabled,
policy: config.codex.policy,
},
};
}
+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.',
});
});
});