From 4e30c9b080a4e240b1fa17c3b0486dafc28224da Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 13:59:33 -0400 Subject: [PATCH] feat(cli): add browser automation commands --- README.md | 7 +- docs/browser-automation.md | 164 +++++ docs/project-roadmap.md | 1 + src/ccs.ts | 38 +- src/cliproxy/executor/index.ts | 21 +- src/commands/browser-command.ts | 155 +++++ src/commands/command-catalog.ts | 16 +- src/commands/help-command.ts | 8 +- src/commands/root-command-router.ts | 7 + src/config/unified-config-loader.ts | 56 ++ src/config/unified-config-types.ts | 40 ++ src/utils/browser/browser-settings.ts | 100 +++ src/utils/browser/browser-status.ts | 193 ++++++ src/utils/browser/index.ts | 13 + src/web-server/routes/browser-routes.ts | 136 +++++ src/web-server/routes/index.ts | 2 + .../services/compatible-cli-docs-registry.ts | 1 + tests/unit/commands/browser-command.test.ts | 185 ++++++ .../unit/commands/help-command-parity.test.ts | 13 + .../targets/codex-runtime-integration.test.ts | 42 ++ .../default-profile-browser-launch.test.ts | 80 +++ .../settings-profile-browser-launch.test.ts | 86 +++ .../unit/utils/browser/browser-status.test.ts | 202 ++++++ tests/unit/web-server/browser-routes.test.ts | 171 ++++++ .../compatible-cli/codex-docs-tab.tsx | 5 + .../compatible-cli/codex-mcp-servers-card.tsx | 27 +- ui/src/lib/api-client.ts | 71 +++ ui/src/lib/codex-config.ts | 4 + ui/src/lib/i18n.ts | 220 +++++++ .../settings/components/tab-navigation.tsx | 4 +- ui/src/pages/settings/hooks.ts | 1 + ui/src/pages/settings/hooks/context-hooks.ts | 51 ++ ui/src/pages/settings/hooks/index.ts | 1 + .../settings/hooks/use-browser-config.ts | 93 +++ ui/src/pages/settings/index.tsx | 32 +- .../pages/settings/sections/browser/index.tsx | 577 ++++++++++++++++++ ui/src/pages/settings/settings-context.ts | 38 ++ ui/src/pages/settings/types.ts | 12 +- .../codex-mcp-servers-card.test.tsx | 22 + .../unit/ui/lib/codex-config-browser.test.ts | 36 ++ .../unit/ui/pages/browser-section.test.tsx | 109 ++++ .../ui/pages/settings/settings-page.test.tsx | 20 + 42 files changed, 3028 insertions(+), 32 deletions(-) create mode 100644 docs/browser-automation.md create mode 100644 src/commands/browser-command.ts create mode 100644 src/utils/browser/browser-settings.ts create mode 100644 src/utils/browser/browser-status.ts create mode 100644 src/web-server/routes/browser-routes.ts create mode 100644 tests/unit/commands/browser-command.test.ts create mode 100644 tests/unit/utils/browser/browser-status.test.ts create mode 100644 tests/unit/web-server/browser-routes.test.ts create mode 100644 ui/src/pages/settings/hooks/use-browser-config.ts create mode 100644 ui/src/pages/settings/sections/browser/index.tsx create mode 100644 ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx create mode 100644 ui/tests/unit/ui/lib/codex-config-browser.test.ts create mode 100644 ui/tests/unit/ui/pages/browser-section.test.tsx diff --git a/README.md b/README.md index ec27693d..4aab06f4 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,10 @@ Deep dive: ![WebSearch Fallback](assets/screenshots/websearch.webp) CCS can provision first-class local tools like WebSearch and image analysis for -third-party launches instead of leaving you to wire them by hand. Deep dive: -[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch). +third-party launches instead of leaving you to wire them by hand. Browser +automation now has a first-class setup path as well. Deep dive: +[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch) | +[Browser Automation](./docs/browser-automation.md). ## Docs Matrix @@ -144,6 +146,7 @@ reference material. | Compare OAuth providers, Claude accounts, and API profiles | [Provider Overview](https://docs.ccs.kaitran.ca/providers/concepts/overview) | | Learn the dashboard structure and feature pages | [Dashboard Overview](https://docs.ccs.kaitran.ca/features/dashboard/overview) | | Configure profiles, paths, and environment variables | [Configuration](https://docs.ccs.kaitran.ca/getting-started/configuration) | +| Understand browser attach vs Codex browser tooling | [Browser Automation](./docs/browser-automation.md) | | Keep OpenCode aligned with your live CCS setup | [OpenCode Sync Plugin](https://docs.ccs.kaitran.ca/features/workflow/opencode-sync) | | Browse every command and flag | [CLI Commands](https://docs.ccs.kaitran.ca/reference/cli-commands) | | Recover from install, auth, or provider failures | [Troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) | diff --git a/docs/browser-automation.md b/docs/browser-automation.md new file mode 100644 index 00000000..87be8aaf --- /dev/null +++ b/docs/browser-automation.md @@ -0,0 +1,164 @@ +# Browser Automation + +Last Updated: 2026-04-16 + +CCS provides browser automation through two separate runtime paths: + +- **Claude Browser Attach**: reuses a running Chrome/Chromium session through the CCS-managed local `ccs-browser` MCP runtime +- **Codex Browser Tools**: injects Playwright MCP tooling into Codex-target launches + +These are related, but they are not the same implementation and they do not promise a shared browser session. + +## How Browser Automation Works + +### Claude Browser Attach + +Claude-target CCS launches can provision a managed local MCP server named `ccs-browser`. +That path is designed for workflows where you want Claude to interact with a browser session +that already has useful authenticated state. + +Claude Browser Attach requires a browser launched in attach mode with remote debugging +enabled. A recent Chrome update alone is not sufficient. + +### Codex Browser Tools + +Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides +for the `ccs_browser` runtime config entry. + +This is configured from the same Browser settings surface, but it is distinct from Claude +Browser Attach. + +## Configuration + +### Via Dashboard + +Open `ccs config` -> `Settings` -> `Browser`. + +The Browser screen exposes two sections: + +- **Claude Browser Attach** + - enable/disable the Claude attach lane + - choose the Chrome user-data directory + - set the expected DevTools port + - review readiness and next-step guidance + - copy a generated browser launch command +- **Codex Browser Tools** + - enable/disable CCS-managed browser tooling for Codex-target launches + - review whether the detected Codex build supports managed browser overrides + +### Via CLI + +```bash +ccs help browser +ccs browser status +ccs browser doctor +``` + +Use `ccs browser status` for the current state and `ccs browser doctor` for actionable +troubleshooting guidance. + +### Via Config File + +Edit `~/.ccs/config.yaml`: + +```yaml +browser: + claude: + enabled: false + user_data_dir: "~/.ccs/browser/chrome-user-data" + devtools_port: 9222 + codex: + enabled: true +``` + +Notes: + +- `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 + +## Environment Variable Overrides + +CCS still supports environment-variable overrides for backward compatibility. + +| Variable | Description | +|----------|-------------| +| `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir | +| `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory | +| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override | + +If an override is active, Browser status surfaces should report that the current session is being +managed externally by environment variables. + +## Managed Runtime Files + +- `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach +- `~/.ccs/mcp/ccs-browser-server.cjs` -> local Claude Browser Attach MCP runtime +- `Codex runtime config overrides` -> CCS manages the `ccs_browser` MCP entry for Codex-target launches + +Do not treat the generic Codex MCP editor as the primary browser setup path. CCS-managed browser +entries should be configured from `Settings -> Browser`. + +## Launching Chrome For Claude Attach + +Claude Browser Attach needs a browser launched with remote debugging. + +Typical examples: + +```bash +# macOS +open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data" + +# Linux +google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data" + +# Windows +chrome.exe --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.ccs\\browser\\chrome-user-data" +``` + +Using a dedicated CCS browser data dir is recommended. It avoids profile-locking issues and keeps +automation state separate from your daily browser profile. + +## Troubleshooting + +### 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`. + +### 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` +3. Rerun `ccs browser doctor` + +### Browser status says no running browser session was found + +CCS could not find usable DevTools attach metadata for the configured user-data directory. + +1. Make sure Chrome was started with `--remote-debugging-port=` +2. Make sure it is using the same `user_data_dir` configured in CCS +3. Rerun `ccs browser doctor` + +### Browser status says the DevTools endpoint is unreachable + +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` + +### Codex Browser Tools are unavailable + +Codex browser tooling depends on a Codex build that supports `--config` overrides. + +If CCS reports `unsupported_build`, upgrade Codex and rerun `ccs browser status`. + +## Security Notes + +- Browser automation may operate inside authenticated browser sessions +- Prefer a dedicated automation user-data dir instead of your everyday browser profile +- Do not commit browser paths, secrets, or generated session state to version control +- Treat `~/.ccs/config.yaml`, `~/.claude.json`, and the browser user-data directory as local machine state diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 2cdfda56..56d8394b 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **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. - **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell. - **2026-04-14**: **#991** CCS now auto-routes Claude-target settings profiles that use OpenAI-compatible endpoints through a local Anthropic-compatible proxy instead of sending raw Anthropic `/v1/messages` traffic directly to chat-completions backends. The `ccs proxy` command now supports `start`, `status`, `activate`, and `stop` with explicit host binding, shell-aware activation helpers, and a fuller local runtime env contract. The proxy surface now exposes `GET /`, `/health`, `/v1/models`, and `/v1/messages`, logs routing decisions into CCS structured logs, supports Anthropic image blocks plus request-time `profile:model` overrides, and adds config-driven scenario routing (`background`, `think`, `longContext`, `webSearch`) on top of the compatible-profile path. Coverage now includes request routing, rate-limit/timeout/empty-upstream failures, chunked tool-call streaming, and disconnect cleanup alongside the existing unit, integration, and e2e suites. diff --git a/src/ccs.ts b/src/ccs.ts index 05db124b..3cedddbb 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -39,11 +39,15 @@ import { import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, + getEffectiveClaudeBrowserAttachConfig, resolveBrowserRuntimeEnv, - resolveConfiguredBrowserProfileDir, syncBrowserMcpToConfigDir, } from './utils/browser'; -import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; +import { + getBrowserConfig, + getGlobalEnvConfig, + getOfficialChannelsConfig, +} from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks, removeImageAnalysisProfileHook, @@ -135,7 +139,7 @@ const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v function resolveCodexRuntimeConfigOverrides( target: ReturnType ): string[] { - if (target !== 'codex') { + if (target !== 'codex' || !getBrowserConfig().codex.enabled) { return []; } return buildCodexBrowserMcpOverrides(); @@ -1054,13 +1058,13 @@ async function main(): Promise { const imageAnalysisMcpReady = resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; - const browserProfileDir = + const browserAttachConfig = resolvedTarget === 'claude' - ? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR) + ? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) : undefined; if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); - if (browserProfileDir) { + if (browserAttachConfig?.enabled) { ensureBrowserMcpOrThrow(); } } @@ -1087,7 +1091,7 @@ async function main(): Promise { syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); if ( - browserProfileDir && + browserAttachConfig?.enabled && inheritedClaudeConfigDir && !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) ) { @@ -1297,10 +1301,13 @@ async function main(): Promise { // Explicitly inject effective settings env vars so stale ANTHROPIC_* // values from prior sessions cannot leak into the active profile. - if (browserProfileDir) { + if (browserAttachConfig?.enabled) { browserRuntimeEnv = { ...(await resolveBrowserRuntimeEnv({ - profileDir: browserProfileDir, + profileDir: browserAttachConfig.userDataDir, + devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort + ? String(browserAttachConfig.devtoolsPort) + : undefined, })), }; } @@ -1465,17 +1472,20 @@ async function main(): Promise { CCS_IMAGE_ANALYSIS_SKIP: '1', }; let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; - const browserProfileDir = + const browserAttachConfig = resolvedTarget === 'claude' - ? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR) + ? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) : undefined; if (resolvedTarget === 'claude') { - if (browserProfileDir) { + if (browserAttachConfig?.enabled) { ensureBrowserMcpOrThrow(); browserRuntimeEnv = { ...(await resolveBrowserRuntimeEnv({ - profileDir: browserProfileDir, + profileDir: browserAttachConfig.userDataDir, + devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort + ? String(browserAttachConfig.devtoolsPort) + : undefined, })), }; Object.assign(envVars, browserRuntimeEnv); @@ -1495,7 +1505,7 @@ async function main(): Promise { if (defaultContinuityInheritance.claudeConfigDir) { envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; if ( - browserProfileDir && + browserAttachConfig?.enabled && !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) ) { throw new Error( diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 4ad89052..f3d9f1a9 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -66,11 +66,15 @@ import { import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, + getEffectiveClaudeBrowserAttachConfig, resolveBrowserRuntimeEnv, - resolveConfiguredBrowserProfileDir, syncBrowserMcpToConfigDir, } from '../../utils/browser'; -import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader'; +import { + getBrowserConfig, + loadOrCreateUnifiedConfig, + getThinkingConfig, +} from '../../config/unified-config-loader'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { isKiroAuthMethod, @@ -260,8 +264,8 @@ export async function execClaudeWithCLIProxy( // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); - const browserProfileDir = resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR); - if (browserProfileDir) { + const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); + if (browserAttachConfig.enabled) { ensureBrowserMcpOrThrow(); } displayWebSearchStatus(); @@ -1050,7 +1054,7 @@ export async function execClaudeWithCLIProxy( syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); if ( - browserProfileDir && + browserAttachConfig.enabled && inheritedClaudeConfigDir && !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) ) { @@ -1153,10 +1157,13 @@ export async function execClaudeWithCLIProxy( } // 11. Build final environment with all proxy chains - const browserRuntimeEnv = browserProfileDir + const browserRuntimeEnv = browserAttachConfig.enabled ? { ...(await resolveBrowserRuntimeEnv({ - profileDir: browserProfileDir, + profileDir: browserAttachConfig.userDataDir, + devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort + ? String(browserAttachConfig.devtoolsPort) + : undefined, })), } : undefined; diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts new file mode 100644 index 00000000..d42a0654 --- /dev/null +++ b/src/commands/browser-command.ts @@ -0,0 +1,155 @@ +import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser'; +import { color, dim, header, initUI, subheader } from '../utils/ui'; + +type HelpWriter = (line: string) => void; + +function currentPlatform(): 'darwin' | 'linux' | 'win32' { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; +} + +function summarizeBrowserHealth(status: BrowserStatusPayload): { + label: 'ready' | 'partial' | 'action required'; + exitCode: 0 | 1; +} { + const claudeNeedsAttention = status.claude.enabled && status.claude.state !== 'ready'; + if (claudeNeedsAttention) { + return { label: 'action required', exitCode: 1 }; + } + + if (status.codex.enabled && status.codex.state !== 'enabled') { + return { label: 'partial', exitCode: 0 }; + } + + return { label: 'ready', exitCode: 0 }; +} + +function writeCommandTable(writeLine: HelpWriter): void { + writeLine(subheader('Commands')); + 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(''); +} + +function writeIntro(writeLine: HelpWriter): void { + writeLine(' Claude Browser Attach reuses a local Chrome session for Claude-target launches.'); + writeLine( + ' Codex Browser Tools inject managed Playwright MCP overrides into Codex-target launches.' + ); + writeLine(''); +} + +function writeClaudeStatus( + status: BrowserStatusPayload['claude'], + writeLine: HelpWriter, + includeLaunchGuidance: boolean +): void { + 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(` DevTools port: ${status.devtoolsPort}`); + writeLine(` Managed MCP: ${status.managedMcpServerName}`); + writeLine(` Managed path: ${status.managedMcpServerPath}`); + if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) { + writeLine(` DevTools endpoint: ${status.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL}`); + } + writeLine(` Detail: ${status.detail}`); + writeLine(` Next step: ${status.nextStep}`); + if (includeLaunchGuidance && status.enabled && status.state !== 'ready') { + writeLine( + ` Launch command (${currentPlatform()}): ${status.launchCommands[currentPlatform()]}` + ); + } + writeLine(''); +} + +function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: HelpWriter): void { + writeLine(subheader('Codex Browser Tools')); + writeLine(` State: ${status.state}`); + writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`); + writeLine(` Managed server: ${status.serverName}`); + writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`); + writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`); + if (status.version) { + writeLine(` Codex version: ${status.version}`); + } + writeLine(` Detail: ${status.detail}`); + writeLine(` Next step: ${status.nextStep}`); + writeLine(''); +} + +export async function showBrowserHelp(writeLine: HelpWriter = console.log): Promise { + await initUI(); + writeLine(header('CCS Browser Help')); + writeLine(''); + writeIntro(writeLine); + writeLine(subheader('Usage')); + writeLine(` ${color('ccs browser ', 'command')}`); + writeLine(` ${color('ccs help browser', 'command')}`); + writeLine(''); + writeCommandTable(writeLine); + writeLine(subheader('What Each Lane Does')); + writeLine(' Claude Browser Attach expects a Chrome user-data dir and remote debugging port.'); + 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 doctor', 'command')} ${dim('# Detailed troubleshooting output')}` + ); + writeLine( + ` ${color('ccs config', 'command')} ${dim('# Open Settings > Browser in the dashboard')}` + ); + writeLine(''); +} + +export async function handleBrowserCommand( + args: string[], + writeLine: HelpWriter = console.log +): Promise { + const subcommand = args[0]; + if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') { + await showBrowserHelp(writeLine); + return; + } + + if (subcommand !== 'status' && subcommand !== 'doctor') { + await initUI(); + writeLine(color(`Unknown browser subcommand: ${subcommand}`, 'error')); + writeLine(''); + writeLine(` ${dim('Supported subcommands: status, doctor')}`); + writeLine(''); + process.exitCode = 1; + return; + } + + await initUI(); + const status = await getBrowserStatus(); + + writeLine(header(`ccs browser ${subcommand}`)); + writeLine(''); + writeIntro(writeLine); + + if (subcommand === 'doctor') { + const summary = summarizeBrowserHealth(status); + writeLine(subheader('Overall')); + writeLine(` Claude Browser Attach: ${status.claude.title}`); + writeLine(` Codex Browser Tools: ${status.codex.title}`); + writeLine(` Result: ${summary.label}`); + writeLine(''); + } + + writeClaudeStatus(status.claude, writeLine, subcommand === 'doctor'); + writeCodexStatus(status.codex, writeLine); + + if (subcommand === 'doctor') { + process.exitCode = summarizeBrowserHealth(status).exitCode; + } +} diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 6b6b01d6..5b37e223 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -1,7 +1,13 @@ import { COPILOT_SUBCOMMANDS } from '../copilot/constants'; import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; -export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets'; +export type HelpTopicName = + | 'profiles' + | 'providers' + | 'kiro' + | 'browser' + | 'completion' + | 'targets'; export interface HelpTopicEntry { name: HelpTopicName; @@ -25,6 +31,7 @@ export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [ { name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' }, { name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' }, { name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' }, + { name: 'browser', summary: 'Claude Browser Attach and Codex Browser Tools guidance' }, { name: 'completion', summary: 'Shell completion install, refresh, and testing' }, { name: 'targets', summary: 'Claude, Droid, and Codex target routing' }, ] as const; @@ -114,6 +121,12 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [ group: 'runtime', visibility: 'public', }, + { + name: 'browser', + summary: 'Inspect Claude Browser Attach and Codex Browser Tools readiness', + group: 'runtime', + visibility: 'public', + }, { name: 'copilot', summary: 'Run or manage the GitHub Copilot bridge', @@ -307,6 +320,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly (await import('./cleanup-command')).handleCleanupCommand(['--help']), + browser: async () => (await import('./browser-command')).showBrowserHelp(writeLine), cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(), copilot: async () => process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])), diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts index 250c5910..6d09848f 100644 --- a/src/commands/root-command-router.ts +++ b/src/commands/root-command-router.ts @@ -105,6 +105,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ await handleSyncCommand(); }, }, + { + name: 'browser', + handle: async (args) => { + const { handleBrowserCommand } = await import('./browser-command'); + await handleBrowserCommand(args); + }, + }, { name: 'cleanup', aliases: ['--cleanup'], diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 5cd1254e..50835435 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -23,6 +23,7 @@ import { DEFAULT_THINKING_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, + DEFAULT_BROWSER_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, DEFAULT_LOGGING_CONFIG, } from './unified-config-types'; @@ -34,6 +35,7 @@ import type { OfficialChannelsConfig, OfficialChannelId, DashboardAuthConfig, + BrowserConfig, ImageAnalysisConfig, LoggingConfig, CursorConfig, @@ -46,6 +48,7 @@ import { normalizeOfficialChannelIds, resolveLegacyDiscordSelection, } from '../channels/official-channels-runtime'; +import { getRecommendedBrowserUserDataDir } from '../utils/browser/browser-settings'; import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver'; import { normalizeSearxngBaseUrl } from '../utils/websearch/types'; @@ -54,6 +57,32 @@ const CONFIG_JSON = 'config.json'; const CONFIG_LOCK = 'config.yaml.lock'; const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds +function normalizeBrowserDevtoolsPort(value: number | undefined): number { + if (!Number.isFinite(value)) { + return DEFAULT_BROWSER_CONFIG.claude.devtools_port; + } + + const port = Math.floor(value as number); + if (port < 1 || port > 65535) { + return DEFAULT_BROWSER_CONFIG.claude.devtools_port; + } + + return port; +} + +function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig { + return { + claude: { + enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled, + 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, + }, + }; +} + /** * Get path to unified config.yaml */ @@ -592,6 +621,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { partial.dashboard_auth?.session_timeout_hours ?? DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours, }, + browser: canonicalizeBrowserConfig(partial.browser), // Image analysis config - enabled by default for CLIProxy providers image_analysis: canonicalizeImageAnalysisConfig({ enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, @@ -916,6 +946,23 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Browser automation section + if (config.browser) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Browser Automation: Claude browser attach and Codex browser tooling'); + lines.push('# Claude attach reuses a running Chrome/Chromium session with remote debugging.'); + lines.push('# Codex tooling controls whether CCS injects Playwright MCP overrides.'); + lines.push('#'); + lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the'); + lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.'); + lines.push('# Configure via: Settings > Browser or `ccs browser ...`.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ browser: config.browser }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + // Image analysis section if (config.image_analysis) { lines.push('# ----------------------------------------------------------------------------'); @@ -1334,6 +1381,15 @@ export function getDashboardAuthConfig(): DashboardAuthConfig { }; } +/** + * Get browser automation configuration. + * Returns canonicalized defaults if not configured. + */ +export function getBrowserConfig(): BrowserConfig { + const config = loadOrCreateUnifiedConfig(); + return canonicalizeBrowserConfig(config.browser); +} + /** * Get image_analysis configuration. * Returns defaults if not configured. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index afb7006c..1b8b67f4 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -812,6 +812,40 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { session_timeout_hours: 24, }; +/** + * Browser automation configuration. + * Controls Claude browser attach and Codex browser tooling. + */ +export interface BrowserClaudeConfig { + /** Enable Claude browser attach (default: false) */ + enabled: boolean; + /** Chrome user-data directory used for attach mode */ + user_data_dir: string; + /** DevTools port used for attach mode (default: 9222) */ + devtools_port: number; +} + +export interface BrowserCodexConfig { + /** Enable Codex browser tooling injection (default: true) */ + enabled: boolean; +} + +export interface BrowserConfig { + claude: BrowserClaudeConfig; + codex: BrowserCodexConfig; +} + +export const DEFAULT_BROWSER_CONFIG: BrowserConfig = { + claude: { + enabled: false, + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, +}; + /** * Image analysis configuration. * Routes image/PDF files through CLIProxy for vision analysis. @@ -895,6 +929,8 @@ export interface UnifiedConfig { channels?: OfficialChannelsConfig; /** Dashboard authentication configuration (optional) */ dashboard_auth?: DashboardAuthConfig; + /** Browser automation configuration */ + browser?: BrowserConfig; /** Image analysis configuration (vision via CLIProxy) */ image_analysis?: ImageAnalysisConfig; } @@ -1041,6 +1077,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { thinking: { ...DEFAULT_THINKING_CONFIG }, channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG }, dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG }, + browser: { + claude: { ...DEFAULT_BROWSER_CONFIG.claude }, + codex: { ...DEFAULT_BROWSER_CONFIG.codex }, + }, image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }, }; } diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts new file mode 100644 index 00000000..2e0691e2 --- /dev/null +++ b/src/utils/browser/browser-settings.ts @@ -0,0 +1,100 @@ +import * as path from 'path'; +import type { BrowserConfig } from '../../config/unified-config-types'; +import { getCcsDir } from '../config-manager'; +import { expandPath } from '../helpers'; + +export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; + +export interface EffectiveClaudeBrowserAttachConfig { + enabled: boolean; + source: 'config' | BrowserOverrideSource; + overrideActive: boolean; + userDataDir: string; + devtoolsPort: number; + hasExplicitDevtoolsPort: 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 getBrowserAttachOverride(env: NodeJS.ProcessEnv = process.env): { + userDataDir?: string; + devtoolsPort?: number; + source?: BrowserOverrideSource; +} { + const explicitUserDataDir = resolveBrowserUserDataDir(env.CCS_BROWSER_USER_DATA_DIR); + if (explicitUserDataDir) { + return { + userDataDir: explicitUserDataDir, + devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT), + source: 'CCS_BROWSER_USER_DATA_DIR', + }; + } + + const legacyProfileDir = resolveBrowserUserDataDir(env.CCS_BROWSER_PROFILE_DIR); + if (legacyProfileDir) { + return { + userDataDir: legacyProfileDir, + devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT), + source: 'CCS_BROWSER_PROFILE_DIR', + }; + } + + return {}; +} + +export function getEffectiveClaudeBrowserAttachConfig( + config: BrowserConfig, + env: NodeJS.ProcessEnv = process.env +): EffectiveClaudeBrowserAttachConfig { + const override = getBrowserAttachOverride(env); + const configUserDataDir = + resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir(); + const configPort = normalizeDevtoolsPort(config.claude.devtools_port); + + if (override.userDataDir) { + return { + enabled: true, + source: override.source as BrowserOverrideSource, + overrideActive: true, + userDataDir: override.userDataDir, + devtoolsPort: override.devtoolsPort ?? configPort, + hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, + }; + } + + return { + enabled: config.claude.enabled, + source: 'config', + overrideActive: false, + userDataDir: configUserDataDir, + devtoolsPort: configPort, + hasExplicitDevtoolsPort: true, + }; +} + +function parseDevtoolsPort(value?: string): number | undefined { + if (!value?.trim() || !/^\d+$/.test(value.trim())) { + return undefined; + } + + return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10)); +} + +function normalizeDevtoolsPort(value: number | undefined): number { + if (!Number.isFinite(value)) { + return 9222; + } + + const port = Math.floor(value as number); + if (port < 1 || port > 65535) { + return 9222; + } + + return port; +} diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts new file mode 100644 index 00000000..4af83b20 --- /dev/null +++ b/src/utils/browser/browser-status.ts @@ -0,0 +1,193 @@ +import { getBrowserConfig } from '../../config/unified-config-loader'; +import { getCodexBinaryInfo } from '../../targets/codex-detector'; +import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; +import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer'; +import { + 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'; + overrideActive: boolean; + state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready'; + title: string; + detail: string; + nextStep: string; + effectiveUserDataDir: string; + recommendedUserDataDir: string; + devtoolsPort: number; + managedMcpServerName: string; + managedMcpServerPath: string; + launchCommands: BrowserLaunchCommands; + runtimeEnv?: BrowserRuntimeEnv; +} + +export interface CodexBrowserStatus { + enabled: boolean; + state: 'disabled' | 'enabled' | 'unsupported_build'; + title: string; + detail: string; + nextStep: string; + serverName: string; + supportsConfigOverrides: boolean; + binaryPath: string | null; + version?: string; +} + +export interface BrowserStatusPayload { + claude: ClaudeBrowserStatus; + codex: CodexBrowserStatus; +} + +export async function getBrowserStatus(): Promise { + const browserConfig = getBrowserConfig(); + return { + claude: await buildClaudeBrowserStatus(browserConfig), + codex: buildCodexBrowserStatus(browserConfig), + }; +} + +async function buildClaudeBrowserStatus( + browserConfig = getBrowserConfig() +): Promise { + const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig); + const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort); + const base: Omit = { + enabled: effective.enabled, + source: effective.source, + overrideActive: effective.overrideActive, + effectiveUserDataDir: effective.userDataDir, + recommendedUserDataDir: getRecommendedBrowserUserDataDir(), + devtoolsPort: effective.devtoolsPort, + managedMcpServerName: getBrowserMcpServerName(), + managedMcpServerPath: getBrowserMcpServerPath(), + launchCommands, + }; + + if (!effective.enabled) { + return { + ...base, + state: 'disabled', + 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`.', + }; + } + + try { + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir: effective.userDataDir, + devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined, + }); + + return { + ...base, + state: 'ready', + title: 'Claude Browser Attach is ready.', + detail: + 'CCS can reach the configured Chrome DevTools endpoint for the current attach session.', + nextStep: 'Launch a Claude-target CCS session to use the managed browser MCP runtime.', + runtimeEnv, + }; + } catch (error) { + const message = (error as Error).message; + if (message.includes('Chrome profile directory is invalid')) { + return { + ...base, + state: 'path_missing', + title: 'Claude Browser Attach path is missing.', + detail: message, + nextStep: `Create or choose a Chrome user-data directory, then launch Chrome with attach mode enabled. Example: ${launchCommands[platformKey()]}`, + }; + } + + if (message.includes('Chrome reuse metadata')) { + return { + ...base, + state: 'browser_not_running', + title: 'Claude Browser Attach could not find a running browser session.', + detail: message, + nextStep: `Start Chrome with remote debugging and the configured user-data dir. Example: ${launchCommands[platformKey()]}`, + }; + } + + return { + ...base, + state: 'endpoint_unreachable', + title: 'Claude Browser Attach could not reach the DevTools endpoint.', + detail: message, + nextStep: `Restart the attach browser session or confirm the configured port. Example: ${launchCommands[platformKey()]}`, + }; + } +} + +function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrowserStatus { + if (!browserConfig.codex.enabled) { + return { + enabled: false, + state: 'disabled', + title: 'Codex Browser Tools are disabled.', + detail: 'CCS will not inject Playwright MCP browser tooling into Codex-target launches.', + nextStep: + 'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.', + serverName: 'ccs_browser', + supportsConfigOverrides: false, + binaryPath: null, + }; + } + + const binaryInfo = getCodexBinaryInfo({ includeVersion: true, includeFeatures: true }); + const supportsConfigOverrides = Boolean(binaryInfo?.features?.includes('config-overrides')); + if (!binaryInfo || !supportsConfigOverrides) { + return { + enabled: true, + state: 'unsupported_build', + title: 'Codex Browser Tools need a Codex build with --config override support.', + detail: binaryInfo + ? `Detected Codex at ${binaryInfo.path}, but it does not advertise --config overrides.` + : 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', + nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', + serverName: 'ccs_browser', + supportsConfigOverrides, + binaryPath: binaryInfo?.path ?? null, + version: binaryInfo?.version, + }; + } + + return { + enabled: true, + state: 'enabled', + title: 'Codex Browser Tools are enabled.', + detail: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.', + nextStep: 'Use a Codex-target CCS launch to access browser tools.', + serverName: 'ccs_browser', + supportsConfigOverrides, + binaryPath: binaryInfo.path, + version: binaryInfo.version, + }; +} + +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}`, + }; +} + +function platformKey(): keyof BrowserLaunchCommands { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; +} diff --git a/src/utils/browser/index.ts b/src/utils/browser/index.ts index be30da8e..22f92251 100644 --- a/src/utils/browser/index.ts +++ b/src/utils/browser/index.ts @@ -17,9 +17,22 @@ export { export { appendBrowserToolArgs } from './claude-tool-args'; +export { + getRecommendedBrowserUserDataDir, + getBrowserAttachOverride, + getEffectiveClaudeBrowserAttachConfig, +} from './browser-settings'; + export { resolveBrowserRuntimeEnv, resolveDefaultChromeUserDataDir, resolveConfiguredBrowserProfileDir, } from './chrome-reuse'; export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse'; + +export { getBrowserStatus } from './browser-status'; +export type { + BrowserStatusPayload, + ClaudeBrowserStatus, + CodexBrowserStatus, +} from './browser-status'; diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts new file mode 100644 index 00000000..2701339f --- /dev/null +++ b/src/web-server/routes/browser-routes.ts @@ -0,0 +1,136 @@ +import { Router, type Request, type Response } from 'express'; +import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { getBrowserStatus } from '../../utils/browser'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; + +const router = Router(); +const BROWSER_LOCAL_ACCESS_ERROR = + 'Browser endpoints require localhost access when dashboard auth is disabled.'; + +interface BrowserRouteBody { + claude?: { + enabled?: boolean; + userDataDir?: string; + devtoolsPort?: number; + }; + codex?: { + enabled?: boolean; + }; +} + +function isValidDevtoolsPort(value: number): boolean { + return Number.isInteger(value) && value >= 1 && value <= 65535; +} + +router.use((req: Request, res: Response, next) => { + if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) { + next(); + } +}); + +router.get('/', async (_req: Request, res: Response): Promise => { + try { + const config = getBrowserConfig(); + const status = await getBrowserStatus(); + res.json({ + config: toBrowserRouteConfig(config), + status, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.get('/status', async (_req: Request, res: Response): Promise => { + try { + res.json(await getBrowserStatus()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.put('/', async (req: Request, res: Response): Promise => { + if ( + req.body === null || + req.body === undefined || + typeof req.body !== 'object' || + Array.isArray(req.body) + ) { + res.status(400).json({ error: 'Invalid request body. Must be an object.' }); + return; + } + + const { claude, codex } = req.body as BrowserRouteBody; + if (claude && (typeof claude !== 'object' || Array.isArray(claude))) { + res.status(400).json({ error: 'Invalid value for claude. Must be an object.' }); + return; + } + if (codex && (typeof codex !== 'object' || Array.isArray(codex))) { + res.status(400).json({ error: 'Invalid value for codex. Must be an object.' }); + return; + } + if (claude?.enabled !== undefined && typeof claude.enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for claude.enabled. Must be a boolean.' }); + return; + } + if (claude?.userDataDir !== undefined && typeof claude.userDataDir !== 'string') { + res.status(400).json({ error: 'Invalid value for claude.userDataDir. Must be a string.' }); + return; + } + if ( + claude?.devtoolsPort !== undefined && + (typeof claude.devtoolsPort !== 'number' || !isValidDevtoolsPort(claude.devtoolsPort)) + ) { + res.status(400).json({ + error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.', + }); + return; + } + if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' }); + return; + } + + try { + const current = getBrowserConfig(); + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: claude?.enabled ?? current.claude.enabled, + user_data_dir: claude?.userDataDir?.trim() || current.claude.user_data_dir, + devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port, + }, + codex: { + enabled: codex?.enabled ?? current.codex.enabled, + }, + }; + }); + + const config = getBrowserConfig(); + const status = await getBrowserStatus(); + res.json({ + success: true, + browser: { + config: toBrowserRouteConfig(config), + status, + }, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +function toBrowserRouteConfig(config: ReturnType) { + return { + claude: { + enabled: config.claude.enabled, + userDataDir: config.claude.user_data_dir, + devtoolsPort: config.claude.devtools_port, + }, + codex: { + enabled: config.codex.enabled, + }, + }; +} + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 992ce80d..64b99ad0 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -19,6 +19,7 @@ import settingsRoutes from './settings-routes'; import channelsRoutes from './channels-routes'; import websearchRoutes from './websearch-routes'; import imageAnalysisRoutes from './image-analysis-routes'; +import browserRoutes from './browser-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxyRoutingRoutes from './cliproxy-routing-routes'; @@ -97,6 +98,7 @@ apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ==================== apiRoutes.use('/websearch', websearchRoutes); +apiRoutes.use('/browser', browserRoutes); apiRoutes.use('/image-analysis', imageAnalysisRoutes); // ==================== Copilot ==================== diff --git a/src/web-server/services/compatible-cli-docs-registry.ts b/src/web-server/services/compatible-cli-docs-registry.ts index 5cba7137..cfa467e6 100644 --- a/src/web-server/services/compatible-cli-docs-registry.ts +++ b/src/web-server/services/compatible-cli-docs-registry.ts @@ -114,6 +114,7 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record] overlay on top of base config', 'CCS-backed Codex launches may apply transient -c overrides and CCS_CODEX_API_KEY', 'Official docs treat model_providers, mcp_servers, features, and project trust as schema-backed config surfaces', + 'CCS-managed browser tooling for Codex should be configured from Settings > Browser, not by editing the ccs_browser MCP entry directly', ], links: [ { diff --git a/tests/unit/commands/browser-command.test.ts b/tests/unit/commands/browser-command.test.ts new file mode 100644 index 00000000..c7c55412 --- /dev/null +++ b/tests/unit/commands/browser-command.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, test, spyOn } from 'bun:test'; + +import * as browserUtils from '../../../src/utils/browser'; +import { handleBrowserCommand } from '../../../src/commands/browser-command'; + +function stripAnsi(input: string): string { + return input.replace(/\u001b\[[0-9;]*m/g, ''); +} + +async function renderLines(args: string[]): Promise { + const lines: string[] = []; + await handleBrowserCommand(args, (line) => lines.push(line)); + return stripAnsi(lines.join('\n')); +} + +function currentPlatform(): 'darwin' | 'linux' | 'win32' { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; +} + +describe('browser command', () => { + afterEach(() => { + process.exitCode = 0; + }); + + test('status renders both browser lanes from the shared status payload', async () => { + const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ + 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', + }, + }); + + try { + const rendered = await renderLines(['status']); + + expect(rendered.includes('ccs browser status')).toBe(true); + expect(rendered.includes('Claude Browser Attach reuses a local Chrome session')).toBe(true); + expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe( + true + ); + expect(rendered.includes('Managed MCP: ccs-browser')).toBe(true); + expect(rendered.includes('Managed server: ccs_browser')).toBe(true); + expect(rendered.includes('DevTools endpoint: http://127.0.0.1:9222')).toBe(true); + } finally { + statusSpy.mockRestore(); + } + }); + + test('doctor prints env override context and launch guidance when Claude attach is not ready', async () => { + const launchCommands = { + darwin: 'open -na "Google Chrome" --args --remote-debugging-port=9444', + linux: + 'google-chrome --remote-debugging-port=9444 --user-data-dir="/tmp/browser-profile"', + win32: 'chrome.exe --remote-debugging-port=9444 --user-data-dir="/tmp/browser-profile"', + }; + const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ + claude: { + enabled: true, + source: 'CCS_BROWSER_PROFILE_DIR', + overrideActive: true, + state: 'browser_not_running', + title: 'Claude Browser Attach could not find a running browser session.', + detail: 'Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort', + nextStep: 'Start Chrome with remote debugging.', + effectiveUserDataDir: '/tmp/browser-profile', + recommendedUserDataDir: '/tmp/browser-profile', + devtoolsPort: 9444, + managedMcpServerName: 'ccs-browser', + managedMcpServerPath: '/tmp/ccs-browser-server.cjs', + launchCommands, + }, + codex: { + enabled: true, + 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.', + nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', + serverName: 'ccs_browser', + supportsConfigOverrides: false, + binaryPath: '/usr/local/bin/codex', + version: 'codex-cli 0.70.0', + }, + }); + + try { + const rendered = await renderLines(['doctor']); + + expect(rendered.includes('Result: action required')).toBe(true); + expect(rendered.includes('Source: CCS_BROWSER_PROFILE_DIR (env override active)')).toBe( + true + ); + expect( + rendered.includes( + `Launch command (${currentPlatform()}): ${launchCommands[currentPlatform()]}` + ) + ).toBe(true); + expect(rendered.includes('Detected Codex at /usr/local/bin/codex')).toBe(true); + expect(process.exitCode).toBe(1); + } finally { + statusSpy.mockRestore(); + } + }); + + test('doctor stays ready on Claude-only machines when Codex is not installed', async () => { + const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ + claude: { + enabled: false, + source: 'config', + overrideActive: false, + state: 'disabled', + 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`.', + 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 --remote-debugging-port=9222', + linux: + 'google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + win32: + 'chrome.exe --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + }, + }, + codex: { + enabled: true, + 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.', + nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', + serverName: 'ccs_browser', + supportsConfigOverrides: false, + binaryPath: null, + }, + }); + + try { + const rendered = await renderLines(['doctor']); + + expect(rendered.includes('Result: partial')).toBe(true); + expect(rendered.includes('run `ccs browser enable`')).toBe(false); + expect(process.exitCode).toBe(0); + } finally { + statusSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index d8563c49..2a493d4c 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -25,6 +25,7 @@ describe('help command parity', () => { expect(visibleLines.length).toBeLessThanOrEqual(90); expect(rendered.includes('ccs help ')).toBe(true); + expect(rendered.includes('ccs help browser')).toBe(true); expect(rendered.includes('ccs help completion')).toBe(true); }); @@ -69,6 +70,18 @@ describe('help command parity', () => { expect(rendered.includes('GitHub OAuth is dashboard-only')).toBe(true); }); + test('browser topic explains Claude attach versus Codex browser tools', async () => { + const rendered = await renderLines((writeLine) => handleHelpRoute(['browser'], writeLine)); + + expect(rendered.includes('CCS Browser Help')).toBe(true); + expect(rendered.includes('Claude Browser Attach reuses a local Chrome session')).toBe(true); + expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe( + true + ); + expect(rendered.includes('ccs browser status')).toBe(true); + expect(rendered.includes('ccs browser doctor')).toBe(true); + }); + test('completion topic documents install and verification paths', async () => { const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine)); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 1afdb956..bfdb1212 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -3,6 +3,7 @@ import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; interface RunResult { status: number | null; @@ -193,6 +194,47 @@ process.exit(0); ]); }); + it('skips Codex browser MCP overrides when browser tooling is disabled in config', () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: false, + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: false, + }, + }; + }); + + 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('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => { if (process.platform === 'win32') return; diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index 2ee938a3..93dd4552 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -4,6 +4,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; @@ -245,4 +246,83 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); }); + + it('uses config-backed browser attach settings when env overrides are absent', async () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/config-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const port = await waitForMockDevtoolsPort(mockServerPortPath); + await waitForDevtoolsVersionEndpoint(port); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/config-target`, + 'utf8' + ); + + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: browserProfileDir, + devtools_port: Number.parseInt(port, 10), + }, + codex: { + enabled: true, + }, + }; + }); + + const result = runCcs(['default', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-target'); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); }); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index 61052620..b94846fc 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -3,6 +3,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; @@ -196,4 +197,89 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); }); + + it('uses config-backed browser attach settings for settings-profile launches', async () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/config-settings-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const startDeadline = Date.now() + 5000; + while (!fs.existsSync(mockServerPortPath)) { + if (Date.now() > startDeadline) { + throw new Error('Timed out waiting for mock DevTools server to start'); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/config-settings-target`, + 'utf8' + ); + + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: browserProfileDir, + devtools_port: Number.parseInt(port, 10), + }, + codex: { + enabled: true, + }, + }; + }); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-settings-target'); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); }); diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts new file mode 100644 index 00000000..1e047e5d --- /dev/null +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { 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 * as codexDetector from '../../../../src/targets/codex-detector'; + +describe('browser status', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalBrowserUserDataDir: string | undefined; + let originalBrowserProfileDir: string | undefined; + let originalBrowserDevtoolsPort: string | undefined; + + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-status-')); + originalCcsHome = process.env.CCS_HOME; + originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + + process.env.CCS_HOME = tempHome; + delete process.env.CCS_BROWSER_USER_DATA_DIR; + delete process.env.CCS_BROWSER_PROFILE_DIR; + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalBrowserUserDataDir !== undefined) { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } else { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } + + if (originalBrowserProfileDir !== undefined) { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } else { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } + + if (originalBrowserDevtoolsPort !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } + + rmSync(tempHome, { recursive: true, force: true }); + }); + + it('returns a disabled Claude lane with the recommended managed user-data dir by default', async () => { + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(status.claude).toMatchObject({ + enabled: false, + state: 'disabled', + source: 'config', + effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtoolsPort: 9222, + managedMcpServerName: 'ccs-browser', + }); + expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222'); + expect(status.codex).toMatchObject({ + enabled: true, + state: 'enabled', + serverName: 'ccs_browser', + supportsConfigOverrides: true, + }); + } finally { + codexSpy.mockRestore(); + } + }); + + it('prefers CCS_BROWSER_USER_DATA_DIR over config when an env override is present', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '/config-browser', + devtools_port: 9333, + }, + codex: { + enabled: true, + }, + }; + }); + process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser'; + process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444'; + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ + CCS_BROWSER_USER_DATA_DIR: '/env-browser', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9444', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9444', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(status.claude).toMatchObject({ + enabled: true, + state: 'ready', + source: 'CCS_BROWSER_USER_DATA_DIR', + effectiveUserDataDir: '/env-browser', + devtoolsPort: 9444, + }); + expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('9444'); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); + + it('reports browser_not_running when attach metadata is missing', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '/tmp/browser-profile', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, + }; + }); + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue( + new Error('Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort') + ); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(status.claude.state).toBe('browser_not_running'); + expect(status.claude.detail).toContain('DevToolsActivePort'); + expect(status.claude.nextStep).toContain('--remote-debugging-port=9222'); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); + + it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => { + process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser'; + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ + CCS_BROWSER_USER_DATA_DIR: '/legacy-browser', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '50123', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:50123', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/legacy', + }); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ + profileDir: '/legacy-browser', + devtoolsPort: undefined, + }); + expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123'); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts new file mode 100644 index 00000000..17ed127b --- /dev/null +++ b/tests/unit/web-server/browser-routes.test.ts @@ -0,0 +1,171 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import { mkdtempSync, rmSync } from 'node:fs'; +import type { Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import browserRoutes from '../../../src/web-server/routes/browser-routes'; +import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader'; + +describe('browser routes', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalDashboardAuthEnabled: string | undefined; + let forcedRemoteAddress = '127.0.0.1'; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); + app.use('/api/browser', browserRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-')); + originalCcsHome = process.env.CCS_HOME; + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + process.env.CCS_HOME = tempHome; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '127.0.0.1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } + + rmSync(tempHome, { recursive: true, force: true }); + }); + + it('blocks remote access when dashboard auth is disabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + + const response = await fetch(`${baseUrl}/api/browser`); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Browser endpoints require localhost access when dashboard auth is disabled.', + }); + }); + + it('returns the default browser config and status payload', async () => { + const response = await fetch(`${baseUrl}/api/browser`); + expect(response.status).toBe(200); + const payload = await response.json(); + + expect(payload.config).toMatchObject({ + claude: { + enabled: false, + userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtoolsPort: 9222, + }, + codex: { + enabled: true, + }, + }); + expect(payload.status.claude).toMatchObject({ + state: 'disabled', + managedMcpServerName: 'ccs-browser', + }); + expect(payload.status.codex).toMatchObject({ + enabled: true, + serverName: 'ccs_browser', + }); + }); + + it('updates the saved browser config through the dashboard route', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + enabled: true, + userDataDir: '/tmp/ccs-browser', + devtoolsPort: 9333, + }, + codex: { + enabled: false, + }, + }), + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.browser.config).toMatchObject({ + claude: { + enabled: true, + userDataDir: '/tmp/ccs-browser', + devtoolsPort: 9333, + }, + codex: { + enabled: false, + }, + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.browser).toMatchObject({ + claude: { + enabled: true, + user_data_dir: '/tmp/ccs-browser', + devtools_port: 9333, + }, + codex: { + enabled: false, + }, + }); + }); + + it('rejects invalid DevTools ports at the route boundary', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + devtoolsPort: 0, + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.', + }); + }); +}); diff --git a/ui/src/components/compatible-cli/codex-docs-tab.tsx b/ui/src/components/compatible-cli/codex-docs-tab.tsx index 8c118e9f..8a88a652 100644 --- a/ui/src/components/compatible-cli/codex-docs-tab.tsx +++ b/ui/src/components/compatible-cli/codex-docs-tab.tsx @@ -139,6 +139,11 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) { Export CLIPROXY_API_KEY before launching native Codex. +

