feat: add browser setup flow for Claude attach

This commit is contained in:
Tam Nhu Tran
2026-04-19 22:00:16 -04:00
parent ba0a7ccd60
commit 7ad8bbf0a1
15 changed files with 1052 additions and 130 deletions
+45 -13
View File
@@ -1,6 +1,6 @@
# Browser Automation
Last Updated: 2026-04-16
Last Updated: 2026-04-19
CCS provides browser automation through two separate runtime paths:
@@ -50,12 +50,14 @@ The Browser screen exposes two sections:
```bash
ccs help browser
ccs browser setup
ccs browser status
ccs browser doctor
ccs browser doctor --fix
```
Use `ccs browser status` for the current state and `ccs browser doctor` for actionable
troubleshooting guidance.
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.
### Via Config File
@@ -109,6 +111,34 @@ legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not se
Do not treat the generic Codex MCP editor as the primary browser setup path. CCS-managed browser
entries should be configured from `Settings -> Browser`.
## Primary Setup Flow
The shortest supported setup path is:
```bash
ccs browser setup
```
That flow:
1. enables Claude Browser Attach in the saved CCS browser config
2. keeps the configured DevTools port normalized
3. creates the configured browser user-data directory if needed
4. tries to start a managed Chrome/Chromium attach session
5. re-checks readiness and reports the next step if Chrome still needs manual attention
If you want the same remediation flow from the diagnostic command, use:
```bash
ccs browser doctor --fix
```
If you only want to save config and browser-directory state without launching Chrome:
```bash
ccs browser setup --no-launch
```
## Launching Chrome For Claude Attach
Claude Browser Attach needs a browser launched with remote debugging.
@@ -137,15 +167,15 @@ the remaining requirement is a running Chrome session started with `--remote-deb
### Browser status says Claude Browser Attach is disabled
Enable Claude Browser Attach in `Settings -> Browser` or via the browser config block in
`~/.ccs/config.yaml`.
Run `ccs browser setup`, enable Claude Browser Attach in `Settings -> Browser`, or edit the
browser config block in `~/.ccs/config.yaml`.
### Browser status says the path is missing
The configured Chrome user-data directory does not exist yet.
1. Create the directory or use the generated launch command
2. Start Chrome in attach mode with `--remote-debugging-port`
1. Run `ccs browser setup`
2. If Chrome still is not ready, use the generated launch command
3. Rerun `ccs browser doctor`
If you are using the CCS-managed default path, this usually means the path could not be created
@@ -155,9 +185,10 @@ automatically and now needs manual attention.
CCS could not find usable DevTools attach metadata for the configured user-data directory.
1. Make sure Chrome was started with `--remote-debugging-port=<port>`
2. Make sure it is using the same `user_data_dir` configured in CCS
3. Rerun `ccs browser doctor`
1. Run `ccs browser setup`
2. If needed, make sure Chrome was started with `--remote-debugging-port=<port>`
3. Make sure it is using the same `user_data_dir` configured in CCS
4. Rerun `ccs browser doctor`
For the CCS-managed default path, this is the normal first-run state after CCS bootstraps the
directory for you.
@@ -166,9 +197,10 @@ directory for you.
CCS found attach metadata, but the endpoint did not answer successfully.
1. Restart the attach browser session
2. Confirm the expected port matches the real remote debugging port
3. Rerun `ccs browser status`
1. Run `ccs browser setup`
2. If needed, restart the attach browser session
3. Confirm the expected port matches the real remote debugging port
4. Rerun `ccs browser status`
### Codex Browser Tools are unavailable
+1
View File
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **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, supports `ccs browser doctor --fix` as a repair alias, 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.
- **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads.
+78 -11
View File
@@ -1,10 +1,11 @@
import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser';
import * as browserUtils from '../utils/browser';
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;
function summarizeBrowserHealth(status: BrowserStatusPayload): {
function summarizeBrowserHealth(status: browserUtils.BrowserStatusPayload): {
label: 'ready' | 'partial' | 'action required';
exitCode: 0 | 1;
} {
@@ -22,12 +23,18 @@ function summarizeBrowserHealth(status: BrowserStatusPayload): {
function writeCommandTable(writeLine: HelpWriter): void {
writeLine(subheader('Commands'));
writeLine(
` ${color('ccs browser setup', 'command')} Configure Claude Browser Attach and try to start the managed browser session`
);
writeLine(
` ${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`
);
writeLine(
` ${color('ccs browser doctor --fix', 'command')} ${dim('# Alias for browser setup')}`
);
writeLine('');
}
@@ -40,15 +47,20 @@ function writeIntro(writeLine: HelpWriter): void {
}
function writeClaudeStatus(
status: BrowserStatusPayload['claude'],
status: browserUtils.BrowserStatusPayload['claude'],
writeLine: HelpWriter,
includeLaunchGuidance: boolean
): void {
const userDataDirDisplay =
status.effectiveUserDataDir === status.recommendedUserDataDir
? getCcsPathDisplay('browser', 'chrome-user-data')
: status.effectiveUserDataDir;
writeLine(subheader('Claude Browser Attach'));
writeLine(` State: ${status.state}`);
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`);
writeLine(` User data dir: ${status.effectiveUserDataDir}`);
writeLine(` User data dir: ${userDataDirDisplay}`);
writeLine(` DevTools port: ${status.devtoolsPort}`);
writeLine(` Managed MCP: ${status.managedMcpServerName}`);
writeLine(` Managed path: ${status.managedMcpServerPath}`);
@@ -64,7 +76,10 @@ function writeClaudeStatus(
writeLine('');
}
function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: HelpWriter): void {
function writeCodexStatus(
status: browserUtils.BrowserStatusPayload['codex'],
writeLine: HelpWriter
): void {
writeLine(subheader('Codex Browser Tools'));
writeLine(` State: ${status.state}`);
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
@@ -79,13 +94,36 @@ function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: Help
writeLine('');
}
function writeSetupSummary(
result: browserUtils.BrowserSetupResult,
writeLine: HelpWriter,
label: string
): void {
writeLine(subheader('Overall'));
writeLine(` Command: ${label}`);
writeLine(` Result: ${result.ready ? 'ready' : 'action required'}`);
writeLine(` Config updated: ${result.configUpdated ? 'yes' : 'no'}`);
writeLine(` Created user-data dir: ${result.createdUserDataDir ? 'yes' : 'no'}`);
writeLine(` Browser MCP ready: ${result.mcpReady ? 'yes' : 'no'}`);
if (result.launchAttempted) {
writeLine(` Browser launch: ${result.launchStarted ? 'started' : 'failed'}`);
}
writeLine(` Launch command: ${result.launchCommand}`);
if (result.notes.length > 0) {
for (const note of result.notes) {
writeLine(` Note: ${note}`);
}
}
writeLine('');
}
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 <status|doctor>', 'command')}`);
writeLine(` ${color('ccs browser <setup|status|doctor>', 'command')}`);
writeLine(` ${color('ccs help browser', 'command')}`);
writeLine('');
writeCommandTable(writeLine);
@@ -94,38 +132,67 @@ export async function showBrowserHelp(writeLine: HelpWriter = console.log): Prom
writeLine(' Codex Browser Tools depend on a Codex build that supports --config overrides.');
writeLine('');
writeLine(subheader('Examples'));
writeLine(` ${color('ccs browser status', 'command')} ${dim('# Quick readiness snapshot')}`);
writeLine(
` ${color('ccs browser setup', 'command')} ${dim('# Configure and start the managed browser session')}`
);
writeLine(
` ${color('ccs browser setup --no-launch', 'command')} ${dim('# Save setup only, do not start Chrome')}`
);
writeLine(
` ${color('ccs browser doctor', 'command')} ${dim('# Detailed troubleshooting output')}`
);
writeLine(
` ${color('ccs browser doctor --fix', 'command')} ${dim('# Run the setup flow from doctor')}`
);
writeLine(
` ${color('ccs config', 'command')} ${dim('# Open Settings > Browser in the dashboard')}`
);
writeLine('');
}
function isHelpRequest(args: string[]): boolean {
return args.length === 0 || args.includes('--help') || args.includes('-h');
}
export async function handleBrowserCommand(
args: string[],
writeLine: HelpWriter = console.log
): Promise<void> {
const subcommand = args[0];
if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
if (isHelpRequest(args)) {
await showBrowserHelp(writeLine);
return;
}
const subcommand = args[0];
if (subcommand === 'setup' || (subcommand === 'doctor' && args.includes('--fix'))) {
await initUI();
const result = await browserUtils.runBrowserSetup({
launch: !args.includes('--no-launch'),
});
const label = subcommand === 'setup' ? 'ccs browser setup' : 'ccs browser doctor --fix';
writeLine(header(label));
writeLine('');
writeIntro(writeLine);
writeSetupSummary(result, writeLine, label);
writeClaudeStatus(result.status.claude, writeLine, !result.ready);
writeCodexStatus(result.status.codex, writeLine);
process.exitCode = result.ready ? 0 : 1;
return;
}
if (subcommand !== 'status' && subcommand !== 'doctor') {
await initUI();
writeLine(color(`Unknown browser subcommand: ${subcommand}`, 'error'));
writeLine('');
writeLine(` ${dim('Supported subcommands: status, doctor')}`);
writeLine(` ${dim('Supported subcommands: setup, status, doctor')}`);
writeLine('');
process.exitCode = 1;
return;
}
await initUI();
const status = await getBrowserStatus();
const status = await browserUtils.getBrowserStatus();
writeLine(header(`ccs browser ${subcommand}`));
writeLine('');
+2 -2
View File
@@ -123,7 +123,7 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
},
{
name: 'browser',
summary: 'Inspect Claude Browser Attach and Codex Browser Tools readiness',
summary: 'Set up or inspect Claude Browser Attach and Codex Browser Tools readiness',
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: ['status', 'doctor', '--help', '-h'],
browser: ['setup', 'status', 'doctor', '--fix', '-f', '--no-launch', '--help', '-h'],
docker: ['--help', '-h', '--host'],
env: ['--format', '--shell', '--ide', '--help', '-h'],
migrate: MIGRATE_FLAGS,
+11
View File
@@ -188,6 +188,17 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
if (subcommand === 'logs')
return completeSubcommands([], ['--follow', '--service', '--host', '--help', '-h']);
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker);
case 'browser':
if (!subcommand || subcommand.startsWith('-')) {
return completeSubcommands(['setup', 'status', 'doctor'], ['--help', '-h']);
}
if (subcommand === 'setup') {
return completeSubcommands([], ['--no-launch', '--help', '-h']);
}
if (subcommand === 'doctor') {
return completeSubcommands([], ['--fix', '-f', '--help', '-h', '--no-launch']);
}
return completeSubcommands([], ['--help', '-h']);
case 'cursor':
return completeSubcommands(CURSOR_COMPLETION_SUBCOMMANDS);
case 'proxy':
+71 -43
View File
@@ -1,10 +1,10 @@
import * as fs from 'fs';
import * as path from 'path';
import type { BrowserConfig } from '../../config/unified-config-types';
import { getCcsDir } from '../config-manager';
import { getCcsDir, getCcsPathDisplay } from '../config-manager';
import { expandPath } from '../helpers';
import { getNodePlatformKey } from './platform';
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
import { getNodePlatformKey } from './platform';
export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
@@ -17,8 +17,10 @@ export interface EffectiveClaudeBrowserAttachConfig {
hasExplicitDevtoolsPort: boolean;
}
export function getRecommendedBrowserUserDataDir(): string {
return path.join(getCcsDir(), 'browser', 'chrome-user-data');
export interface BrowserLaunchCommands {
darwin: string;
linux: string;
win32: string;
}
export interface BrowserAttachRuntimeResolution {
@@ -39,7 +41,29 @@ export interface ManagedBrowserAttachNotReadyMessage {
warning: string;
}
function isManagedDefaultBrowserAttach(config: EffectiveClaudeBrowserAttachConfig): boolean {
export function getRecommendedBrowserUserDataDir(): string {
return path.join(getCcsDir(), 'browser', 'chrome-user-data');
}
export function resolveBrowserUserDataDir(value?: string): string | undefined {
return value?.trim() ? expandPath(value) : undefined;
}
export function buildBrowserLaunchCommands(
userDataDir: string,
devtoolsPort: number
): BrowserLaunchCommands {
const quotedPath = JSON.stringify(userDataDir);
return {
darwin: `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
linux: `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
win32: `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
};
}
export function isManagedClaudeBrowserAttachConfig(
config: EffectiveClaudeBrowserAttachConfig
): boolean {
return (
config.source === 'config' &&
path.resolve(config.userDataDir) === path.resolve(getRecommendedBrowserUserDataDir())
@@ -47,25 +71,36 @@ function isManagedDefaultBrowserAttach(config: EffectiveClaudeBrowserAttachConfi
}
function buildCurrentPlatformLaunchCommand(userDataDir: string, devtoolsPort: number): string {
const quotedPath = JSON.stringify(userDataDir);
switch (getNodePlatformKey()) {
case 'darwin':
return `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
case 'win32':
return `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
default:
return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
}
return buildBrowserLaunchCommands(userDataDir, devtoolsPort)[getNodePlatformKey()];
}
export function resolveBrowserUserDataDir(value?: string): string | undefined {
return value?.trim() ? expandPath(value) : undefined;
function buildManagedBrowserActionLines(): string[] {
return [
'Run `ccs browser setup` to configure and start the managed browser session.',
'Diagnose only: `ccs browser doctor`.',
];
}
export function buildManagedBrowserAttachSetupOptions(
_config: EffectiveClaudeBrowserAttachConfig
): string[] {
return buildManagedBrowserActionLines();
}
function buildManagedBrowserAttachWarning(_config: EffectiveClaudeBrowserAttachConfig): string {
return [
'Claude Browser Attach is not ready yet.',
` Managed user-data dir: ${getCcsPathDisplay('browser', 'chrome-user-data')}`,
' CCS will continue without browser tools for this launch.',
'',
...buildManagedBrowserActionLines().map((line) => ` ${line}`),
].join('\n');
}
export function ensureManagedBrowserUserDataDir(
config: EffectiveClaudeBrowserAttachConfig
): ManagedBrowserAttachBootstrap {
if (!isManagedDefaultBrowserAttach(config)) {
if (!isManagedClaudeBrowserAttachConfig(config)) {
return {
usesManagedDefaultDir: false,
createdProfileDir: false,
@@ -110,51 +145,51 @@ export function describeManagedBrowserAttachNotReady(
launchCommand?: string;
} = {}
): ManagedBrowserAttachNotReadyMessage | undefined {
if (!isManagedDefaultBrowserAttach(config)) {
if (!isManagedClaudeBrowserAttachConfig(config)) {
return undefined;
}
const launchCommand =
options.launchCommand ??
buildCurrentPlatformLaunchCommand(config.userDataDir, config.devtoolsPort);
const continueWithoutTools =
'CCS will continue without browser tools until the attach session is ready.';
buildCurrentPlatformLaunchCommand(
getCcsPathDisplay('browser', 'chrome-user-data'),
config.devtoolsPort
);
const nextStep = [
...buildManagedBrowserActionLines(),
`Manual launch (${getNodePlatformKey()}): ${launchCommand}`,
].join('\n');
if (errorMessage.includes('Chrome reuse metadata')) {
const summary = options.createdProfileDir
? `CCS created the managed browser profile at ${config.userDataDir}, but no running attach-mode Chrome session is using it yet`
: `No running attach-mode Chrome session is using the managed browser profile at ${config.userDataDir}`;
const nextStep = `Start Chrome with remote debugging and the managed user-data dir. Example: ${launchCommand}`;
? 'CCS created the managed browser profile directory, but the attach session is not running yet.'
: 'No running attach-mode Chrome session is using the managed browser profile yet.';
return {
state: 'browser_not_running',
title: 'Claude Browser Attach is waiting for a managed Chrome session.',
detail: `${summary}. Diagnostic: ${errorMessage}`,
title: 'Claude Browser Attach is not ready yet.',
detail: `${summary} Diagnostic: ${errorMessage}`,
nextStep,
warning: `${summary}. ${nextStep} ${continueWithoutTools}`,
warning: buildManagedBrowserAttachWarning(config),
};
}
if (errorMessage.includes('Chrome DevTools endpoint')) {
const summary = `CCS could not reach the attach-mode DevTools endpoint for the managed browser profile at ${config.userDataDir}`;
const nextStep = `Restart Chrome in attach mode and retry. Example: ${launchCommand}`;
return {
state: 'endpoint_unreachable',
title: 'Claude Browser Attach could not reach the managed Chrome session.',
detail: `${summary}. Diagnostic: ${errorMessage}`,
detail: `CCS found the managed browser profile, but the DevTools endpoint did not answer successfully. Diagnostic: ${errorMessage}`,
nextStep,
warning: `${summary}. ${nextStep} ${continueWithoutTools}`,
warning: buildManagedBrowserAttachWarning(config),
};
}
if (errorMessage.includes('Chrome profile directory is invalid')) {
const summary = `CCS could not initialize the managed browser profile at ${config.userDataDir}`;
const nextStep = `Confirm the path is writable or reset it to the CCS-managed default, then launch Chrome in attach mode. Example: ${launchCommand}`;
return {
state: 'path_missing',
title: 'Claude Browser Attach could not initialize the managed profile.',
detail: `${summary}. Diagnostic: ${errorMessage}`,
detail: `CCS could not initialize the managed browser profile directory. Diagnostic: ${errorMessage}`,
nextStep,
warning: `${summary}. ${nextStep} ${continueWithoutTools}`,
warning: buildManagedBrowserAttachWarning(config),
};
}
@@ -213,9 +248,6 @@ export function getEffectiveClaudeBrowserAttachConfig(
overrideActive: false,
userDataDir: configUserDataDir,
devtoolsPort: configPort,
// Config-backed browser attach always keeps an explicit port so launches
// stay aligned with Settings > Browser, even when the effective value is
// the default 9222.
hasExplicitDevtoolsPort: true,
};
}
@@ -230,11 +262,7 @@ export async function resolveOptionalBrowserAttachRuntime(
const bootstrap = ensureManagedBrowserUserDataDir(config);
if (bootstrap.createdProfileDir) {
return {
warning: describeManagedBrowserAttachNotReady(
config,
`Chrome reuse metadata not found: ${path.join(config.userDataDir, 'DevToolsActivePort')}`,
{ createdProfileDir: true }
)?.warning,
warning: buildManagedBrowserAttachWarning(config),
};
}
+348
View File
@@ -0,0 +1,348 @@
import { spawn } from 'child_process';
import * as fs from 'fs';
import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import type { BrowserConfig } from '../../config/unified-config-types';
import { getCcsPathDisplay } from '../config-manager';
import { type BrowserStatusPayload, getBrowserStatus } from './browser-status';
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
import { ensureBrowserMcp } from './mcp-installer';
import {
buildBrowserLaunchCommands,
getEffectiveClaudeBrowserAttachConfig,
getRecommendedBrowserUserDataDir,
isManagedClaudeBrowserAttachConfig,
type EffectiveClaudeBrowserAttachConfig,
} from './browser-settings';
export interface BrowserSetupOptions {
launch?: boolean;
}
export interface BrowserSetupResult {
configUpdated: boolean;
createdUserDataDir: boolean;
launchAttempted: boolean;
launchStarted: boolean;
launchCommand: string;
launchError?: string;
mcpReady: boolean;
overrideActive: boolean;
ready: boolean;
runtimeEnv?: BrowserRuntimeEnv;
status: BrowserStatusPayload;
notes: string[];
}
export interface BrowserSetupDeps {
getBrowserConfig: typeof getBrowserConfig;
mutateUnifiedConfig: typeof mutateUnifiedConfig;
ensureBrowserMcp: typeof ensureBrowserMcp;
resolveBrowserRuntimeEnv: typeof resolveBrowserRuntimeEnv;
getBrowserStatus: typeof getBrowserStatus;
launchBrowserSession: (
config: EffectiveClaudeBrowserAttachConfig
) => Promise<{ launchCommand: string; started: boolean; error?: string }>;
sleep: (ms: number) => Promise<void>;
}
const defaultBrowserSetupDeps: BrowserSetupDeps = {
getBrowserConfig,
mutateUnifiedConfig,
ensureBrowserMcp,
resolveBrowserRuntimeEnv,
getBrowserStatus,
launchBrowserSession,
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
};
export async function runBrowserSetup(
options: BrowserSetupOptions = {},
deps: BrowserSetupDeps = defaultBrowserSetupDeps
): Promise<BrowserSetupResult> {
const initialConfig = deps.getBrowserConfig();
const configUpdated = persistBrowserSetupConfig(deps, initialConfig);
const persistedConfig = deps.getBrowserConfig();
const effectiveConfig = getEffectiveClaudeBrowserAttachConfig(persistedConfig);
const createdUserDataDir = ensureBrowserUserDataDir(effectiveConfig.userDataDir);
const mcpReady = deps.ensureBrowserMcp();
const notes: string[] = [];
if (effectiveConfig.overrideActive) {
notes.push(
`Current session is using ${effectiveConfig.source}; saved config may still be shadowed until that override is removed.`
);
}
if (!mcpReady) {
notes.push('CCS could not fully prepare the local browser MCP runtime.');
}
let runtimeEnv = await tryResolveBrowserRuntime(effectiveConfig, deps);
let launchAttempted = false;
let launchStarted = false;
let launchError: string | undefined;
const { launchCommand } = getPreferredLaunchInfo(effectiveConfig);
if (!runtimeEnv && options.launch !== false) {
launchAttempted = true;
const launchResult = await deps.launchBrowserSession(effectiveConfig);
launchStarted = launchResult.started;
launchError = launchResult.error;
runtimeEnv = launchStarted
? await waitForBrowserRuntime(effectiveConfig, deps)
: await tryResolveBrowserRuntime(effectiveConfig, deps);
if (launchError) {
notes.push(launchError);
}
}
const status = await deps.getBrowserStatus();
return {
configUpdated,
createdUserDataDir,
launchAttempted,
launchStarted,
launchCommand,
launchError,
mcpReady,
overrideActive: effectiveConfig.overrideActive,
ready: Boolean(runtimeEnv) && mcpReady,
runtimeEnv,
status,
notes,
};
}
function persistBrowserSetupConfig(deps: BrowserSetupDeps, currentConfig: BrowserConfig): boolean {
const before = JSON.stringify(currentConfig);
deps.mutateUnifiedConfig((config) => {
const existingBrowser = config.browser ?? currentConfig;
const currentUserDataDir = existingBrowser.claude.user_data_dir?.trim();
config.browser = {
claude: {
enabled: true,
user_data_dir: currentUserDataDir || getRecommendedBrowserUserDataDir(),
devtools_port: currentConfig.claude.devtools_port,
},
codex: {
enabled: existingBrowser.codex.enabled,
},
};
});
return JSON.stringify(deps.getBrowserConfig()) !== before;
}
function ensureBrowserUserDataDir(userDataDir: string): boolean {
try {
fs.statSync(userDataDir);
return false;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code && code !== 'ENOENT') {
return false;
}
}
try {
fs.mkdirSync(userDataDir, { recursive: true, mode: 0o700 });
return true;
} catch {
return false;
}
}
async function tryResolveBrowserRuntime(
config: EffectiveClaudeBrowserAttachConfig,
deps: BrowserSetupDeps
): Promise<BrowserRuntimeEnv | undefined> {
try {
return await deps.resolveBrowserRuntimeEnv({
profileDir: config.userDataDir,
devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined,
});
} catch {
return undefined;
}
}
async function waitForBrowserRuntime(
config: EffectiveClaudeBrowserAttachConfig,
deps: BrowserSetupDeps
): Promise<BrowserRuntimeEnv | undefined> {
const deadline = Date.now() + 10000;
while (Date.now() <= deadline) {
const runtimeEnv = await tryResolveBrowserRuntime(config, deps);
if (runtimeEnv) {
return runtimeEnv;
}
await deps.sleep(250);
}
return undefined;
}
function getPreferredLaunchInfo(config: EffectiveClaudeBrowserAttachConfig): {
launchCommand: string;
} {
const userDataDirDisplay = isManagedClaudeBrowserAttachConfig(config)
? getCcsPathDisplay('browser', 'chrome-user-data')
: config.userDataDir;
const launchCommands = buildBrowserLaunchCommands(userDataDirDisplay, config.devtoolsPort);
if (process.platform === 'win32') {
return { launchCommand: launchCommands.win32 };
}
if (process.platform === 'darwin') {
return { launchCommand: launchCommands.darwin };
}
return { launchCommand: launchCommands.linux };
}
function getLaunchCandidates(config: EffectiveClaudeBrowserAttachConfig): Array<{
command: string;
args: string[];
displayCommand: string;
}> {
const launchCommands = buildBrowserLaunchCommands(config.userDataDir, config.devtoolsPort);
if (process.platform === 'darwin') {
return [
{
command: 'open',
args: [
'-na',
'Google Chrome',
'--args',
`--remote-debugging-port=${config.devtoolsPort}`,
`--user-data-dir=${config.userDataDir}`,
],
displayCommand: launchCommands.darwin,
},
];
}
if (process.platform === 'win32') {
return [
{
command: 'chrome.exe',
args: [
`--remote-debugging-port=${config.devtoolsPort}`,
`--user-data-dir=${config.userDataDir}`,
],
displayCommand: launchCommands.win32,
},
];
}
return [
{
command: 'google-chrome',
args: [
`--remote-debugging-port=${config.devtoolsPort}`,
`--user-data-dir=${config.userDataDir}`,
],
displayCommand: launchCommands.linux,
},
{
command: 'google-chrome-stable',
args: [
`--remote-debugging-port=${config.devtoolsPort}`,
`--user-data-dir=${config.userDataDir}`,
],
displayCommand: launchCommands.linux.replace('google-chrome', 'google-chrome-stable'),
},
{
command: 'chromium',
args: [
`--remote-debugging-port=${config.devtoolsPort}`,
`--user-data-dir=${config.userDataDir}`,
],
displayCommand: launchCommands.linux.replace('google-chrome', 'chromium'),
},
{
command: 'chromium-browser',
args: [
`--remote-debugging-port=${config.devtoolsPort}`,
`--user-data-dir=${config.userDataDir}`,
],
displayCommand: launchCommands.linux.replace('google-chrome', 'chromium-browser'),
},
];
}
async function launchBrowserSession(
config: EffectiveClaudeBrowserAttachConfig
): Promise<{ launchCommand: string; started: boolean; error?: string }> {
let lastError: string | undefined;
const candidates = getLaunchCandidates(config);
for (const candidate of candidates) {
const result = await spawnDetached(candidate.command, candidate.args);
if (result.started) {
return {
launchCommand: candidate.displayCommand,
started: true,
};
}
if (!result.notFound) {
return {
launchCommand: candidate.displayCommand,
started: false,
error: `CCS could not start the browser automatically. ${result.error}`,
};
}
lastError = result.error;
}
return {
launchCommand: getPreferredLaunchInfo(config).launchCommand,
started: false,
error:
lastError ??
'CCS could not find a supported Chrome/Chromium executable to launch automatically.',
};
}
async function spawnDetached(
command: string,
args: string[]
): Promise<{ started: boolean; notFound: boolean; error?: string }> {
return await new Promise((resolve) => {
try {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.once('error', (error) => {
const errno = error as NodeJS.ErrnoException;
resolve({
started: false,
notFound: errno.code === 'ENOENT',
error: errno.message,
});
});
child.once('spawn', () => {
child.unref();
resolve({ started: true, notFound: false });
});
} catch (error) {
resolve({
started: false,
notFound: false,
error: (error as Error).message,
});
}
});
}
+27 -28
View File
@@ -1,22 +1,20 @@
import * as path from 'path';
import { getBrowserConfig } from '../../config/unified-config-loader';
import { getCcsPathDisplay } from '../config-manager';
import { getCodexBinaryInfo } from '../../targets/codex-detector';
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer';
import { getNodePlatformKey } from './platform';
import {
buildBrowserLaunchCommands,
buildManagedBrowserAttachSetupOptions,
describeManagedBrowserAttachNotReady,
ensureManagedBrowserUserDataDir,
type BrowserLaunchCommands,
getEffectiveClaudeBrowserAttachConfig,
getRecommendedBrowserUserDataDir,
} from './browser-settings';
export interface BrowserLaunchCommands {
darwin: string;
linux: string;
win32: string;
}
export interface ClaudeBrowserStatus {
enabled: boolean;
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
@@ -63,7 +61,7 @@ async function buildClaudeBrowserStatus(
browserConfig = getBrowserConfig()
): Promise<ClaudeBrowserStatus> {
const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort);
const launchCommands = buildBrowserLaunchCommands(effective.userDataDir, effective.devtoolsPort);
const managedBootstrap = ensureManagedBrowserUserDataDir(effective);
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
enabled: effective.enabled,
@@ -84,13 +82,12 @@ async function buildClaudeBrowserStatus(
title: 'Claude Browser Attach is disabled.',
detail:
'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.',
nextStep:
'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.',
nextStep: `Enable Claude Browser Attach in Settings > Browser or in ${getCcsPathDisplay('config.yaml')}, then run \`ccs browser setup\`.`,
};
}
if (managedBootstrap.createdProfileDir) {
const managedDefaultMessage = describeManagedBrowserAttachNotReady(
const managedMessage = describeManagedBrowserAttachNotReady(
effective,
`Chrome reuse metadata not found: ${path.join(effective.userDataDir, 'DevToolsActivePort')}`,
{
@@ -98,13 +95,13 @@ async function buildClaudeBrowserStatus(
launchCommand: launchCommands[getNodePlatformKey()],
}
);
if (managedDefaultMessage) {
if (managedMessage) {
return {
...base,
state: managedDefaultMessage.state,
title: managedDefaultMessage.title,
detail: managedDefaultMessage.detail,
nextStep: managedDefaultMessage.nextStep,
state: managedMessage.state,
title: managedMessage.title,
detail: managedMessage.detail,
nextStep: managedMessage.nextStep,
};
}
}
@@ -126,17 +123,17 @@ async function buildClaudeBrowserStatus(
};
} catch (error) {
const message = (error as Error).message;
const managedDefaultMessage = describeManagedBrowserAttachNotReady(effective, message, {
const managedMessage = describeManagedBrowserAttachNotReady(effective, message, {
createdProfileDir: managedBootstrap.createdProfileDir,
launchCommand: launchCommands[getNodePlatformKey()],
});
if (managedDefaultMessage) {
if (managedMessage) {
return {
...base,
state: managedDefaultMessage.state,
title: managedDefaultMessage.title,
detail: managedDefaultMessage.detail,
nextStep: managedDefaultMessage.nextStep,
state: managedMessage.state,
title: managedMessage.title,
detail: managedMessage.detail,
nextStep: managedMessage.nextStep,
};
}
@@ -216,11 +213,13 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows
};
}
function buildLaunchCommands(userDataDir: string, devtoolsPort: number): BrowserLaunchCommands {
const quotedPath = JSON.stringify(userDataDir);
return {
darwin: `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
linux: `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
win32: `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
};
export function getManagedBrowserSetupHint(): string {
return buildManagedBrowserAttachSetupOptions({
enabled: true,
source: 'config',
overrideActive: false,
userDataDir: getRecommendedBrowserUserDataDir(),
devtoolsPort: 9222,
hasExplicitDevtoolsPort: true,
}).join('\n');
}
+12 -1
View File
@@ -18,14 +18,22 @@ export {
export { appendBrowserToolArgs } from './claude-tool-args';
export {
buildBrowserLaunchCommands,
buildManagedBrowserAttachSetupOptions,
describeManagedBrowserAttachNotReady,
ensureManagedBrowserUserDataDir,
getRecommendedBrowserUserDataDir,
getBrowserAttachOverride,
getEffectiveClaudeBrowserAttachConfig,
isManagedClaudeBrowserAttachConfig,
resolveOptionalBrowserAttachRuntime,
} from './browser-settings';
export type {
BrowserLaunchCommands,
BrowserAttachRuntimeResolution,
EffectiveClaudeBrowserAttachConfig,
ManagedBrowserAttachBootstrap,
ManagedBrowserAttachNotReadyMessage,
} from './browser-settings';
export {
@@ -35,9 +43,12 @@ export {
} from './chrome-reuse';
export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse';
export { getBrowserStatus } from './browser-status';
export { getBrowserStatus, getManagedBrowserSetupHint } from './browser-status';
export type {
BrowserStatusPayload,
ClaudeBrowserStatus,
CodexBrowserStatus,
} from './browser-status';
export { runBrowserSetup } from './browser-setup';
export type { BrowserSetupOptions, BrowserSetupResult } from './browser-setup';
+15 -1
View File
@@ -107,8 +107,22 @@ export function getCcsDirSource(): [string, string] {
* Keeps the default path concise while preserving explicit overrides.
*/
export function getCcsDirDisplay(): string {
return getCcsPathDisplay();
}
/**
* Get a CCS path as a user-facing display path.
* Keeps the default location concise while preserving explicit overrides.
*/
export function getCcsPathDisplay(...segments: string[]): string {
const [source, dir] = getCcsDirSource();
return source === 'default' ? '~/.ccs' : dir;
if (source === 'default') {
return process.platform === 'win32'
? path.win32.join('%USERPROFILE%\\.ccs', ...segments)
: path.posix.join('~/.ccs', ...segments.map((segment) => segment.replace(/\\/g, '/')));
}
return path.join(dir, ...segments);
}
/**
+129
View File
@@ -182,4 +182,133 @@ describe('browser command', () => {
statusSpy.mockRestore();
}
});
test('setup runs the browser setup flow and prints remediation results', async () => {
const setupSpy = spyOn(browserUtils, 'runBrowserSetup').mockResolvedValue({
configUpdated: true,
createdUserDataDir: true,
launchAttempted: true,
launchStarted: true,
launchCommand: 'open -na "Google Chrome" --args --remote-debugging-port=9222',
mcpReady: true,
overrideActive: false,
ready: true,
runtimeEnv: {
CCS_BROWSER_USER_DATA_DIR: '/tmp/browser-profile',
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
CCS_BROWSER_DEVTOOLS_PORT: '9222',
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222',
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
},
status: {
claude: {
enabled: true,
source: 'config',
overrideActive: false,
state: 'ready',
title: 'Claude Browser Attach is ready.',
detail: 'CCS can reach the configured Chrome DevTools endpoint.',
nextStep: 'Launch Claude.',
effectiveUserDataDir: '/tmp/browser-profile',
recommendedUserDataDir: '/tmp/browser-profile',
devtoolsPort: 9222,
managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands: {
darwin: 'open -na "Google Chrome" --args',
linux: 'google-chrome --remote-debugging-port=9222',
win32: 'chrome.exe --remote-debugging-port=9222',
},
runtimeEnv: {
CCS_BROWSER_USER_DATA_DIR: '/tmp/browser-profile',
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
CCS_BROWSER_DEVTOOLS_PORT: '9222',
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222',
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
},
},
codex: {
enabled: true,
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'CCS can inject the managed Playwright MCP overrides.',
nextStep: 'Use a Codex-target launch.',
serverName: 'ccs_browser',
supportsConfigOverrides: true,
binaryPath: '/usr/local/bin/codex',
version: 'codex-cli 0.120.0',
},
},
notes: [],
});
try {
const rendered = await renderLines(['setup']);
expect(rendered.includes('ccs browser setup')).toBe(true);
expect(rendered.includes('Result: ready')).toBe(true);
expect(rendered.includes('Config updated: yes')).toBe(true);
expect(rendered.includes('Browser launch: started')).toBe(true);
expect(rendered.includes('Launch command: open -na "Google Chrome"')).toBe(true);
expect(process.exitCode).toBe(0);
} finally {
setupSpy.mockRestore();
}
});
test('doctor --fix reuses the browser setup flow', async () => {
const setupSpy = spyOn(browserUtils, 'runBrowserSetup').mockResolvedValue({
configUpdated: false,
createdUserDataDir: false,
launchAttempted: false,
launchStarted: false,
launchCommand: 'open -na "Google Chrome" --args --remote-debugging-port=9222',
mcpReady: false,
overrideActive: false,
ready: false,
status: {
claude: {
enabled: true,
source: 'config',
overrideActive: false,
state: 'browser_not_running',
title: 'Claude Browser Attach is not ready yet.',
detail: 'No running attach-mode Chrome session is using the managed browser profile yet.',
nextStep: 'Run `ccs browser setup` to configure and start the managed browser session.',
effectiveUserDataDir: '/tmp/browser-profile',
recommendedUserDataDir: '/tmp/browser-profile',
devtoolsPort: 9222,
managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands: {
darwin: 'open -na "Google Chrome" --args',
linux: 'google-chrome --remote-debugging-port=9222',
win32: 'chrome.exe --remote-debugging-port=9222',
},
},
codex: {
enabled: true,
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'CCS can inject the managed Playwright MCP overrides.',
nextStep: 'Use a Codex-target launch.',
serverName: 'ccs_browser',
supportsConfigOverrides: true,
binaryPath: '/usr/local/bin/codex',
},
},
notes: ['CCS could not fully prepare the local browser MCP runtime.'],
});
try {
const rendered = await renderLines(['doctor', '--fix', '--no-launch']);
expect(rendered.includes('ccs browser doctor --fix')).toBe(true);
expect(rendered.includes('Result: action required')).toBe(true);
expect(rendered.includes('Note: CCS could not fully prepare the local browser MCP runtime.')).toBe(true);
expect(process.exitCode).toBe(1);
} finally {
setupSpy.mockRestore();
}
});
});
@@ -140,6 +140,18 @@ describe('completion backend', () => {
expect(suggestionValues(['cliproxy'])).toEqual(expect.arrayContaining(['remove', '--backend']));
});
test('suggests browser subcommands and fix/setup flags', () => {
expect(suggestionValues(['browser'])).toEqual(
expect.arrayContaining(['setup', 'status', 'doctor'])
);
expect(suggestionValues(['browser', 'setup'])).toEqual(
expect.arrayContaining(['--no-launch', '--help', '-h'])
);
expect(suggestionValues(['browser', 'doctor'])).toEqual(
expect.arrayContaining(['--fix', '-f', '--no-launch'])
);
});
test('treats cursor as a provider shortcut in completion', () => {
const values = suggestionValues(['cursor']);
expect(values).toEqual(
@@ -78,6 +78,7 @@ describe('help command parity', () => {
expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe(
true
);
expect(rendered.includes('ccs browser setup')).toBe(true);
expect(rendered.includes('ccs browser status')).toBe(true);
expect(rendered.includes('ccs browser doctor')).toBe(true);
});
@@ -0,0 +1,245 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { UnifiedConfig } from '../../../../src/config/unified-config-types';
import { runBrowserSetup, type BrowserSetupDeps } from '../../../../src/utils/browser/browser-setup';
function createUnifiedConfig(userDataDir: string): UnifiedConfig {
return {
version: 12,
default: undefined,
profiles: {},
profile_targets: {},
copilot: {
enabled: false,
prompt: '',
command: '',
args: [],
env: {},
auto_install: false,
},
cursor: {
enabled: false,
model: '',
port: 3891,
daemon_mode: false,
auth: {
token: '',
},
},
websearch: {
enabled: false,
providers: {},
},
browser: {
claude: {
enabled: false,
user_data_dir: userDataDir,
devtools_port: 9222,
},
codex: {
enabled: true,
},
},
image_analysis: {
enabled: false,
providers: {},
},
global_env: {},
cliproxy_server: {
mode: 'local',
remote: {
enabled: false,
host: '',
port: 0,
protocol: 'http',
auth_token: '',
management_key: '',
},
local: {
port: 8085,
auto_start: true,
},
},
cliproxy_safety: {
concurrent_limit: 1,
cooldown_seconds: 0,
shared_responsibility: false,
},
quota_management: {
enabled: false,
},
thinking: {
mode: 'auto',
show_warnings: true,
},
official_channels: {
enabled: false,
selected: [],
unattended: false,
},
dashboard_auth: {
enabled: false,
users: [],
session_secret: '',
},
logging: {
enabled: true,
profile_starts: true,
delegation_calls: true,
proxy_requests: false,
quota_polls: false,
},
};
}
describe('browser setup', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
it('enables Claude browser attach and returns ready without launching when runtime already resolves', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-setup-'));
const config = createUnifiedConfig(join(tempDir, 'browser-profile'));
const deps: BrowserSetupDeps = {
getBrowserConfig: () => config.browser,
mutateUnifiedConfig: (mutator) => {
mutator(config);
return config;
},
ensureBrowserMcp: () => true,
resolveBrowserRuntimeEnv: async () => ({
CCS_BROWSER_USER_DATA_DIR: config.browser.claude.user_data_dir,
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
CCS_BROWSER_DEVTOOLS_PORT: '9222',
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222',
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
}),
getBrowserStatus: async () =>
({
claude: {
enabled: true,
source: 'config',
overrideActive: false,
state: 'ready',
title: 'Claude Browser Attach is ready.',
detail: 'ready',
nextStep: 'Launch Claude.',
effectiveUserDataDir: config.browser.claude.user_data_dir,
recommendedUserDataDir: config.browser.claude.user_data_dir,
devtoolsPort: 9222,
managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands: {
darwin: 'open -na "Google Chrome" --args',
linux: 'google-chrome --remote-debugging-port=9222',
win32: 'chrome.exe --remote-debugging-port=9222',
},
},
codex: {
enabled: true,
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'ready',
nextStep: 'Use Codex.',
serverName: 'ccs_browser',
supportsConfigOverrides: true,
binaryPath: '/usr/local/bin/codex',
},
}) as Awaited<ReturnType<BrowserSetupDeps['getBrowserStatus']>>,
launchBrowserSession: async () => ({
launchCommand: 'open -na "Google Chrome" --args --remote-debugging-port=9222',
started: false,
}),
sleep: async () => undefined,
};
const result = await runBrowserSetup({ launch: false }, deps);
expect(result.configUpdated).toBe(true);
expect(result.createdUserDataDir).toBe(true);
expect(result.launchAttempted).toBe(false);
expect(result.ready).toBe(true);
expect(config.browser.claude.enabled).toBe(true);
});
it('launches the browser session when runtime is not ready initially', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-setup-'));
const config = createUnifiedConfig(join(tempDir, 'browser-profile'));
let resolveCalls = 0;
const deps: BrowserSetupDeps = {
getBrowserConfig: () => config.browser,
mutateUnifiedConfig: (mutator) => {
mutator(config);
return config;
},
ensureBrowserMcp: () => true,
resolveBrowserRuntimeEnv: async () => {
resolveCalls += 1;
if (resolveCalls < 2) {
throw new Error('Chrome reuse metadata not found');
}
return {
CCS_BROWSER_USER_DATA_DIR: config.browser.claude.user_data_dir,
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
CCS_BROWSER_DEVTOOLS_PORT: '9222',
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222',
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
};
},
getBrowserStatus: async () =>
({
claude: {
enabled: true,
source: 'config',
overrideActive: false,
state: 'ready',
title: 'Claude Browser Attach is ready.',
detail: 'ready',
nextStep: 'Launch Claude.',
effectiveUserDataDir: config.browser.claude.user_data_dir,
recommendedUserDataDir: config.browser.claude.user_data_dir,
devtoolsPort: 9222,
managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands: {
darwin: 'open -na "Google Chrome" --args',
linux: 'google-chrome --remote-debugging-port=9222',
win32: 'chrome.exe --remote-debugging-port=9222',
},
},
codex: {
enabled: true,
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'ready',
nextStep: 'Use Codex.',
serverName: 'ccs_browser',
supportsConfigOverrides: true,
binaryPath: '/usr/local/bin/codex',
},
}) as Awaited<ReturnType<BrowserSetupDeps['getBrowserStatus']>>,
launchBrowserSession: async () => ({
launchCommand: 'open -na "Google Chrome" --args --remote-debugging-port=9222',
started: true,
}),
sleep: async () => undefined,
};
const result = await runBrowserSetup({}, deps);
expect(result.launchAttempted).toBe(true);
expect(result.launchStarted).toBe(true);
expect(result.ready).toBe(true);
expect(resolveCalls).toBeGreaterThanOrEqual(2);
});
});
+55 -31
View File
@@ -2,12 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mutateUnifiedConfig } from '../../../../src/config/unified-config-loader';
import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse';
import {
getBrowserStatus,
} from '../../../../src/utils/browser/browser-status';
import { resolveOptionalBrowserAttachRuntime } from '../../../../src/utils/browser/browser-settings';
getBrowserConfig,
mutateUnifiedConfig,
} from '../../../../src/config/unified-config-loader';
import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse';
import { getBrowserStatus } from '../../../../src/utils/browser/browser-status';
import {
getEffectiveClaudeBrowserAttachConfig,
resolveOptionalBrowserAttachRuntime,
} from '../../../../src/utils/browser/browser-settings';
import * as codexDetector from '../../../../src/targets/codex-detector';
describe('browser status', () => {
@@ -119,11 +123,9 @@ describe('browser status', () => {
const status = await getBrowserStatus();
expect(status.claude.state).toBe('browser_not_running');
expect(status.claude.title).toBe(
'Claude Browser Attach is waiting for a managed Chrome session.'
);
expect(status.claude.detail).toContain('CCS created the managed browser profile');
expect(status.claude.nextStep).toContain('--remote-debugging-port=9222');
expect(status.claude.title).toBe('Claude Browser Attach is not ready yet.');
expect(status.claude.detail).toContain('created the managed browser profile directory');
expect(status.claude.nextStep).toContain('ccs browser setup');
expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
} finally {
runtimeSpy.mockRestore();
@@ -178,7 +180,49 @@ describe('browser status', () => {
}
});
it('reports browser_not_running when attach metadata is missing', async () => {
it('returns a short managed attach warning when the managed browser dir is missing', async () => {
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: true,
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: true,
},
};
});
const resolution = await resolveOptionalBrowserAttachRuntime(
getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
);
expect(resolution.runtimeEnv).toBeUndefined();
expect(resolution.warning).toContain('Claude Browser Attach is not ready yet.');
expect(resolution.warning).toContain('ccs browser setup');
expect(resolution.warning).toContain('ccs browser doctor');
});
it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => {
const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data');
mkdirSync(managedDir, { recursive: true });
const resolution = await resolveOptionalBrowserAttachRuntime({
enabled: true,
source: 'config',
overrideActive: false,
userDataDir: managedDir,
devtoolsPort: 43123,
hasExplicitDevtoolsPort: true,
});
expect(resolution.runtimeEnv).toBeUndefined();
expect(resolution.warning).toContain('Claude Browser Attach is not ready yet.');
expect(resolution.warning).toContain('ccs browser setup');
});
it('reports browser_not_running when attach metadata is missing for a custom path', async () => {
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
@@ -214,26 +258,6 @@ describe('browser status', () => {
}
});
it('returns a managed attach warning when the configured DevTools port is unreachable', async () => {
const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data');
mkdirSync(managedDir, { recursive: true });
const runtime = await resolveOptionalBrowserAttachRuntime({
enabled: true,
source: 'config',
overrideActive: false,
userDataDir: managedDir,
devtoolsPort: 43123,
hasExplicitDevtoolsPort: true,
});
expect(runtime.runtimeEnv).toBeUndefined();
expect(runtime.warning).toContain(
'could not reach the attach-mode DevTools endpoint for the managed browser profile'
);
expect(runtime.warning).toContain('continue without browser tools');
});
it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => {
process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser';