From b52503300b0c434fbb9d88283643d3ceaa673d44 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Apr 2026 17:18:14 -0400 Subject: [PATCH] fix(browser): bootstrap managed attach profile setup --- docs/browser-automation.md | 10 ++ src/ccs.ts | 4 +- src/cliproxy/executor/index.ts | 2 +- src/utils/browser/browser-settings.ts | 155 +++++++++++++++++- src/utils/browser/browser-status.ts | 38 +++++ .../default-profile-browser-launch.test.ts | 61 ++++++- .../settings-profile-browser-launch.test.ts | 62 ++++++- .../unit/utils/browser/browser-status.test.ts | 69 +++++++- tests/unit/web-server/browser-routes.test.ts | 5 +- 9 files changed, 388 insertions(+), 18 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 27ba99a6..af51c053 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -129,6 +129,10 @@ chrome.exe --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.ccs\\br Using a dedicated CCS browser data dir is recommended. It avoids profile-locking issues and keeps automation state separate from your daily browser profile. +When Claude Browser Attach uses the recommended managed path (`~/.ccs/browser/chrome-user-data`), +CCS now creates that directory automatically the first time it needs it. After that bootstrap step, +the remaining requirement is a running Chrome session started with `--remote-debugging-port`. + ## Troubleshooting ### Browser status says Claude Browser Attach is disabled @@ -144,6 +148,9 @@ The configured Chrome user-data directory does not exist yet. 2. Start Chrome in attach mode with `--remote-debugging-port` 3. Rerun `ccs browser doctor` +If you are using the CCS-managed default path, this usually means the path could not be created +automatically and now needs manual attention. + ### Browser status says no running browser session was found CCS could not find usable DevTools attach metadata for the configured user-data directory. @@ -152,6 +159,9 @@ CCS could not find usable DevTools attach metadata for the configured user-data 2. Make sure it is using the same `user_data_dir` configured in CCS 3. Rerun `ccs browser doctor` +For the CCS-managed default path, this is the normal first-run state after CCS bootstraps the +directory for you. + ### Browser status says the DevTools endpoint is unreachable CCS found attach metadata, but the endpoint did not answer successfully. diff --git a/src/ccs.ts b/src/ccs.ts index d66ded4a..8a5371fa 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1067,7 +1067,7 @@ async function main(): Promise { : undefined; const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; if (browserAttachRuntime?.warning) { - console.error(warn(browserAttachRuntime.warning)); + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); } if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); @@ -1477,7 +1477,7 @@ async function main(): Promise { : undefined; const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; if (browserAttachRuntime?.warning) { - console.error(warn(browserAttachRuntime.warning)); + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); } if (resolvedTarget === 'claude') { diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 0ec24898..d02a12f5 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -270,7 +270,7 @@ export async function execClaudeWithCLIProxy( : undefined; const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; if (browserAttachRuntime?.warning) { - console.error(warn(browserAttachRuntime.warning)); + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); } if (browserRuntimeEnv) { ensureBrowserMcpOrThrow(); diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index 7dfd99e1..a6e24b03 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -1,7 +1,9 @@ +import * as fs from 'fs'; import * as path from 'path'; import type { BrowserConfig } from '../../config/unified-config-types'; import { getCcsDir } from '../config-manager'; import { expandPath } from '../helpers'; +import { getNodePlatformKey } from './platform'; import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; @@ -24,10 +26,141 @@ export interface BrowserAttachRuntimeResolution { warning?: string; } +export interface ManagedBrowserAttachBootstrap { + usesManagedDefaultDir: boolean; + createdProfileDir: boolean; +} + +export interface ManagedBrowserAttachNotReadyMessage { + state: 'path_missing' | 'browser_not_running' | 'endpoint_unreachable'; + title: string; + detail: string; + nextStep: string; + warning: string; +} + +function isManagedDefaultBrowserAttach(config: EffectiveClaudeBrowserAttachConfig): boolean { + return ( + config.source === 'config' && + path.resolve(config.userDataDir) === path.resolve(getRecommendedBrowserUserDataDir()) + ); +} + +function buildCurrentPlatformLaunchCommand(userDataDir: string, devtoolsPort: number): string { + const quotedPath = JSON.stringify(userDataDir); + switch (getNodePlatformKey()) { + case 'darwin': + return `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`; + case 'win32': + return `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`; + default: + return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`; + } +} + export function resolveBrowserUserDataDir(value?: string): string | undefined { return value?.trim() ? expandPath(value) : undefined; } +export function ensureManagedBrowserUserDataDir( + config: EffectiveClaudeBrowserAttachConfig +): ManagedBrowserAttachBootstrap { + if (!isManagedDefaultBrowserAttach(config)) { + return { + usesManagedDefaultDir: false, + createdProfileDir: false, + }; + } + + try { + fs.statSync(config.userDataDir); + return { + usesManagedDefaultDir: true, + createdProfileDir: false, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code && code !== 'ENOENT') { + return { + usesManagedDefaultDir: true, + createdProfileDir: false, + }; + } + } + + try { + fs.mkdirSync(config.userDataDir, { recursive: true, mode: 0o700 }); + return { + usesManagedDefaultDir: true, + createdProfileDir: true, + }; + } catch { + return { + usesManagedDefaultDir: true, + createdProfileDir: false, + }; + } +} + +export function describeManagedBrowserAttachNotReady( + config: EffectiveClaudeBrowserAttachConfig, + errorMessage: string, + options: { + createdProfileDir?: boolean; + launchCommand?: string; + } = {} +): ManagedBrowserAttachNotReadyMessage | undefined { + if (!isManagedDefaultBrowserAttach(config)) { + return undefined; + } + + const launchCommand = + options.launchCommand ?? + buildCurrentPlatformLaunchCommand(config.userDataDir, config.devtoolsPort); + const continueWithoutTools = + 'CCS will continue without browser tools until the attach session is ready.'; + + if (errorMessage.includes('Chrome reuse metadata')) { + const summary = options.createdProfileDir + ? `CCS created the managed browser profile at ${config.userDataDir}, but no running attach-mode Chrome session is using it yet` + : `No running attach-mode Chrome session is using the managed browser profile at ${config.userDataDir}`; + const nextStep = `Start Chrome with remote debugging and the managed user-data dir. Example: ${launchCommand}`; + return { + state: 'browser_not_running', + title: 'Claude Browser Attach is waiting for a managed Chrome session.', + detail: `${summary}. Diagnostic: ${errorMessage}`, + nextStep, + warning: `${summary}. ${nextStep} ${continueWithoutTools}`, + }; + } + + if (errorMessage.includes('Chrome DevTools endpoint')) { + const summary = `CCS could not reach the attach-mode DevTools endpoint for the managed browser profile at ${config.userDataDir}`; + const nextStep = `Restart Chrome in attach mode and retry. Example: ${launchCommand}`; + return { + state: 'endpoint_unreachable', + title: 'Claude Browser Attach could not reach the managed Chrome session.', + detail: `${summary}. Diagnostic: ${errorMessage}`, + nextStep, + warning: `${summary}. ${nextStep} ${continueWithoutTools}`, + }; + } + + if (errorMessage.includes('Chrome profile directory is invalid')) { + const summary = `CCS could not initialize the managed browser profile at ${config.userDataDir}`; + const nextStep = `Confirm the path is writable or reset it to the CCS-managed default, then launch Chrome in attach mode. Example: ${launchCommand}`; + return { + state: 'path_missing', + title: 'Claude Browser Attach could not initialize the managed profile.', + detail: `${summary}. Diagnostic: ${errorMessage}`, + nextStep, + warning: `${summary}. ${nextStep} ${continueWithoutTools}`, + }; + } + + return undefined; +} + export function getBrowserAttachOverride(env: NodeJS.ProcessEnv = process.env): { userDataDir?: string; devtoolsPort?: number; @@ -94,6 +227,17 @@ export async function resolveOptionalBrowserAttachRuntime( return {}; } + const bootstrap = ensureManagedBrowserUserDataDir(config); + if (bootstrap.createdProfileDir) { + return { + warning: describeManagedBrowserAttachNotReady( + config, + `Chrome reuse metadata not found: ${path.join(config.userDataDir, 'DevToolsActivePort')}`, + { createdProfileDir: true } + )?.warning, + }; + } + try { return { runtimeEnv: await resolveBrowserRuntimeEnv({ @@ -103,13 +247,12 @@ export async function resolveOptionalBrowserAttachRuntime( }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - const usesManagedDefaultDir = - config.source === 'config' && - path.resolve(config.userDataDir) === path.resolve(getRecommendedBrowserUserDataDir()); - - if (usesManagedDefaultDir && message.includes('Chrome profile directory is invalid')) { + const managedDefaultMessage = describeManagedBrowserAttachNotReady(config, message, { + createdProfileDir: bootstrap.createdProfileDir, + }); + if (managedDefaultMessage) { return { - warning: `Claude Browser Attach is enabled, but the managed browser profile does not exist yet (${config.userDataDir}). Launching without browser tools. Run \`ccs browser doctor\` to finish setup.`, + warning: managedDefaultMessage.warning, }; } diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index 0ea469d2..61c7e5f0 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -1,9 +1,12 @@ +import * as path from 'path'; 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 { + describeManagedBrowserAttachNotReady, + ensureManagedBrowserUserDataDir, getEffectiveClaudeBrowserAttachConfig, getRecommendedBrowserUserDataDir, } from './browser-settings'; @@ -61,6 +64,7 @@ async function buildClaudeBrowserStatus( ): Promise { const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig); const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort); + const managedBootstrap = ensureManagedBrowserUserDataDir(effective); const base: Omit = { enabled: effective.enabled, source: effective.source, @@ -85,6 +89,26 @@ async function buildClaudeBrowserStatus( }; } + if (managedBootstrap.createdProfileDir) { + const managedDefaultMessage = describeManagedBrowserAttachNotReady( + effective, + `Chrome reuse metadata not found: ${path.join(effective.userDataDir, 'DevToolsActivePort')}`, + { + createdProfileDir: true, + launchCommand: launchCommands[getNodePlatformKey()], + } + ); + if (managedDefaultMessage) { + return { + ...base, + state: managedDefaultMessage.state, + title: managedDefaultMessage.title, + detail: managedDefaultMessage.detail, + nextStep: managedDefaultMessage.nextStep, + }; + } + } + try { const runtimeEnv = await resolveBrowserRuntimeEnv({ profileDir: effective.userDataDir, @@ -102,6 +126,20 @@ async function buildClaudeBrowserStatus( }; } catch (error) { const message = (error as Error).message; + const managedDefaultMessage = describeManagedBrowserAttachNotReady(effective, message, { + createdProfileDir: managedBootstrap.createdProfileDir, + launchCommand: launchCommands[getNodePlatformKey()], + }); + if (managedDefaultMessage) { + return { + ...base, + state: managedDefaultMessage.state, + title: managedDefaultMessage.title, + detail: managedDefaultMessage.detail, + nextStep: managedDefaultMessage.nextStep, + }; + } + if (message.includes('Chrome profile directory is invalid')) { return { ...base, diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index 5545f366..53cb189c 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -81,6 +81,18 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { }; } +function reserveClosedPort(): number { + const server = Bun.serve({ + port: 0, + fetch() { + return new Response('ok'); + }, + }); + const { port } = server; + server.stop(true); + return port; +} + describe('default profile browser launch', () => { let tmpHome = ''; let fakeClaudePath = ''; @@ -259,7 +271,7 @@ server.listen(0, '127.0.0.1', () => { claude: { enabled: true, user_data_dir: '', - devtools_port: 9222, + devtools_port: 43123, }, codex: { enabled: true, @@ -272,8 +284,10 @@ server.listen(0, '127.0.0.1', () => { }); expect(result.status).toBe(0); - expect(result.stderr).toContain('Launching without browser tools'); - expect(result.stderr).toContain('ccs browser doctor'); + expect(result.stderr).toContain('CCS created the managed browser profile'); + expect(result.stderr).toContain('Start Chrome with remote debugging'); + expect(result.stderr).toContain('continue without browser tools'); + expect(fs.existsSync(path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true); const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); @@ -291,6 +305,47 @@ server.listen(0, '127.0.0.1', () => { } }); + it('skips managed browser attach when the managed profile exists but no browser session is running', () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + const unreachablePort = reserveClosedPort(); + const managedProfileDir = path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data'); + fs.mkdirSync(managedProfileDir, { recursive: true }); + + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '', + devtools_port: unreachablePort, + }, + codex: { + enabled: true, + }, + }; + }); + + const result = runCcs(['default', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + it('uses config-backed browser attach settings when env overrides are absent', async () => { if (process.platform === 'win32') return; diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index 653271b1..32780a4c 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -28,6 +28,18 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { }; } +function reserveClosedPort(): number { + const server = Bun.serve({ + port: 0, + fetch() { + return new Response('ok'); + }, + }); + const { port } = server; + server.stop(true); + return port; +} + describe('settings profile browser launch', () => { let tmpHome = ''; let ccsDir = ''; @@ -210,7 +222,7 @@ server.listen(0, '127.0.0.1', () => { claude: { enabled: true, user_data_dir: '', - devtools_port: 9222, + devtools_port: 43123, }, codex: { enabled: true, @@ -223,8 +235,10 @@ server.listen(0, '127.0.0.1', () => { }); expect(result.status).toBe(0); - expect(result.stderr).toContain('Launching without browser tools'); - expect(result.stderr).toContain('ccs browser doctor'); + expect(result.stderr).toContain('CCS created the managed browser profile'); + expect(result.stderr).toContain('Start Chrome with remote debugging'); + expect(result.stderr).toContain('continue without browser tools'); + expect(fs.existsSync(path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true); const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); @@ -242,6 +256,48 @@ server.listen(0, '127.0.0.1', () => { } }); + it('skips managed browser attach for settings-profile launches when no managed browser session is running', () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + const unreachablePort = reserveClosedPort(); + fs.mkdirSync(path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data'), { + recursive: true, + }); + + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '', + devtools_port: unreachablePort, + }, + codex: { + enabled: true, + }, + }; + }); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + it('uses config-backed browser attach settings for settings-profile launches', async () => { if (process.platform === 'win32') return; diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index a15cd461..e35e52d7 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { mutateUnifiedConfig } from '../../../../src/config/unified-config-loader'; import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse'; -import { getBrowserStatus } from '../../../../src/utils/browser/browser-status'; +import { + getBrowserStatus, +} from '../../../../src/utils/browser/browser-status'; +import { resolveOptionalBrowserAttachRuntime } from '../../../../src/utils/browser/browser-settings'; import * as codexDetector from '../../../../src/targets/codex-detector'; describe('browser status', () => { @@ -86,6 +89,48 @@ describe('browser status', () => { } }); + it('bootstraps the managed default browser profile dir before reporting attach readiness', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, + }; + }); + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue( + new Error( + `Chrome reuse metadata not found: ${join(tempHome, '.ccs', 'browser', 'chrome-user-data', '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.title).toBe( + 'Claude Browser Attach is waiting for a managed Chrome session.' + ); + expect(status.claude.detail).toContain('CCS created the managed browser profile'); + expect(status.claude.nextStep).toContain('--remote-debugging-port=9222'); + expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); + it('prefers CCS_BROWSER_USER_DATA_DIR over config when an env override is present', async () => { mutateUnifiedConfig((config) => { config.browser = { @@ -169,6 +214,26 @@ describe('browser status', () => { } }); + it('returns a managed attach warning when the configured DevTools port is unreachable', async () => { + const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data'); + mkdirSync(managedDir, { recursive: true }); + + const runtime = await resolveOptionalBrowserAttachRuntime({ + enabled: true, + source: 'config', + overrideActive: false, + userDataDir: managedDir, + devtoolsPort: 43123, + hasExplicitDevtoolsPort: true, + }); + + expect(runtime.runtimeEnv).toBeUndefined(); + expect(runtime.warning).toContain( + 'could not reach the attach-mode DevTools endpoint for the managed browser profile' + ); + expect(runtime.warning).toContain('continue without browser tools'); + }); + it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => { process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser'; diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts index bc6e0305..2ce1c342 100644 --- a/tests/unit/web-server/browser-routes.test.ts +++ b/tests/unit/web-server/browser-routes.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; import express from 'express'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import type { Server } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -184,6 +184,9 @@ describe('browser routes', () => { userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9333, }); + expect(payload.browser.status.claude.state).toBe('browser_not_running'); + expect(payload.browser.status.claude.detail).toContain('CCS created the managed browser profile'); + expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true); const config = loadOrCreateUnifiedConfig(); expect(config.browser).toMatchObject({