+ CCS-managed browser tooling belongs to Settings > Browser. Do not edit + the ccs_browser entry from the generic MCP card unless you are + intentionally overriding the managed path in raw TOML. +

diff --git a/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx b/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx index 3407a550..e1570ed3 100644 --- a/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx +++ b/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx @@ -36,6 +36,8 @@ const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = { toolTimeoutSec: null, enabledTools: [], disabledTools: [], + isCcsManaged: false, + managementSurface: null, }; function toCsv(value: string[]) { @@ -70,9 +72,16 @@ function McpServerEditor({ }: McpServerEditorProps) { const { t } = useTranslation(); const [draft, setDraft] = useState(initialDraft); + const reservedManagedBrowserDraft = isNew && draft.name.trim() === 'ccs_browser'; return ( <> + {reservedManagedBrowserDraft ? ( +
+ ccs_browser is reserved for the CCS-managed browser tooling path. + Configure it from Settings > Browser instead of creating it here. +
+ ) : null}
{saving ? : null} {/* TODO i18n: missing key codex.saveMcpServer */} @@ -244,6 +255,8 @@ export function CodexMcpServersCard({ ); const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT; const draftKey = JSON.stringify(draftSeed); + const selectedEntryIsManagedBrowser = + selectedEntry?.isCcsManaged && selectedEntry.managementSurface === 'browser-settings'; return ( + {selectedEntryIsManagedBrowser ? ( +
+ {selectedEntry?.name} is CCS-managed. Configure browser tooling from{' '} + Settings > Browser; the generic MCP editor is read-only for this entry. +
+ ) : null} + setDraft((current) => + updateClaudeDraft(current ?? effectiveConfig, { + userDataDir: event.target.value, + }) + ) + } + placeholder={status.claude.recommendedUserDataDir} + /> +

+ {t('settingsPage.browserSection.claude.userDataDirHint')} +

+
+ +
+ + setClaudePortDraft(event.target.value)} + inputMode="numeric" + /> +

+ {claudePortInvalid + ? t('settingsPage.browserSection.claude.devtoolsPortInvalid') + : t('settingsPage.browserSection.claude.devtoolsPortHint')} +

+
+ + +
+
+

+ {t('settingsPage.browserSection.readiness')} +

+

{status.claude.title}

+

{status.claude.detail}

+
+
+

+ {t('settingsPage.browserSection.nextStep')} +

+

{status.claude.nextStep}

+
+
+ +
+
+

+ {t('settingsPage.browserSection.claude.effectivePath')} +

+

+ {status.claude.effectiveUserDataDir} +

+

+ {t('settingsPage.browserSection.claude.recommendedPath')}:{' '} + {status.claude.recommendedUserDataDir} +

+
+
+

+ {t('settingsPage.browserSection.claude.managedRuntime')} +

+

{status.claude.managedMcpServerName}

+

+ {status.claude.managedMcpServerPath} +

+
+
+ + {status.claude.overrideActive ? ( + + + + {t('settingsPage.browserSection.claude.overrideMessage', { + source: status.claude.source, + })} + + + ) : null} + +
+
+
+

+ {t('settingsPage.browserSection.claude.launchGuidance')} +

+

+ {t('settingsPage.browserSection.claude.launchGuidanceHint')} +

+
+ +
+
+                  {preferredLaunchCommand}
+                
+ {status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL ? ( +

+ DevTools: {status.claude.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL} +

+ ) : null} +
+ +
+ + +
+ + + + + +
+
+ {t('settingsPage.browserSection.codex.title')} + + {t('settingsPage.browserSection.codex.description')} + +
+ + {stateLabel(status.codex.state)} + +
+
+ +
+
+ +

+ {t('settingsPage.browserSection.codex.enabledDescription')} +

+
+ + setDraft((current) => + updateCodexDraft(current ?? effectiveConfig, { enabled: next }) + ) + } + aria-label={t('settingsPage.browserSection.codex.enabledLabel')} + /> +
+ +
+
+

+ {t('settingsPage.browserSection.readiness')} +

+

{status.codex.title}

+

{status.codex.detail}

+
+
+

+ {t('settingsPage.browserSection.nextStep')} +

+

{status.codex.nextStep}

+
+
+ +
+
+

+ {t('settingsPage.browserSection.codex.serverName')} +

+

{status.codex.serverName}

+
+
+

+ {t('settingsPage.browserSection.codex.overrideSupport')} +

+

+ {status.codex.supportsConfigOverrides + ? t('settingsPage.browserSection.codex.overrideSupported') + : t('settingsPage.browserSection.codex.overrideUnsupported')} +

+
+
+

+ {t('settingsPage.browserSection.codex.binary')} +

+

+ {status.codex.binaryPath ?? t('settingsPage.browserSection.codex.notDetected')} +

+ {status.codex.version ? ( +

{status.codex.version}

+ ) : null} +
+
+ +
+ +
+
+
+ + + + ); +} diff --git a/ui/src/pages/settings/settings-context.ts b/ui/src/pages/settings/settings-context.ts index d0329ac8..0aca0404 100644 --- a/ui/src/pages/settings/settings-context.ts +++ b/ui/src/pages/settings/settings-context.ts @@ -5,6 +5,8 @@ import { createContext, type Dispatch } from 'react'; import type { + BrowserConfig, + BrowserStatus, WebSearchConfig, GlobalEnvConfig, CliproxyServerConfig, @@ -15,6 +17,14 @@ import type { // === State === export interface SettingsState { + // Browser state + browserConfig: BrowserConfig | null; + browserStatus: BrowserStatus | null; + browserLoading: boolean; + browserStatusLoading: boolean; + browserSaving: boolean; + browserError: string | null; + browserSuccess: boolean; // WebSearch state webSearchConfig: WebSearchConfig | null; webSearchStatus: WebSearchStatus | null; @@ -43,6 +53,13 @@ export interface SettingsState { } export const initialSettingsState: SettingsState = { + browserConfig: null, + browserStatus: null, + browserLoading: true, + browserStatusLoading: true, + browserSaving: false, + browserError: null, + browserSuccess: false, webSearchConfig: null, webSearchStatus: null, webSearchLoading: true, @@ -69,6 +86,13 @@ export const initialSettingsState: SettingsState = { // === Actions === export type SettingsAction = + | { type: 'SET_BROWSER_CONFIG'; payload: BrowserConfig | null } + | { type: 'SET_BROWSER_STATUS'; payload: BrowserStatus | null } + | { type: 'SET_BROWSER_LOADING'; payload: boolean } + | { type: 'SET_BROWSER_STATUS_LOADING'; payload: boolean } + | { type: 'SET_BROWSER_SAVING'; payload: boolean } + | { type: 'SET_BROWSER_ERROR'; payload: string | null } + | { type: 'SET_BROWSER_SUCCESS'; payload: boolean } | { type: 'SET_WEBSEARCH_CONFIG'; payload: WebSearchConfig | null } | { type: 'SET_WEBSEARCH_STATUS'; payload: WebSearchStatus | null } | { type: 'SET_WEBSEARCH_LOADING'; payload: boolean } @@ -93,6 +117,20 @@ export type SettingsAction = export function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState { switch (action.type) { + case 'SET_BROWSER_CONFIG': + return { ...state, browserConfig: action.payload }; + case 'SET_BROWSER_STATUS': + return { ...state, browserStatus: action.payload }; + case 'SET_BROWSER_LOADING': + return { ...state, browserLoading: action.payload }; + case 'SET_BROWSER_STATUS_LOADING': + return { ...state, browserStatusLoading: action.payload }; + case 'SET_BROWSER_SAVING': + return { ...state, browserSaving: action.payload }; + case 'SET_BROWSER_ERROR': + return { ...state, browserError: action.payload }; + case 'SET_BROWSER_SUCCESS': + return { ...state, browserSuccess: action.payload }; case 'SET_WEBSEARCH_CONFIG': return { ...state, webSearchConfig: action.payload }; case 'SET_WEBSEARCH_STATUS': diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 1cff3d33..a8765ee7 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -3,7 +3,13 @@ * Type definitions for WebSearch, GlobalEnv, and Proxy configurations */ -import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client'; +import type { + BrowserSettingsConfig, + BrowserStatusPayload, + CliproxyServerConfig, + RemoteProxyStatus, + UpdateBrowserSettingsPayload, +} from '@/lib/api-client'; // === WebSearch Types === @@ -162,6 +168,7 @@ export interface OfficialChannelsStatus { // === Tab Types === export type SettingsTab = + | 'browser' | 'websearch' | 'image' | 'channels' @@ -192,3 +199,6 @@ export interface ThinkingConfig { // === Re-exports from api-client === export type { CliproxyServerConfig, RemoteProxyStatus }; +export type BrowserConfig = BrowserSettingsConfig; +export type BrowserStatus = BrowserStatusPayload; +export type BrowserSavePayload = UpdateBrowserSettingsPayload; diff --git a/ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx b/ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx new file mode 100644 index 00000000..616fb160 --- /dev/null +++ b/ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; +import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card'; + +describe('CodexMcpServersCard', () => { + it('blocks creating the reserved ccs_browser entry from the generic MCP editor', async () => { + render(); + + const nameInput = screen.getByPlaceholderText('playwright'); + await userEvent.type(nameInput, 'ccs_browser'); + + expect( + screen.getAllByText( + (_, element) => + element?.textContent?.includes( + 'ccs_browser is reserved for the CCS-managed browser tooling path.' + ) ?? false + )[0] + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save MCP server' })).toBeDisabled(); + }); +}); diff --git a/ui/tests/unit/ui/lib/codex-config-browser.test.ts b/ui/tests/unit/ui/lib/codex-config-browser.test.ts new file mode 100644 index 00000000..6d851fc4 --- /dev/null +++ b/ui/tests/unit/ui/lib/codex-config-browser.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { readCodexMcpServers } from '@/lib/codex-config'; + +describe('readCodexMcpServers', () => { + it('marks the CCS browser MCP entry as managed by Browser settings', () => { + const entries = readCodexMcpServers({ + mcp_servers: { + ccs_browser: { + command: 'npx', + args: ['-y', '@playwright/mcp@0.0.70'], + enabled: true, + }, + playwright: { + command: 'npx', + args: ['-y', '@playwright/mcp@latest'], + enabled: true, + }, + }, + }); + + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'ccs_browser', + isCcsManaged: true, + managementSurface: 'browser-settings', + }), + expect.objectContaining({ + name: 'playwright', + isCcsManaged: false, + managementSurface: null, + }), + ]) + ); + }); +}); diff --git a/ui/tests/unit/ui/pages/browser-section.test.tsx b/ui/tests/unit/ui/pages/browser-section.test.tsx new file mode 100644 index 00000000..3e6ce379 --- /dev/null +++ b/ui/tests/unit/ui/pages/browser-section.test.tsx @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; + +const mocks = vi.hoisted(() => ({ + useBrowserConfig: vi.fn(), + useRawConfig: vi.fn(), + fetchConfig: vi.fn(), + fetchStatus: vi.fn(), + saveConfig: vi.fn(), + fetchRawConfig: vi.fn(), +})); + +vi.mock('@/pages/settings/hooks', async () => { + const actual = + await vi.importActual('@/pages/settings/hooks'); + return { + ...actual, + useBrowserConfig: mocks.useBrowserConfig, + useRawConfig: mocks.useRawConfig, + }; +}); + +import BrowserSection from '@/pages/settings/sections/browser'; + +describe('BrowserSection', () => { + beforeEach(() => { + mocks.fetchConfig.mockReset(); + mocks.fetchStatus.mockReset(); + mocks.saveConfig.mockReset(); + mocks.fetchRawConfig.mockReset(); + + mocks.useRawConfig.mockReturnValue({ + rawConfig: 'browser:\n claude:\n enabled: true\n', + loading: false, + copied: false, + fetchRawConfig: mocks.fetchRawConfig, + copyToClipboard: vi.fn(), + }); + + mocks.useBrowserConfig.mockReturnValue({ + config: { + claude: { + enabled: true, + userDataDir: '/tmp/browser-profile', + devtoolsPort: 9222, + }, + codex: { + enabled: true, + }, + }, + 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 --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + linux: + 'google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + win32: 'chrome.exe --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + }, + }, + codex: { + enabled: true, + state: 'enabled', + title: 'Codex Browser Tools are enabled.', + detail: 'CCS can inject 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', + }, + }, + loading: false, + statusLoading: false, + saving: false, + error: null, + success: false, + fetchConfig: mocks.fetchConfig, + fetchStatus: mocks.fetchStatus, + saveConfig: mocks.saveConfig, + }); + }); + + it('uses the current draft for launch guidance and disables testing until the draft is saved', async () => { + render(, { withSettingsProvider: true }); + + const pathInput = screen.getByLabelText('Chrome user-data directory'); + await userEvent.clear(pathInput); + await userEvent.type(pathInput, '/tmp/new-browser-profile'); + + const launchCommand = screen.getByText(/new-browser-profile/); + expect(launchCommand).toBeInTheDocument(); + + const testConnectionButton = screen.getByRole('button', { name: 'Test connection' }); + expect(testConnectionButton).toBeDisabled(); + }); +}); diff --git a/ui/tests/unit/ui/pages/settings/settings-page.test.tsx b/ui/tests/unit/ui/pages/settings/settings-page.test.tsx index 651a3adb..a8253db2 100644 --- a/ui/tests/unit/ui/pages/settings/settings-page.test.tsx +++ b/ui/tests/unit/ui/pages/settings/settings-page.test.tsx @@ -31,6 +31,10 @@ vi.mock('@/pages/settings/sections/websearch', () => ({ default: () =>
WebSearch Section
, })); +vi.mock('@/pages/settings/sections/browser', () => ({ + default: () =>
Browser Section
, +})); + vi.mock('@/pages/settings/sections/channels', () => ({ default: () =>
Channels Section
, })); @@ -70,6 +74,7 @@ describe('SettingsPage raw config panel', () => { }); it('keeps the current config editor visible while raw config refreshes', async () => { + window.history.pushState({}, '', '/settings'); mocks.useRawConfig.mockReturnValue({ rawConfig: 'websearch:\n enabled: true\n', loading: true, @@ -84,4 +89,19 @@ describe('SettingsPage raw config panel', () => { expect(screen.getByLabelText('config editor')).toHaveValue('websearch:\n enabled: true\n'); expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); }); + + it('renders the Browser section when the settings tab query is browser', async () => { + window.history.pushState({}, '', '/settings?tab=browser'); + mocks.useRawConfig.mockReturnValue({ + rawConfig: 'browser:\n claude:\n enabled: false\n', + loading: false, + copied: false, + fetchRawConfig: mocks.fetchRawConfig, + copyToClipboard: mocks.copyToClipboard, + }); + + render(); + + expect(await screen.findAllByText('Browser Section')).toHaveLength(2); + }); });