Merge pull request #1034 from kaitranntt/kai/hotfix/browser-attach-profile-dir-invalid

hotfix(browser): avoid aborting launch on missing managed profile
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-16 23:40:48 -04:00
commit d5b4dfde1e
11 files changed
+213 -44

No files matched your search

+21 -26
View File
@@ -40,7 +40,7 @@ import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
getEffectiveClaudeBrowserAttachConfig,
resolveBrowserRuntimeEnv,
resolveOptionalBrowserAttachRuntime,
syncBrowserMcpToConfigDir,
} from './utils/browser';
import {
@@ -1057,14 +1057,21 @@ async function main(): Promise<void> {
// 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<void> {
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserAttachConfig?.enabled &&
browserRuntimeEnv &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
@@ -1301,17 +1308,6 @@ async function main(): Promise<void> {
// 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<void> {
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<void> {
if (defaultContinuityInheritance.claudeConfigDir) {
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
if (
browserAttachConfig?.enabled &&
browserRuntimeEnv &&
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
) {
throw new Error(
+12 -3
View File
@@ -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);
+10 -14
View File
@@ -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,
+4 -1
View File
@@ -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<CliproxyRoutingApplyResult> {
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);
+36
View File
@@ -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<BrowserAttachRuntimeResolution> {
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;
+1
View File
@@ -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;
+5
View File
@@ -21,6 +21,11 @@ export {
getRecommendedBrowserUserDataDir,
getBrowserAttachOverride,
getEffectiveClaudeBrowserAttachConfig,
resolveOptionalBrowserAttachRuntime,
} from './browser-settings';
export type {
BrowserAttachRuntimeResolution,
EffectiveClaudeBrowserAttachConfig,
} from './browser-settings';
export {
@@ -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 () {
@@ -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: <T>(ccsDir: string, fn: () => Promise<T> | T) => Promise<T>;
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 });
}
@@ -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;
@@ -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;