feat(browser): 接入 CCS browser MCP 与最小交互闭环

新增 browser MCP 安装、Chrome 复用与运行时接线,
实现 navigate、click、type、take_screenshot 四个 phase-1 工具并补齐相关单测。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
walkerandClaude Opus 4.6 authored and walker1211 committed 2026-04-13 11:19:16 +08:00
1 parent efecd7c7d9
commit eb8149e8fa
29 files changed
+3627 -118

No files matched your search

+75 -2
View File
@@ -36,6 +36,13 @@ import {
syncImageAnalysisMcpToConfigDir,
appendThirdPartyImageAnalysisToolArgs,
} from './utils/image-analysis';
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
resolveBrowserRuntimeEnv,
resolveConfiguredBrowserProfileDir,
syncBrowserMcpToConfigDir,
} from './utils/browser';
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
@@ -69,6 +76,7 @@ import { execClaude } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
// Import target adapter system
import {
@@ -117,6 +125,15 @@ interface RuntimeReasoningResolution {
const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
function resolveCodexRuntimeConfigOverrides(
target: ReturnType<typeof resolveTargetType>
): string[] {
if (target !== 'codex') {
return [];
}
return buildCodexBrowserMcpOverrides();
}
/**
* Smart profile detection
*/
@@ -624,6 +641,7 @@ async function main(): Promise<void> {
// For non-claude targets, verify target binary exists once and pass it through.
const targetBinaryInfo = targetAdapter?.detectBinary() ?? null;
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(resolvedTarget);
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
const displayName = targetAdapter?.displayName || resolvedTarget;
console.error(fail(`${displayName} CLI not found.`));
@@ -867,6 +885,7 @@ async function main(): Promise<void> {
model: envVars['ANTHROPIC_MODEL'],
}),
reasoningOverride: runtimeReasoningOverride,
runtimeConfigOverrides: codexRuntimeConfigOverrides,
envVars,
};
@@ -982,8 +1001,16 @@ 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 browserProfileDir =
resolvedTarget === 'claude'
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
: undefined;
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
if (browserProfileDir) {
ensureBrowserMcpOrThrow();
}
}
// Display WebSearch status (single line, equilibrium UX)
@@ -1007,6 +1034,15 @@ async function main(): Promise<void> {
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserProfileDir &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
throw new Error(
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
);
}
const expandedSettingsPath =
resolvedSettingsPath ??
(profileInfo.settingsPath
@@ -1208,12 +1244,21 @@ 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) {
browserRuntimeEnv = {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
})),
};
}
const envVars: NodeJS.ProcessEnv = {
...globalEnv,
...settingsEnv,
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
...(browserRuntimeEnv || {}),
CCS_PROFILE_TYPE: 'settings',
};
@@ -1238,6 +1283,7 @@ async function main(): Promise<void> {
model: settingsEnv['ANTHROPIC_MODEL'],
}),
reasoningOverride: runtimeReasoningOverride,
runtimeConfigOverrides: codexRuntimeConfigOverrides,
envVars,
};
await adapter.prepareCredentials(creds);
@@ -1254,10 +1300,13 @@ async function main(): Promise<void> {
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(remainingArgs)
: remainingArgs;
const browserArgs = browserRuntimeEnv
? appendBrowserToolArgs(imageAnalysisArgs)
: imageAnalysisArgs;
const launchArgs = [
'--settings',
expandedSettingsPath,
...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile',
@@ -1314,8 +1363,22 @@ async function main(): Promise<void> {
CCS_WEBSEARCH_SKIP: '1',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
const browserProfileDir =
resolvedTarget === 'claude'
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
: undefined;
if (resolvedTarget === 'claude') {
if (browserProfileDir) {
ensureBrowserMcpOrThrow();
browserRuntimeEnv = {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
})),
};
Object.assign(envVars, browserRuntimeEnv);
}
const defaultContinuityInheritance = await resolveProfileContinuityInheritance({
profileName: profileInfo.name,
profileType: profileInfo.type,
@@ -1330,6 +1393,14 @@ async function main(): Promise<void> {
}
if (defaultContinuityInheritance.claudeConfigDir) {
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
if (
browserProfileDir &&
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
) {
throw new Error(
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
);
}
}
}
@@ -1355,6 +1426,8 @@ async function main(): Promise<void> {
model: process.env['ANTHROPIC_MODEL'],
}),
reasoningOverride: runtimeReasoningOverride,
runtimeConfigOverrides: codexRuntimeConfigOverrides,
browserRuntimeEnv,
};
if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) {
console.error(
@@ -1377,7 +1450,7 @@ async function main(): Promise<void> {
}
const launchArgs = resolveNativeClaudeLaunchArgs(
remainingArgs,
browserRuntimeEnv ? appendBrowserToolArgs(remainingArgs) : remainingArgs,
'default',
envVars.CLAUDE_CONFIG_DIR
);
+4
View File
@@ -74,6 +74,8 @@ export interface ProxyChainConfig {
claudeConfigDir?: string;
/** Execution-aware image analysis env prepared by the caller */
imageAnalysisEnv?: Record<string, string>;
/** Optional browser runtime env for Claude browser MCP reuse. */
browserRuntimeEnv?: Record<string, string>;
}
interface CliproxyImageAnalysisDeps {
@@ -240,6 +242,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
compositeDefaultTier,
claudeConfigDir,
imageAnalysisEnv: resolvedImageAnalysisEnv,
browserRuntimeEnv,
} = config;
// Build base env vars - check remote mode first
@@ -394,6 +397,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
...(browserRuntimeEnv || {}),
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
};
+41 -1
View File
@@ -63,6 +63,13 @@ import {
syncImageAnalysisMcpToConfigDir,
appendThirdPartyImageAnalysisToolArgs,
} from '../../utils/image-analysis';
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
resolveBrowserRuntimeEnv,
resolveConfiguredBrowserProfileDir,
syncBrowserMcpToConfigDir,
} from '../../utils/browser';
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import {
@@ -245,6 +252,10 @@ 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) {
ensureBrowserMcpOrThrow();
}
displayWebSearchStatus();
const providerConfig = getProviderConfig(provider);
@@ -1006,6 +1017,15 @@ export async function execClaudeWithCLIProxy(
}
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserProfileDir &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
throw new Error(
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
);
}
// Build initial env vars to get ANTHROPIC_BASE_URL
const initialEnvVars = buildClaudeEnvironment({
@@ -1101,6 +1121,14 @@ export async function execClaudeWithCLIProxy(
}
// 11. Build final environment with all proxy chains
const browserRuntimeEnv = browserProfileDir
? {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
})),
}
: undefined;
const env = buildClaudeEnvironment({
provider,
useRemoteProxy,
@@ -1128,6 +1156,7 @@ export async function execClaudeWithCLIProxy(
compositeDefaultTier: cfg.compositeDefaultTier,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
browserRuntimeEnv,
});
if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) {
@@ -1143,6 +1172,14 @@ export async function execClaudeWithCLIProxy(
}
const webSearchEnv = getWebSearchHookEnv();
if (process.env.CCS_DEBUG) {
console.error(
`[cliproxy-browser-debug] keys=${Object.keys(env)
.filter((key) => key.startsWith('CCS_BROWSER_'))
.sort()
.join(',')} ws=${env.CCS_BROWSER_DEVTOOLS_WS_URL || ''}`
);
}
logEnvironment(env, webSearchEnv, verbose);
if (imageAnalysisWarning) {
console.error(info(imageAnalysisWarning));
@@ -1222,10 +1259,13 @@ export async function execClaudeWithCLIProxy(
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
: claudeArgs;
const browserArgs = browserRuntimeEnv
? appendBrowserToolArgs(imageAnalysisArgs)
: imageAnalysisArgs;
const launchArgs = [
'--settings',
settingsPath,
...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'cliproxy.executor',
+2
View File
@@ -229,6 +229,8 @@ export interface ExecutorConfig {
profileName?: string;
/** Optional inherited continuity directory from mapped account profile */
claudeConfigDir?: string;
/** Optional browser runtime env for Claude browser MCP reuse. */
browserRuntimeEnv?: Record<string, string>;
}
/**
+2 -1
View File
@@ -121,9 +121,10 @@ export function loadMigrationCheckData(): MigrationCheckData {
if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) {
const legacyProfiles = legacyConfig.profiles as Record<string, unknown>;
const unifiedProfiles = unifiedConfig.profiles ?? {};
for (const profileName of Object.keys(legacyProfiles)) {
const targetProfileName = resolveAliasToCanonical(profileName);
if (!unifiedConfig.profiles[targetProfileName]) {
if (!unifiedProfiles[targetProfileName]) {
needsMigration = true;
break;
}
+5 -2
View File
@@ -14,7 +14,7 @@ import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
import type { AccountContextPolicy } from '../auth/account-context';
import { getCcsDir, getCcsHome } from '../utils/config-manager';
const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis']);
const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis', 'ccs-browser']);
/** Options for instance creation */
export interface InstanceOptions {
@@ -252,9 +252,12 @@ class InstanceManager {
}
instanceContent.mcpServers = mergedMcpServers;
const fileMode = fs.existsSync(instanceClaudeJson)
? fs.statSync(instanceClaudeJson).mode & 0o777
: 0o600;
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), {
encoding: 'utf8',
mode: 0o600,
mode: fileMode,
});
return true;
} catch (error) {
+14 -2
View File
@@ -12,6 +12,7 @@ import type { ProfileType } from '../types/profile';
import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from '../utils/shell-executor';
import { ErrorManager } from '../utils/error-manager';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { appendBrowserToolArgs } from '../utils/browser';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { runCleanup } from '../errors';
@@ -32,8 +33,16 @@ export class ClaudeAdapter implements TargetAdapter {
// No-op: Claude receives credentials via environment variables
}
buildArgs(_profile: string, userArgs: string[]): string[] {
return userArgs;
buildArgs(
_profile: string,
userArgs: string[],
options?: {
creds?: TargetCredentials;
profileType?: ProfileType;
binaryInfo?: TargetBinaryInfo;
}
): string[] {
return options?.creds?.browserRuntimeEnv ? appendBrowserToolArgs(userArgs) : userArgs;
}
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
@@ -51,6 +60,9 @@ export class ClaudeAdapter implements TargetAdapter {
if (creds.envVars) {
Object.assign(env, creds.envVars);
}
if (creds.browserRuntimeEnv) {
Object.assign(env, creds.browserRuntimeEnv);
}
if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl;
if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey;
+13 -8
View File
@@ -188,20 +188,23 @@ export class CodexAdapter implements TargetAdapter {
const profileType = options?.profileType || 'default';
const creds = options?.creds;
const reasoningOverride = normalizeCodexReasoningOverride(creds?.reasoningOverride);
const runtimeConfigOverrides = creds?.runtimeConfigOverrides ?? [];
if (profileType === 'default') {
const overrides = [...runtimeConfigOverrides];
if (reasoningOverride) {
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
}
if (overrides.length === 0) {
return userArgs;
}
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
if (reasoningOverride) {
throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo));
}
return [
...buildConfigOverrideArgs([
`model_reasoning_effort=${formatTomlString(reasoningOverride)}`,
]),
...userArgs,
];
return userArgs;
}
return userArgs;
return [...buildConfigOverrideArgs(overrides), ...userArgs];
}
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
@@ -233,6 +236,8 @@ export class CodexAdapter implements TargetAdapter {
overrides.push(`model=${formatTomlString(creds.model)}`);
}
overrides.push(...runtimeConfigOverrides);
if (reasoningOverride) {
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
}
+4
View File
@@ -29,8 +29,12 @@ export interface TargetCredentials {
* Targets may ignore this when unsupported.
*/
reasoningOverride?: string | number;
/** Target-native runtime config overrides (for example Codex -c key=value entries). */
runtimeConfigOverrides?: string[];
/** Additional env vars from profile resolution (websearch, hooks, etc.) */
envVars?: NodeJS.ProcessEnv;
/** Runtime browser reuse env passed through to Claude launches when browser MCP is active. */
browserRuntimeEnv?: NodeJS.ProcessEnv;
}
/**
+28
View File
@@ -0,0 +1,28 @@
const CODEX_BROWSER_MCP_SERVER_NAME = 'ccs_browser';
const DEFAULT_BROWSER_TOOL_TIMEOUT_SEC = 30;
const PLAYWRIGHT_MCP_PACKAGE = '@playwright/mcp@0.0.70';
function formatTomlString(value: string): string {
return JSON.stringify(value);
}
function formatTomlArray(values: string[]): string {
return JSON.stringify(values);
}
function getNpxCommand(): string {
return process.platform === 'win32' ? 'npx.cmd' : 'npx';
}
export function getCodexBrowserMcpServerName(): string {
return CODEX_BROWSER_MCP_SERVER_NAME;
}
export function buildCodexBrowserMcpOverrides(): string[] {
return [
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.command=${formatTomlString(getNpxCommand())}`,
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.args=${formatTomlArray(['-y', PLAYWRIGHT_MCP_PACKAGE])}`,
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.enabled=true`,
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.tool_timeout_sec=${DEFAULT_BROWSER_TOOL_TIMEOUT_SEC}`,
];
}
+238
View File
@@ -0,0 +1,238 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { expandPath } from '../helpers';
export interface BrowserReuseOptions {
profileDir?: string;
devtoolsPort?: string;
}
export interface BrowserRuntimeEnv {
CCS_BROWSER_USER_DATA_DIR: string;
CCS_BROWSER_DEVTOOLS_HOST: string;
CCS_BROWSER_DEVTOOLS_PORT: string;
CCS_BROWSER_DEVTOOLS_HTTP_URL: string;
CCS_BROWSER_DEVTOOLS_WS_URL: string;
}
const DEVTOOLS_HOST = '127.0.0.1';
const DEVTOOLS_ACTIVE_PORT_FILE = 'DevToolsActivePort';
export function resolveDefaultChromeUserDataDir(platform = process.platform): string {
switch (platform) {
case 'darwin':
return path.join(
process.env.HOME || process.env.USERPROFILE || '',
'Library',
'Application Support',
'Google',
'Chrome'
);
case 'linux':
return path.join(
process.env.HOME || process.env.USERPROFILE || '',
'.config',
'google-chrome'
);
case 'win32': {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) {
throw new Error(
'LOCALAPPDATA is required to resolve the default Chrome user-data-dir on Windows'
);
}
return path.join(localAppData, 'Google', 'Chrome', 'User Data');
}
default:
return path.join(
process.env.HOME || process.env.USERPROFILE || '',
'.config',
'google-chrome'
);
}
}
export function resolveConfiguredBrowserProfileDir(profileDir?: string): string | undefined {
if (profileDir?.trim()) {
return expandPath(profileDir);
}
try {
const defaultUserDataDir = resolveDefaultChromeUserDataDir();
return fs.existsSync(path.join(defaultUserDataDir, DEVTOOLS_ACTIVE_PORT_FILE))
? defaultUserDataDir
: undefined;
} catch {
return undefined;
}
}
export async function resolveBrowserRuntimeEnv(
options: BrowserReuseOptions
): Promise<BrowserRuntimeEnv> {
const requestedProfileDir = options.profileDir || resolveDefaultChromeUserDataDir();
const userDataDir = expandPath(requestedProfileDir);
const directoryStat = await safeStat(userDataDir);
if (!directoryStat?.isDirectory()) {
throw new Error(`Chrome profile directory is invalid: ${userDataDir}`);
}
const metadataPath = path.join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
const port = await resolveDevToolsPort({
userDataDir,
metadataPath,
explicitPort: options.devtoolsPort || process.env.CCS_BROWSER_DEVTOOLS_PORT,
});
const httpUrl = `http://${DEVTOOLS_HOST}:${port}`;
const versionUrl = `${httpUrl}/json/version`;
let versionPayload: unknown;
try {
const response = await fetch(versionUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
versionPayload = await response.json();
} catch {
throw new Error(`Chrome DevTools endpoint is unreachable: ${httpUrl}`);
}
const websocketUrl =
typeof versionPayload === 'object' &&
versionPayload !== null &&
typeof (versionPayload as Record<string, unknown>).webSocketDebuggerUrl === 'string'
? (versionPayload as Record<string, string>).webSocketDebuggerUrl
: '';
if (!websocketUrl) {
throw new Error(`Chrome DevTools endpoint did not provide a websocket target: ${versionUrl}`);
}
return {
CCS_BROWSER_USER_DATA_DIR: userDataDir,
CCS_BROWSER_DEVTOOLS_HOST: DEVTOOLS_HOST,
CCS_BROWSER_DEVTOOLS_PORT: port,
CCS_BROWSER_DEVTOOLS_HTTP_URL: httpUrl,
CCS_BROWSER_DEVTOOLS_WS_URL: websocketUrl,
};
}
function parseDevToolsPort(metadataPath: string, metadata: string): string {
const [rawPort] = metadata
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (!rawPort || !/^\d+$/.test(rawPort)) {
throw new Error(`Chrome reuse metadata is invalid: ${metadataPath}`);
}
const port = Number.parseInt(rawPort, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Chrome reuse metadata is invalid: ${metadataPath}`);
}
return String(port);
}
interface ResolveDevToolsPortOptions {
userDataDir: string;
metadataPath: string;
explicitPort?: string;
}
async function resolveDevToolsPort(options: ResolveDevToolsPortOptions): Promise<string> {
if (options.explicitPort?.trim()) {
return parsePortValue(options.explicitPort.trim(), 'CCS_BROWSER_DEVTOOLS_PORT');
}
let metadata: string | undefined;
try {
metadata = await fs.promises.readFile(options.metadataPath, 'utf8');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code && code !== 'ENOENT') {
throw new Error(`Chrome reuse metadata is unreadable: ${options.metadataPath}`);
}
}
if (metadata !== undefined) {
return parseDevToolsPort(options.metadataPath, metadata);
}
const discoveredPort = discoverDevToolsPortFromChromeProcess(options.userDataDir);
if (discoveredPort) {
return discoveredPort;
}
throw new Error(`Chrome reuse metadata not found: ${options.metadataPath}`);
}
function parsePortValue(rawPort: string, sourceLabel: string): string {
if (!/^\d+$/.test(rawPort)) {
throw new Error(`Chrome reuse metadata is invalid: ${sourceLabel}`);
}
const port = Number.parseInt(rawPort, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Chrome reuse metadata is invalid: ${sourceLabel}`);
}
return String(port);
}
function discoverDevToolsPortFromChromeProcess(userDataDir: string): string | undefined {
try {
const processList = execFileSync('ps', ['-axww', '-o', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
const lines = processList
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const normalizedDir = normalizePathForComparison(userDataDir);
for (const line of lines) {
if (!line.includes('Chrome') && !line.includes('chromium')) {
continue;
}
if (!matchesUserDataDir(line, normalizedDir)) {
continue;
}
const match = line.match(/--remote-debugging-port=(\d+)/);
if (match?.[1]) {
return parsePortValue(match[1], 'process-list');
}
}
} catch {
return undefined;
}
return undefined;
}
function matchesUserDataDir(commandLine: string, normalizedDir: string): boolean {
const candidates = [
`--user-data-dir=${normalizedDir}`,
`--user-data-dir="${normalizedDir}"`,
`--user-data-dir='${normalizedDir}'`,
];
const normalizedCommand = normalizePathForComparison(commandLine);
return candidates.some((candidate) => normalizedCommand.includes(candidate));
}
function normalizePathForComparison(value: string): string {
return process.platform === 'win32' ? value.toLowerCase() : value;
}
async function safeStat(targetPath: string): Promise<fs.Stats | null> {
try {
return await fs.promises.stat(targetPath);
} catch {
return null;
}
}
+22
View File
@@ -0,0 +1,22 @@
import {
hasExactFlagValue as hasExactClaudeFlagValue,
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
} from '../claude-tool-args';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const BROWSER_STEERING_PROMPT =
'For DOM/screenshots/elements/page actions, prefer the CCS MCP Browser tool, reuse the configured running Chrome context whenever possible, and if the tool or context is unavailable, explain that clearly instead of pretending page state is available.';
function ensureBrowserSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
if (hasExactClaudeFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, BROWSER_STEERING_PROMPT)) {
return args;
}
return [...optionArgs, APPEND_SYSTEM_PROMPT_FLAG, BROWSER_STEERING_PROMPT, ...trailingArgs];
}
export function appendBrowserToolArgs(args: string[]): string[] {
return ensureBrowserSteeringPrompt(args);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Browser Utilities
*/
export {
getBrowserMcpServerName,
getBrowserMcpServerPath,
installBrowserMcpServer,
ensureBrowserMcpConfig,
ensureBrowserMcp,
uninstallBrowserMcpServer,
removeBrowserMcpConfig,
uninstallBrowserMcp,
syncBrowserMcpToConfigDir,
ensureBrowserMcpOrThrow,
} from './mcp-installer';
export { appendBrowserToolArgs } from './claude-tool-args';
export {
resolveBrowserRuntimeEnv,
resolveDefaultChromeUserDataDir,
resolveConfiguredBrowserProfileDir,
} from './chrome-reuse';
export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse';
+375
View File
@@ -0,0 +1,375 @@
/**
* Browser MCP installer and ~/.claude.json provisioning.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { InstanceManager } from '../../management/instance-manager';
import { getClaudeUserConfigPath } from '../claude-config-path';
import { getCcsDir } from '../config-manager';
const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs';
const BROWSER_MCP_SERVER_NAME = 'ccs-browser';
interface ClaudeUserConfig {
mcpServers?: Record<string, unknown>;
[key: string]: unknown;
}
interface ManagedBrowserMcpConfig {
type: 'stdio';
command: 'node';
args: [string];
env: Record<string, string>;
}
function resolvePackageRoot(fromPath: string): string | null {
let currentDir = path.dirname(fromPath);
while (true) {
if (fs.existsSync(path.join(currentDir, 'package.json'))) {
return currentDir;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
return null;
}
currentDir = parentDir;
}
}
function getBrowserMcpServerEnv(): Record<string, string> {
const sourcePath = resolveBundledServerSourcePath();
if (!sourcePath) {
return {};
}
const packageRoot = resolvePackageRoot(sourcePath);
if (!packageRoot) {
return {};
}
const nodeModulesPath = path.join(packageRoot, 'node_modules');
return fs.existsSync(nodeModulesPath) ? { NODE_PATH: nodeModulesPath } : {};
}
function getCcsMcpDir(): string {
return path.join(getCcsDir(), 'mcp');
}
export function getBrowserMcpServerName(): string {
return BROWSER_MCP_SERVER_NAME;
}
export function getBrowserMcpServerPath(): string {
return path.join(getCcsMcpDir(), BROWSER_MCP_SERVER);
}
function hasMatchingContents(sourcePath: string, destinationPath: string): boolean {
if (!fs.existsSync(destinationPath)) {
return false;
}
const source = fs.readFileSync(sourcePath);
try {
const destination = fs.readFileSync(destinationPath);
return source.equals(destination);
} catch {
return false;
}
}
function getTempPath(targetPath: string): string {
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${targetPath}.${suffix}.tmp`;
}
function resolveBundledServerSourcePath(): string | null {
const possiblePaths = [
path.join(__dirname, '..', '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
path.join(__dirname, '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
path.join(__dirname, '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
];
for (const candidate of possiblePaths) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null {
if (!fs.existsSync(configPath)) {
return {};
}
try {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as ClaudeUserConfig;
} catch {
return null;
}
}
function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean {
const tempPath = getTempPath(configPath);
const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600;
try {
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
fs.chmodSync(tempPath, fileMode);
fs.renameSync(tempPath, configPath);
return true;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
const configDir = path.dirname(configPath);
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
let release: (() => void) | undefined;
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
if (!fs.existsSync(lockTarget)) {
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
}
try {
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
return callback();
} finally {
if (release) {
try {
release();
} catch {
// Best-effort release.
}
}
}
}
function isLockUnavailableError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
}
function removeManagedServerConfig(configPath: string): boolean {
if (!fs.existsSync(configPath)) {
return false;
}
try {
return withClaudeUserConfigLock(configPath, () => {
const config = readClaudeUserConfig(configPath);
if (config === null) {
return false;
}
const existingServers =
config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
? { ...(config.mcpServers as Record<string, unknown>) }
: {};
if (!(BROWSER_MCP_SERVER_NAME in existingServers)) {
return false;
}
delete existingServers[BROWSER_MCP_SERVER_NAME];
const nextConfig: ClaudeUserConfig = { ...config };
if (Object.keys(existingServers).length === 0) {
delete nextConfig.mcpServers;
} else {
nextConfig.mcpServers = existingServers;
}
try {
return writeClaudeUserConfig(configPath, nextConfig);
} catch {
return false;
}
});
} catch (error) {
if (isLockUnavailableError(error)) {
return false;
}
throw error;
}
}
export function installBrowserMcpServer(): boolean {
const sourcePath = resolveBundledServerSourcePath();
if (!sourcePath) {
return false;
}
const mcpDir = getCcsMcpDir();
if (!fs.existsSync(mcpDir)) {
fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 });
}
const serverPath = getBrowserMcpServerPath();
const sourceMode = fs.statSync(sourcePath).mode & 0o777;
if (hasMatchingContents(sourcePath, serverPath)) {
if ((fs.statSync(serverPath).mode & 0o777) !== sourceMode) {
fs.chmodSync(serverPath, sourceMode);
}
return true;
}
const tempPath = getTempPath(serverPath);
try {
fs.copyFileSync(sourcePath, tempPath);
fs.chmodSync(tempPath, sourceMode);
try {
fs.renameSync(tempPath, serverPath);
} catch (renameError) {
const errorCode = (renameError as NodeJS.ErrnoException).code;
if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') {
throw renameError;
}
if (!hasMatchingContents(sourcePath, serverPath)) {
fs.copyFileSync(tempPath, serverPath);
fs.chmodSync(serverPath, sourceMode);
}
}
return true;
} catch {
return false;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
export function ensureBrowserMcpConfig(): boolean {
const claudeUserConfigPath = getClaudeUserConfigPath();
const claudeUserConfigDir = path.dirname(claudeUserConfigPath);
if (!fs.existsSync(claudeUserConfigDir)) {
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
}
const desiredServerConfig: ManagedBrowserMcpConfig = {
type: 'stdio',
command: 'node',
args: [getBrowserMcpServerPath()],
env: getBrowserMcpServerEnv(),
};
try {
return withClaudeUserConfigLock(claudeUserConfigPath, () => {
const config = readClaudeUserConfig(claudeUserConfigPath);
if (config === null) {
return false;
}
const existingServers =
config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>)
: {};
const currentConfig = existingServers[BROWSER_MCP_SERVER_NAME];
if (
typeof currentConfig === 'object' &&
currentConfig !== null &&
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
) {
return true;
}
const nextConfig: ClaudeUserConfig = {
...config,
mcpServers: {
...existingServers,
[BROWSER_MCP_SERVER_NAME]: desiredServerConfig,
},
};
try {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
return true;
} catch {
return false;
}
});
} catch (error) {
if (isLockUnavailableError(error)) {
return false;
}
throw error;
}
}
export function ensureBrowserMcp(): boolean {
const installed = installBrowserMcpServer();
const configured = installed && ensureBrowserMcpConfig();
return installed && configured;
}
export function syncBrowserMcpToConfigDir(claudeConfigDir: string | undefined): boolean {
if (!claudeConfigDir) {
return false;
}
return new InstanceManager().syncMcpServers(claudeConfigDir);
}
export function uninstallBrowserMcpServer(): boolean {
const serverPath = getBrowserMcpServerPath();
if (!fs.existsSync(serverPath)) {
return false;
}
try {
fs.unlinkSync(serverPath);
return true;
} catch {
return false;
}
}
export function removeBrowserMcpConfig(): boolean {
let removed = removeManagedServerConfig(getClaudeUserConfigPath());
const instanceManager = new InstanceManager();
for (const instanceName of instanceManager.listInstances()) {
const instancePath = instanceManager.getInstancePath(instanceName);
const instanceClaudeConfigPath = path.join(instancePath, '.claude.json');
removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed;
}
return removed;
}
export function uninstallBrowserMcp(): boolean {
const removedConfig = removeBrowserMcpConfig();
const removedServer = uninstallBrowserMcpServer();
return removedConfig || removedServer;
}
export function ensureBrowserMcpOrThrow(): boolean {
const ready = ensureBrowserMcp();
if (!ready) {
throw new Error('Browser MCP is enabled, but CCS could not prepare the local browser tool.');
}
return ready;
}
+46
View File
@@ -0,0 +1,46 @@
export function splitArgsAtTerminator(args: string[]): {
optionArgs: string[];
trailingArgs: string[];
} {
const terminatorIndex = args.indexOf('--');
if (terminatorIndex === -1) {
return { optionArgs: args, trailingArgs: [] };
}
return {
optionArgs: args.slice(0, terminatorIndex),
trailingArgs: args.slice(terminatorIndex),
};
}
export function getImmediateFlagValue(args: string[], index: number): string | null {
const value = args[index + 1];
if (value === undefined || value === '--' || value.startsWith('--')) {
return null;
}
return value;
}
export function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value === expectedValue) {
return true;
}
continue;
}
if (arg === `${flag}=${expectedValue}`) {
return true;
}
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
return true;
}
}
return false;
}
+9 -46
View File
@@ -2,58 +2,21 @@
* Claude launch argument helpers for first-class Image Analysis.
*/
import {
hasExactFlagValue as hasExactClaudeFlagValue,
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
} from '../claude-tool-args';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const IMAGE_ANALYSIS_STEERING_PROMPT =
'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.';
function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } {
const terminatorIndex = args.indexOf('--');
if (terminatorIndex === -1) {
return { optionArgs: args, trailingArgs: [] };
}
return {
optionArgs: args.slice(0, terminatorIndex),
trailingArgs: args.slice(terminatorIndex),
};
}
function getImmediateFlagValue(args: string[], index: number): string | null {
const value = args[index + 1];
if (value === undefined || value === '--' || value.startsWith('--')) {
return null;
}
return value;
}
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value === expectedValue) {
return true;
}
continue;
}
if (arg === `${flag}=${expectedValue}`) {
return true;
}
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
return true;
}
}
return false;
}
function ensureImageAnalysisSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
if (hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)) {
if (
hasExactClaudeFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)
) {
return args;
}
+13 -47
View File
@@ -2,6 +2,12 @@
* Claude launch argument helpers for third-party WebSearch.
*/
import {
getImmediateFlagValue,
hasExactFlagValue as hasExactClaudeFlagValue,
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
} from '../claude-tool-args';
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
@@ -23,26 +29,6 @@ function mergeToolValues(rawValues: string[], toolName: string): string {
return merged.join(',');
}
function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } {
const terminatorIndex = args.indexOf('--');
if (terminatorIndex === -1) {
return { optionArgs: args, trailingArgs: [] };
}
return {
optionArgs: args.slice(0, terminatorIndex),
trailingArgs: args.slice(terminatorIndex),
};
}
function getImmediateFlagValue(args: string[], index: number): string | null {
const value = args[index + 1];
if (value === undefined || value === '--' || value.startsWith('--')) {
return null;
}
return value;
}
function hasToolInFlag(args: string[], flag: string, toolName: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
@@ -68,32 +54,8 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean
return false;
}
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value === expectedValue) {
return true;
}
continue;
}
if (arg === `${flag}=${expectedValue}`) {
return true;
}
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
return true;
}
}
return false;
}
function ensureDisallowedNativeWebSearchTool(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
if (hasToolInFlag(optionArgs, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) {
return args;
@@ -132,10 +94,14 @@ function ensureDisallowedNativeWebSearchTool(args: string[]): string[] {
}
function ensureWebSearchSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
if (
hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, THIRD_PARTY_WEBSEARCH_STEERING_PROMPT)
hasExactClaudeFlagValue(
optionArgs,
APPEND_SYSTEM_PROMPT_FLAG,
THIRD_PARTY_WEBSEARCH_STEERING_PROMPT
)
) {
return args;
}