From 4e30c9b080a4e240b1fa17c3b0486dafc28224da Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 13:59:33 -0400 Subject: [PATCH 1/4] 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); + }); }); From 06f6f5485fdae1790ac50eee3f0bdc4905b340b1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 18:47:48 -0400 Subject: [PATCH 2/4] feat(settings): refine browser automation tab --- ui/bun.lock | 10 + ui/package.json | 2 + ui/src/lib/i18n.ts | 8 + ui/src/lib/platform.ts | 25 + .../pages/settings/sections/browser/index.tsx | 983 +++++++++++------- ui/tests/unit/ui/lib/platform.test.ts | 32 + 6 files changed, 669 insertions(+), 391 deletions(-) create mode 100644 ui/src/lib/platform.ts create mode 100644 ui/tests/unit/ui/lib/platform.test.ts diff --git a/ui/bun.lock b/ui/bun.lock index d79f76ca..cd5c1000 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -8,6 +8,7 @@ "@hookform/resolvers": "^5.2.2", "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", @@ -28,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.38.0", "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", @@ -273,6 +275,8 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -735,6 +739,8 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -877,6 +883,10 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], + + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.12.4", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.7.0", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg=="], diff --git a/ui/package.json b/ui/package.json index a5a7ad53..872ab80e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,6 +22,7 @@ "@hookform/resolvers": "^5.2.2", "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", @@ -42,6 +43,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.38.0", "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 502ea578..5329df3a 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -2433,6 +2433,8 @@ const resources = { 'Configure Claude Browser Attach and Codex Browser Tools here, then use the guidance below to launch or verify each lane.', readiness: 'Readiness', nextStep: 'Next step', + technicalDetails: 'Technical Details', + diagnostics: 'Diagnostics', actions: { saveClaude: 'Save Claude settings', saveCodex: 'Save Codex settings', @@ -4846,6 +4848,8 @@ const resources = { '在这里配置 Claude Browser Attach 和 Codex Browser Tools,然后按下方指引启动或验证每条路径。', readiness: '就绪状态', nextStep: '下一步', + technicalDetails: '技术细节', + diagnostics: '诊断信息', actions: { saveClaude: '保存 Claude 设置', saveCodex: '保存 Codex 设置', @@ -7361,6 +7365,8 @@ const resources = { 'Cấu hình Claude Browser Attach và Codex Browser Tools tại đây, rồi dùng hướng dẫn bên dưới để khởi chạy hoặc kiểm tra từng luồng.', readiness: 'Trạng thái sẵn sàng', nextStep: 'Bước tiếp theo', + technicalDetails: 'Chi tiết kỹ thuật', + diagnostics: 'Chẩn đoán', actions: { saveClaude: 'Lưu cấu hình Claude', saveCodex: 'Lưu cấu hình Codex', @@ -9783,6 +9789,8 @@ const resources = { 'ここで Claude Browser Attach と Codex Browser Tools を設定し、各レーンの起動や確認は下の案内を使ってください。', readiness: '準備状況', nextStep: '次の手順', + technicalDetails: '技術的な詳細', + diagnostics: '診断情報', actions: { saveClaude: 'Claude 設定を保存', saveCodex: 'Codex 設定を保存', diff --git a/ui/src/lib/platform.ts b/ui/src/lib/platform.ts new file mode 100644 index 00000000..535abdb7 --- /dev/null +++ b/ui/src/lib/platform.ts @@ -0,0 +1,25 @@ +export type ClientPlatformKey = 'darwin' | 'linux' | 'win32'; + +type NavigatorWithUserAgentData = Pick & { + userAgentData?: { + platform?: string | null; + }; +}; + +function normalizeClientPlatform(value: string): ClientPlatformKey { + const platform = value.toLowerCase(); + if (platform.includes('mac')) return 'darwin'; + if (platform.includes('win')) return 'win32'; + return 'linux'; +} + +export function getClientPlatformKey( + nav: NavigatorWithUserAgentData = navigator as NavigatorWithUserAgentData +): ClientPlatformKey { + const userAgentDataPlatform = + typeof nav.userAgentData?.platform === 'string' ? nav.userAgentData.platform : ''; + const fallbackPlatform = + typeof nav.platform === 'string' && nav.platform.trim() ? nav.platform : nav.userAgent; + + return normalizeClientPlatform(userAgentDataPlatform || fallbackPlatform || ''); +} diff --git a/ui/src/pages/settings/sections/browser/index.tsx b/ui/src/pages/settings/sections/browser/index.tsx index 49dc8301..6c997257 100644 --- a/ui/src/pages/settings/sections/browser/index.tsx +++ b/ui/src/pages/settings/sections/browser/index.tsx @@ -1,60 +1,40 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Browser, + Gear, + CheckCircle, + WarningCircle, + XCircle, + ArrowRight, + ClipboardText, + ArrowsClockwise, + TerminalWindow, + CaretDown, + Info, +} from '@phosphor-icons/react'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; -import { - AlertCircle, - CheckCircle2, - Copy, - Monitor, - RefreshCw, - SearchCheck, - Wrench, -} from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { getClientPlatformKey } from '@/lib/platform'; import { cn } from '@/lib/utils'; import { useBrowserConfig, useRawConfig } from '../../hooks'; import type { BrowserConfig } from '../../types'; -function getPlatformKey(): 'darwin' | 'linux' | 'win32' { - const platform = navigator.platform.toLowerCase(); - if (platform.includes('mac')) return 'darwin'; - if (platform.includes('win')) return 'win32'; - return 'linux'; -} +// --- Constants & Helpers --- function parsePortDraft(value: string): number | null { - if (!/^\d+$/.test(value.trim())) { - return null; - } - + if (!/^\d+$/.test(value.trim())) return null; const port = Number.parseInt(value.trim(), 10); - if (port < 1 || port > 65535) { - return null; - } - + if (port < 1 || port > 65535) return null; return port; } -function statusTone(state: string) { - if (state === 'ready' || state === 'enabled') { - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; - } - if (state === 'disabled') { - return 'border-slate-500/20 bg-slate-500/10 text-slate-700 dark:text-slate-300'; - } - return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300'; -} - -function stateLabel(state: string) { - return state.replaceAll('_', ' '); -} - function buildLaunchCommand( userDataDir: string, devtoolsPort: number, @@ -70,32 +50,181 @@ function buildLaunchCommand( return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`; } -function updateClaudeDraft( - source: BrowserConfig, - updates: Partial -): BrowserConfig { - return { - ...source, - claude: { - ...source.claude, - ...updates, - }, - }; +// --- High-End Components --- + +/** + * Double-Bezel Card Pattern + * Machined hardware look with nested enclosures + */ +function DoubleBezelCard({ + children, + className, + title, + description, + badge, + action, +}: { + children: React.ReactNode; + className?: string; + title?: string; + description?: string; + badge?: React.ReactNode; + action?: React.ReactNode; +}) { + return ( +
+
+ {/* Subtle highlight inner shadow */} +
+ + {(title || action) && ( +
+
+
+

{title}

+ {badge} +
+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ )} +
{children}
+
+
+ ); } -function updateCodexDraft( - source: BrowserConfig, - updates: Partial -): BrowserConfig { - return { - ...source, - codex: { - ...source.codex, - ...updates, - }, - }; +/** + * Animated Status Strip + * Highlights readiness with cinematic dynamics + */ +function StatusStrip({ + state, + title, + detail, + nextStep, +}: { + state: string; + title: string; + detail: string; + nextStep: string; +}) { + const { t } = useTranslation(); + const isReady = state === 'ready' || state === 'enabled'; + const isError = !isReady && state !== 'disabled'; + + return ( +
+
+
+ {isReady ? ( + + ) : isError ? ( + + ) : ( + + )} +
+
+
+ + {t('settingsPage.browserSection.readiness')} + + {isReady && ( + + )} +
+

{title}

+

{detail}

+ {nextStep && ( +
+ +

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

+
+ )} +
+
+
+ ); } +/** + * Diagnostics Accordion + * Pushes secondary details lower in the hierarchy + */ +function DiagnosticsSection({ + title, + children, + defaultOpen = false, +}: { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( + + + + + +
+ {children} +
+
+
+ ); +} + +// --- Main Section --- + export default function BrowserSection() { const { t } = useTranslation(); const { fetchRawConfig } = useRawConfig(); @@ -132,7 +261,7 @@ export default function BrowserSection() { return buildLaunchCommand( effectiveConfig.claude.userDataDir, effectiveConfig.claude.devtoolsPort, - getPlatformKey() + getClientPlatformKey() ); }, [effectiveConfig]); @@ -168,7 +297,6 @@ export default function BrowserSection() { const saveClaudeSettings = useCallback(async () => { if (!effectiveConfig || claudePort === null) return; - const saved = await saveConfig({ claude: { enabled: effectiveConfig.claude.enabled, @@ -176,7 +304,6 @@ export default function BrowserSection() { devtoolsPort: claudePort, }, }); - if (saved) { await fetchRawConfig(); setActionMessage(null); @@ -187,13 +314,11 @@ export default function BrowserSection() { const saveCodexSettings = useCallback(async () => { if (!effectiveConfig) return; - const saved = await saveConfig({ codex: { enabled: effectiveConfig.codex.enabled, }, }); - if (saved) { await fetchRawConfig(); setActionMessage(null); @@ -208,12 +333,21 @@ export default function BrowserSection() { setActionMessage(t('settingsPage.browserSection.messages.launchCommandCopied')); }, [preferredLaunchCommand, t]); + // -- Render Helpers -- + if (loading) { return (
-
- - {t('settings.loading')} +
+ + + + + {t('settings.loading')} +
); @@ -221,357 +355,424 @@ export default function BrowserSection() { if (!config || !status || !effectiveConfig) { return ( -
- - - +
+ + + {error ?? t('settingsPage.browserSection.description')} +
+ +
-
- -
); } return ( -
-
+ {/* Toast Notification Surface */} + + {(error || success || actionMessage) && ( + + {error ? ( +
+ + {error} +
+ ) : ( +
+ + {actionMessage ?? t('commonToast.settingsSaved')} +
+ )} +
)} - > - {error && ( - - - {error} - - )} - {!error && (success || actionMessage) && ( -
- - - {actionMessage ?? t('commonToast.settingsSaved')} - -
- )} -
+ -
-
-
-
- -

{t('settingsPage.browserSection.title')}

+
+ {/* Header Section */} + +
+
+
+ +
+
+

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

+

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

+
-

- {t('settingsPage.browserSection.description')} -

- -
+ - - - - {t('settingsPage.browserSection.primaryTitle')}{' '} - {t('settingsPage.browserSection.primaryDescription')} - - + + + + + + {t('settingsPage.browserSection.primaryTitle')} + {' '} + {t('settingsPage.browserSection.primaryDescription')} + + + - - -
-
- {t('settingsPage.browserSection.claude.title')} - - {t('settingsPage.browserSection.claude.description')} - -
- - {stateLabel(status.claude.state)} - -
-
- -
-
- -

- {t('settingsPage.browserSection.claude.enabledDescription')} -

-
- - setDraft((current) => - updateClaudeDraft(current ?? effectiveConfig, { enabled: next }) - ) - } - aria-label={t('settingsPage.browserSection.claude.enabledLabel')} - /> -
- -
-
- - - 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')} -

+ {/* Claude Lane Card */} + + +
+ + + setDraft((current) => + updateClaudeDraft(current ?? effectiveConfig, { enabled: next }) + ) + } + />
+
-
-                  {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}

-
-
+
+
+
+ + + setDraft((current) => + updateClaudeDraft(current ?? effectiveConfig, { + userDataDir: event.target.value, + }) + ) + } + className="rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70" + placeholder={status.claude.recommendedUserDataDir} + /> +

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

+
-
-
-

- {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} -
-
+
+ + setClaudePortDraft(event.target.value)} + inputMode="numeric" + className="max-w-[120px] rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70" + /> +

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

+
+
-
- +
+
+
+
+

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

+

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

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

+ {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} +

+
+
+
+
- - + + + + {/* Codex Lane Card */} + + +
+ + + setDraft((current) => + updateCodexDraft(current ?? effectiveConfig, { enabled: next }) + ) + } + /> +
+ +
+ } + > +
+ + + +
+
+

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

+
+

+ {status.codex.serverName} +

+
+
+
+

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

+
+ {status.codex.supportsConfigOverrides ? ( + + ) : ( + + )} + {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} +
+ )} +
+
+
+
+
+ +
); } + +function updateClaudeDraft( + source: BrowserConfig, + updates: Partial +): BrowserConfig { + return { + ...source, + claude: { + ...source.claude, + ...updates, + }, + }; +} + +function updateCodexDraft( + source: BrowserConfig, + updates: Partial +): BrowserConfig { + return { + ...source, + codex: { + ...source.codex, + ...updates, + }, + }; +} diff --git a/ui/tests/unit/ui/lib/platform.test.ts b/ui/tests/unit/ui/lib/platform.test.ts new file mode 100644 index 00000000..b97f923a --- /dev/null +++ b/ui/tests/unit/ui/lib/platform.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { getClientPlatformKey } from '@/lib/platform'; + +describe('getClientPlatformKey', () => { + it('prefers navigator.userAgentData.platform when available', () => { + expect( + getClientPlatformKey({ + userAgentData: { platform: 'macOS' }, + platform: 'Win32', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }) + ).toBe('darwin'); + }); + + it('falls back to navigator.platform when userAgentData is unavailable', () => { + expect( + getClientPlatformKey({ + platform: 'Win32', + userAgent: 'Mozilla/5.0 (X11; Linux x86_64)', + }) + ).toBe('win32'); + }); + + it('falls back to user-agent parsing when platform is unavailable', () => { + expect( + getClientPlatformKey({ + platform: '', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }) + ).toBe('win32'); + }); +}); From 8a17410f9642911d3b74357d6bbfd16aecddad4e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 18:48:28 -0400 Subject: [PATCH 3/4] fix(browser): address platform and port review --- docs/browser-automation.md | 10 +++++ src/commands/browser-command.ts | 12 ++---- src/utils/browser/browser-settings.ts | 3 ++ src/utils/browser/browser-status.ts | 13 ++---- src/utils/browser/platform.ts | 9 ++++ .../unit/utils/browser/browser-status.test.ts | 41 +++++++++++++++++++ tests/unit/utils/browser/platform.test.ts | 17 ++++++++ 7 files changed, 87 insertions(+), 18 deletions(-) create mode 100644 src/utils/browser/platform.ts create mode 100644 tests/unit/utils/browser/platform.test.ts diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 87be8aaf..27ba99a6 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -90,6 +90,16 @@ CCS still supports environment-variable overrides for backward compatibility. If an override is active, Browser status surfaces should report that the current session is being managed externally by environment variables. +Override precedence is: + +1. `CCS_BROWSER_USER_DATA_DIR` +2. `CCS_BROWSER_PROFILE_DIR` +3. the persisted `browser.claude.user_data_dir` config value + +Config-backed Browser Attach always passes an explicit DevTools port to the runtime, even when the +effective value is the default `9222`. Metadata-based port discovery is preserved only for the +legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not set. + ## Managed Runtime Files - `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts index d42a0654..d9c4ccf6 100644 --- a/src/commands/browser-command.ts +++ b/src/commands/browser-command.ts @@ -1,14 +1,9 @@ import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser'; +import { getNodePlatformKey } from '../utils/browser/platform'; 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; @@ -63,9 +58,8 @@ function writeClaudeStatus( writeLine(` Detail: ${status.detail}`); writeLine(` Next step: ${status.nextStep}`); if (includeLaunchGuidance && status.enabled && status.state !== 'ready') { - writeLine( - ` Launch command (${currentPlatform()}): ${status.launchCommands[currentPlatform()]}` - ); + const platform = getNodePlatformKey(); + writeLine(` Launch command (${platform}): ${status.launchCommands[platform]}`); } writeLine(''); } diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index 2e0691e2..e1a9f6fe 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -74,6 +74,9 @@ export function getEffectiveClaudeBrowserAttachConfig( overrideActive: false, userDataDir: configUserDataDir, devtoolsPort: configPort, + // Config-backed browser attach always keeps an explicit port so launches + // stay aligned with Settings > Browser, even when the effective value is + // the default 9222. hasExplicitDevtoolsPort: true, }; } diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index 4af83b20..0ea469d2 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -2,6 +2,7 @@ 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 { getNodePlatformKey } from './platform'; import { getEffectiveClaudeBrowserAttachConfig, getRecommendedBrowserUserDataDir, @@ -107,7 +108,7 @@ async function buildClaudeBrowserStatus( 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()]}`, + nextStep: `Create or choose a Chrome user-data directory, then launch Chrome with attach mode enabled. Example: ${launchCommands[getNodePlatformKey()]}`, }; } @@ -117,7 +118,7 @@ async function buildClaudeBrowserStatus( 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()]}`, + nextStep: `Start Chrome with remote debugging and the configured user-data dir. Example: ${launchCommands[getNodePlatformKey()]}`, }; } @@ -126,7 +127,7 @@ async function buildClaudeBrowserStatus( 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()]}`, + nextStep: `Restart the attach browser session or confirm the configured port. Example: ${launchCommands[getNodePlatformKey()]}`, }; } } @@ -185,9 +186,3 @@ function buildLaunchCommands(userDataDir: string, devtoolsPort: number): Browser 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/platform.ts b/src/utils/browser/platform.ts new file mode 100644 index 00000000..9f747258 --- /dev/null +++ b/src/utils/browser/platform.ts @@ -0,0 +1,9 @@ +export type BrowserPlatformKey = 'darwin' | 'linux' | 'win32'; + +export function getNodePlatformKey( + platform: NodeJS.Platform = process.platform +): BrowserPlatformKey { + if (platform === 'darwin') return 'darwin'; + if (platform === 'win32') return 'win32'; + return 'linux'; +} diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index 1e047e5d..a15cd461 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -199,4 +199,45 @@ describe('browser status', () => { codexSpy.mockRestore(); } }); + + it('always forwards an explicit port for config-backed browser attach sessions', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '/tmp/config-browser', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, + }; + }); + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ + CCS_BROWSER_USER_DATA_DIR: '/tmp/config-browser', + 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/config', + }); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + await getBrowserStatus(); + + expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ + profileDir: '/tmp/config-browser', + devtoolsPort: '9222', + }); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); }); diff --git a/tests/unit/utils/browser/platform.test.ts b/tests/unit/utils/browser/platform.test.ts new file mode 100644 index 00000000..2c6ab7a0 --- /dev/null +++ b/tests/unit/utils/browser/platform.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'bun:test'; +import { getNodePlatformKey } from '../../../../src/utils/browser/platform'; + +describe('browser platform helper', () => { + it('maps darwin explicitly', () => { + expect(getNodePlatformKey('darwin')).toBe('darwin'); + }); + + it('maps win32 explicitly', () => { + expect(getNodePlatformKey('win32')).toBe('win32'); + }); + + it('falls back to linux for other node platforms', () => { + expect(getNodePlatformKey('linux')).toBe('linux'); + expect(getNodePlatformKey('freebsd')).toBe('linux'); + }); +}); From 4583ffa022909c4456051c6e89be791c18933eab Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 19:00:14 -0400 Subject: [PATCH 4/4] fix(browser): reset blank attach path to default --- src/web-server/routes/browser-routes.ts | 4 +- tests/unit/web-server/browser-routes.test.ts | 43 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts index 2701339f..14d8bdc3 100644 --- a/src/web-server/routes/browser-routes.ts +++ b/src/web-server/routes/browser-routes.ts @@ -93,11 +93,13 @@ router.put('/', async (req: Request, res: Response): Promise => { try { const current = getBrowserConfig(); + const nextClaudeUserDataDir = + claude?.userDataDir === undefined ? current.claude.user_data_dir : claude.userDataDir.trim(); mutateUnifiedConfig((config) => { config.browser = { claude: { enabled: claude?.enabled ?? current.claude.enabled, - user_data_dir: claude?.userDataDir?.trim() || current.claude.user_data_dir, + user_data_dir: nextClaudeUserDataDir, devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port, }, codex: { diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts index 17ed127b..bc6e0305 100644 --- a/tests/unit/web-server/browser-routes.test.ts +++ b/tests/unit/web-server/browser-routes.test.ts @@ -152,6 +152,49 @@ describe('browser routes', () => { }); }); + it('treats a blank user-data directory as a reset to the recommended path', async () => { + const firstResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + enabled: true, + userDataDir: '/tmp/ccs-browser-custom', + devtoolsPort: 9333, + }, + }), + }); + + expect(firstResponse.status).toBe(200); + + const resetResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + userDataDir: ' ', + }, + }), + }); + + expect(resetResponse.status).toBe(200); + const payload = await resetResponse.json(); + expect(payload.browser.config.claude).toMatchObject({ + enabled: true, + userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtoolsPort: 9333, + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.browser).toMatchObject({ + claude: { + enabled: true, + user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtools_port: 9333, + }, + }); + }); + it('rejects invalid DevTools ports at the route boundary', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT',