diff --git a/src/ccs.ts b/src/ccs.ts index 3cedddbb..d66ded4a 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -40,7 +40,7 @@ import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, getEffectiveClaudeBrowserAttachConfig, - resolveBrowserRuntimeEnv, + resolveOptionalBrowserAttachRuntime, syncBrowserMcpToConfigDir, } from './utils/browser'; import { @@ -1057,14 +1057,21 @@ async function main(): Promise { // Settings-based profiles (glm, glmt) are third-party providers const imageAnalysisMcpReady = resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; - let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; const browserAttachConfig = resolvedTarget === 'claude' ? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) : undefined; + const browserAttachRuntime = + resolvedTarget === 'claude' && browserAttachConfig?.enabled + ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) + : undefined; + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + console.error(warn(browserAttachRuntime.warning)); + } if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); - if (browserAttachConfig?.enabled) { + if (browserRuntimeEnv) { ensureBrowserMcpOrThrow(); } } @@ -1091,7 +1098,7 @@ async function main(): Promise { syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); if ( - browserAttachConfig?.enabled && + browserRuntimeEnv && inheritedClaudeConfigDir && !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) ) { @@ -1301,17 +1308,6 @@ async function main(): Promise { // Explicitly inject effective settings env vars so stale ANTHROPIC_* // values from prior sessions cannot leak into the active profile. - if (browserAttachConfig?.enabled) { - browserRuntimeEnv = { - ...(await resolveBrowserRuntimeEnv({ - profileDir: browserAttachConfig.userDataDir, - devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort - ? String(browserAttachConfig.devtoolsPort) - : undefined, - })), - }; - } - const envVars: NodeJS.ProcessEnv = { ...globalEnv, ...settingsEnv, @@ -1471,23 +1467,22 @@ async function main(): Promise { CCS_WEBSEARCH_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', }; - let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; const browserAttachConfig = resolvedTarget === 'claude' ? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) : undefined; + const browserAttachRuntime = + resolvedTarget === 'claude' && browserAttachConfig?.enabled + ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) + : undefined; + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + console.error(warn(browserAttachRuntime.warning)); + } if (resolvedTarget === 'claude') { - if (browserAttachConfig?.enabled) { + if (browserRuntimeEnv) { ensureBrowserMcpOrThrow(); - browserRuntimeEnv = { - ...(await resolveBrowserRuntimeEnv({ - profileDir: browserAttachConfig.userDataDir, - devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort - ? String(browserAttachConfig.devtoolsPort) - : undefined, - })), - }; Object.assign(envVars, browserRuntimeEnv); } const defaultContinuityInheritance = await resolveProfileContinuityInheritance({ @@ -1505,7 +1500,7 @@ async function main(): Promise { if (defaultContinuityInheritance.claudeConfigDir) { envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; if ( - browserAttachConfig?.enabled && + browserRuntimeEnv && !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) ) { throw new Error( diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 815b81b7..51f59b20 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -42,6 +42,11 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; */ export const CLIPROXY_CONFIG_VERSION = 17; +interface RegenerateConfigOptions { + configPath?: string; + authDir?: string; +} + interface OAuthModelAliasEntry { name: string; alias: string; @@ -739,8 +744,12 @@ function extractYamlSection(content: string, sectionKey: string): string { * @param port - Default port to use if not found in existing config * @returns Path to new config file */ -export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { - const configPath = getConfigPathForPort(port); +export function regenerateConfig( + port: number = CLIPROXY_DEFAULT_PORT, + options?: RegenerateConfigOptions +): string { + const configPath = options?.configPath ?? getConfigPathForPort(port); + const authDir = options?.authDir ?? getAuthDir(); // Preserve user settings from existing config let effectivePort = port; @@ -787,7 +796,7 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { // Ensure directories exist fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 }); + fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); // Generate fresh config with preserved user API keys and aliases let configContent = generateUnifiedConfigContent(effectivePort, userApiKeys, existingAliases); diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index f3d9f1a9..0ec24898 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -67,7 +67,7 @@ import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, getEffectiveClaudeBrowserAttachConfig, - resolveBrowserRuntimeEnv, + resolveOptionalBrowserAttachRuntime, syncBrowserMcpToConfigDir, } from '../../utils/browser'; import { @@ -265,7 +265,14 @@ export async function execClaudeWithCLIProxy( ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); - if (browserAttachConfig.enabled) { + const browserAttachRuntime = browserAttachConfig.enabled + ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) + : undefined; + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + console.error(warn(browserAttachRuntime.warning)); + } + if (browserRuntimeEnv) { ensureBrowserMcpOrThrow(); } displayWebSearchStatus(); @@ -1054,7 +1061,7 @@ export async function execClaudeWithCLIProxy( syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); if ( - browserAttachConfig.enabled && + browserRuntimeEnv && inheritedClaudeConfigDir && !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) ) { @@ -1157,17 +1164,6 @@ export async function execClaudeWithCLIProxy( } // 11. Build final environment with all proxy chains - const browserRuntimeEnv = browserAttachConfig.enabled - ? { - ...(await resolveBrowserRuntimeEnv({ - profileDir: browserAttachConfig.userDataDir, - devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort - ? String(browserAttachConfig.devtoolsPort) - : undefined, - })), - } - : undefined; - const env = buildClaudeEnvironment({ provider, useRemoteProxy, diff --git a/src/cliproxy/routing-strategy.ts b/src/cliproxy/routing-strategy.ts index 6adb15ff..ff621a9b 100644 --- a/src/cliproxy/routing-strategy.ts +++ b/src/cliproxy/routing-strategy.ts @@ -1,5 +1,6 @@ import { mutateUnifiedConfig, loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { regenerateConfig } from './config/generator'; +import { getAuthDir, getConfigPathForPort } from './config/path-resolver'; import { fetchCliproxyRoutingResponse, getCliproxyRoutingTarget, @@ -98,6 +99,8 @@ export async function applyCliproxyRoutingStrategy( strategy: CliproxyRoutingStrategy ): Promise { const target = getCliproxyRoutingTarget(); + const configPath = getConfigPathForPort(target.port); + const authDir = getAuthDir(); if (target.isRemote) { await updateLiveCliproxyRoutingStrategy(strategy); @@ -116,7 +119,7 @@ export async function applyCliproxyRoutingStrategy( config.cliproxy.routing = { strategy }; } }); - regenerateConfig(target.port); + regenerateConfig(target.port, { configPath, authDir }); try { await updateLiveCliproxyRoutingStrategy(strategy); diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index e1a9f6fe..7dfd99e1 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import type { BrowserConfig } from '../../config/unified-config-types'; import { getCcsDir } from '../config-manager'; import { expandPath } from '../helpers'; +import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; @@ -18,6 +19,11 @@ export function getRecommendedBrowserUserDataDir(): string { return path.join(getCcsDir(), 'browser', 'chrome-user-data'); } +export interface BrowserAttachRuntimeResolution { + runtimeEnv?: BrowserRuntimeEnv; + warning?: string; +} + export function resolveBrowserUserDataDir(value?: string): string | undefined { return value?.trim() ? expandPath(value) : undefined; } @@ -81,6 +87,36 @@ export function getEffectiveClaudeBrowserAttachConfig( }; } +export async function resolveOptionalBrowserAttachRuntime( + config: EffectiveClaudeBrowserAttachConfig +): Promise { + if (!config.enabled) { + return {}; + } + + try { + return { + runtimeEnv: await resolveBrowserRuntimeEnv({ + profileDir: config.userDataDir, + devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined, + }), + }; + } 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')) { + 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.`, + }; + } + + throw error; + } +} + function parseDevtoolsPort(value?: string): number | undefined { if (!value?.trim() || !/^\d+$/.test(value.trim())) { return undefined; diff --git a/src/utils/browser/chrome-reuse.ts b/src/utils/browser/chrome-reuse.ts index 976c39ef..14a20ba7 100644 --- a/src/utils/browser/chrome-reuse.ts +++ b/src/utils/browser/chrome-reuse.ts @@ -9,6 +9,7 @@ export interface BrowserReuseOptions { } export interface BrowserRuntimeEnv { + [key: string]: string; CCS_BROWSER_USER_DATA_DIR: string; CCS_BROWSER_DEVTOOLS_HOST: string; CCS_BROWSER_DEVTOOLS_PORT: string; diff --git a/src/utils/browser/index.ts b/src/utils/browser/index.ts index 22f92251..502534a0 100644 --- a/src/utils/browser/index.ts +++ b/src/utils/browser/index.ts @@ -21,6 +21,11 @@ export { getRecommendedBrowserUserDataDir, getBrowserAttachOverride, getEffectiveClaudeBrowserAttachConfig, + resolveOptionalBrowserAttachRuntime, +} from './browser-settings'; +export type { + BrowserAttachRuntimeResolution, + EffectiveClaudeBrowserAttachConfig, } from './browser-settings'; export { diff --git a/tests/unit/cliproxy/config-generator-port.test.js b/tests/unit/cliproxy/config-generator-port.test.js index dfbb76d5..af291d00 100644 --- a/tests/unit/cliproxy/config-generator-port.test.js +++ b/tests/unit/cliproxy/config-generator-port.test.js @@ -185,6 +185,24 @@ describe('Config Generator Port', function () { // Port should still be 8325 assert.ok(content.includes(`port: ${variantPort}`)); }); + + it('honors an explicit config path override', function () { + const variantPort = 8318; + const overrideDir = path.join(testHome, '.ccs-scope-override', 'cliproxy'); + const overrideConfigPath = path.join(overrideDir, `config-${variantPort}.yaml`); + const overrideAuthDir = path.join(overrideDir, 'auth'); + + regenerateConfig(variantPort, { + configPath: overrideConfigPath, + authDir: overrideAuthDir, + }); + + assert.ok(fs.existsSync(overrideConfigPath), 'Should create config at the override path'); + assert.ok(fs.existsSync(overrideAuthDir), 'Should create auth dir at the override path'); + + const defaultConfigPath = path.join(cliproxyDir, `config-${variantPort}.yaml`); + assert.strictEqual(fs.existsSync(defaultConfigPath), false); + }); }); describe('deleteConfigForPort', function () { diff --git a/tests/unit/cliproxy/routing-strategy.test.ts b/tests/unit/cliproxy/routing-strategy.test.ts index fca87c39..d61de452 100644 --- a/tests/unit/cliproxy/routing-strategy.test.ts +++ b/tests/unit/cliproxy/routing-strategy.test.ts @@ -6,6 +6,8 @@ import * as path from 'path'; describe('cliproxy routing strategy service', () => { let tempHome = ''; let scopedConfigDir = ''; + let originalCcsDir: string | undefined; + let originalCcsHome: string | undefined; let runWithScopedConfigDir: (ccsDir: string, fn: () => Promise | T) => Promise; let routingTarget = { host: '127.0.0.1', @@ -18,6 +20,10 @@ describe('cliproxy routing strategy service', () => { beforeEach(async () => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-strategy-')); scopedConfigDir = path.join(tempHome, '.ccs'); + originalCcsDir = process.env.CCS_DIR; + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_DIR = scopedConfigDir; + process.env.CCS_HOME = tempHome; ({ runWithScopedConfigDir } = await import('../../../src/utils/config-manager')); }); @@ -25,6 +31,18 @@ describe('cliproxy routing strategy service', () => { afterEach(() => { mock.restore(); + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index 93dd4552..5545f366 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -247,6 +247,50 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); }); + it('skips managed browser attach when the default CCS browser profile directory is missing', () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, + }; + }); + + const result = runCcs(['default', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + expect(result.stderr).toContain('Launching without browser tools'); + expect(result.stderr).toContain('ccs browser doctor'); + + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain('userDataDir='); + expect(launchedEnv).not.toContain('.ccs/browser/chrome-user-data'); + expect(launchedEnv).not.toContain('ws://127.0.0.1/devtools/browser/'); + } 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 b94846fc..653271b1 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -198,6 +198,50 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); }); + it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, + }; + }); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + expect(result.stderr).toContain('Launching without browser tools'); + expect(result.stderr).toContain('ccs browser doctor'); + + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain('userDataDir='); + expect(launchedEnv).not.toContain('.ccs/browser/chrome-user-data'); + expect(launchedEnv).not.toContain('ws://127.0.0.1/devtools/browser/'); + } 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;