feat(cli): add browser automation commands

This commit is contained in:
Tam Nhu Tran
2026-04-16 18:49:24 -04:00
parent b6aef885ad
commit 4e30c9b080
42 changed files with 3028 additions and 32 deletions
+24 -14
View File
@@ -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<typeof resolveTargetType>
): string[] {
if (target !== 'codex') {
if (target !== 'codex' || !getBrowserConfig().codex.enabled) {
return [];
}
return buildCodexBrowserMcpOverrides();
@@ -1054,13 +1058,13 @@ async function main(): Promise<void> {
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<void> {
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserProfileDir &&
browserAttachConfig?.enabled &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
@@ -1297,10 +1301,13 @@ 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 (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<void> {
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<void> {
if (defaultContinuityInheritance.claudeConfigDir) {
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
if (
browserProfileDir &&
browserAttachConfig?.enabled &&
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
) {
throw new Error(
+14 -7
View File
@@ -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;
+155
View File
@@ -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<void> {
await initUI();
writeLine(header('CCS Browser Help'));
writeLine('');
writeIntro(writeLine);
writeLine(subheader('Usage'));
writeLine(` ${color('ccs browser <status|doctor>', '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<void> {
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;
}
}
+15 -1
View File
@@ -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<Record<string, readonly string[]
config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'],
cursor: ['--help', '-h'],
doctor: ['--fix', '-f', '--help', '-h'],
browser: ['status', 'doctor', '--help', '-h'],
docker: ['--help', '-h', '--host'],
env: ['--format', '--shell', '--ide', '--help', '-h'],
migrate: MIGRATE_FLAGS,
+7 -1
View File
@@ -249,7 +249,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
writeLine(header(`CCS CLI v${packageJson.version}`));
writeLine('');
writeLine(' Claude profile switching, provider routing, and compatible runtime bridges.');
writeLine(' Claude profile switching, provider routing, runtime bridges, and browser tooling.');
writeLine('');
writeLine(subheader('Usage'));
@@ -279,6 +279,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
[
{ name: 'ccs help profiles', summary: getTopicSummary('profiles') },
{ name: 'ccs help providers', summary: getTopicSummary('providers') },
{ name: 'ccs help browser', summary: getTopicSummary('browser') },
{ name: 'ccs help completion', summary: getTopicSummary('completion') },
{ name: 'ccs help targets', summary: getTopicSummary('targets') },
{ name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' },
@@ -328,6 +329,10 @@ export async function handleHelpRoute(
await showTargetsHelp(writeLine);
return;
}
if (topic === 'browser') {
await (await import('./browser-command')).showBrowserHelp(writeLine);
return;
}
if (topic === 'completion') {
const { showShellCompletionHelp } = await import('./shell-completion-command');
showShellCompletionHelp(writeLine);
@@ -342,6 +347,7 @@ export async function handleHelpRoute(
await new AuthCommands().showHelp();
},
cleanup: async () => (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'])),
+7
View File
@@ -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'],
+56
View File
@@ -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>): 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.
+40
View File
@@ -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 },
};
}
+100
View File
@@ -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;
}
+193
View File
@@ -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<BrowserStatusPayload> {
const browserConfig = getBrowserConfig();
return {
claude: await buildClaudeBrowserStatus(browserConfig),
codex: buildCodexBrowserStatus(browserConfig),
};
}
async function buildClaudeBrowserStatus(
browserConfig = getBrowserConfig()
): Promise<ClaudeBrowserStatus> {
const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort);
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
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';
}
+13
View File
@@ -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';
+136
View File
@@ -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<void> => {
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<void> => {
try {
res.json(await getBrowserStatus());
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
router.put('/', async (req: Request, res: Response): Promise<void> => {
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<typeof getBrowserConfig>) {
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;
+2
View File
@@ -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 ====================
@@ -114,6 +114,7 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record<string, CompatibleCliDocsRegistryEntr
'CLI --profile selects a named [profiles.<name>] 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: [
{