mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 10:18:14 +00:00
chore: merge dev into pr-976 branch
This commit is contained in:
commit
31c464a3c5
43 files changed
+4187
-189
No files matched your search
+75
-3
@@ -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
|
||||
@@ -1209,12 +1245,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',
|
||||
};
|
||||
|
||||
@@ -1239,6 +1284,7 @@ async function main(): Promise<void> {
|
||||
model: settingsEnv['ANTHROPIC_MODEL'],
|
||||
}),
|
||||
reasoningOverride: runtimeReasoningOverride,
|
||||
runtimeConfigOverrides: codexRuntimeConfigOverrides,
|
||||
envVars,
|
||||
};
|
||||
await adapter.prepareCredentials(creds);
|
||||
@@ -1255,11 +1301,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',
|
||||
@@ -1317,8 +1365,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,
|
||||
@@ -1333,6 +1395,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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1358,6 +1428,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(
|
||||
@@ -1380,7 +1452,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const launchArgs = resolveNativeClaudeLaunchArgs(
|
||||
remainingArgs,
|
||||
browserRuntimeEnv ? appendBrowserToolArgs(remainingArgs) : remainingArgs,
|
||||
'default',
|
||||
envVars.CLAUDE_CONFIG_DIR
|
||||
);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -483,7 +483,6 @@ export class ToolSanitizationProxy {
|
||||
port: upstreamUrl.port,
|
||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
||||
method: originalReq.method,
|
||||
timeout: this.config.timeoutMs,
|
||||
headers: this.buildForwardHeaders(originalReq.headers),
|
||||
},
|
||||
(upstreamRes) => {
|
||||
@@ -520,7 +519,6 @@ export class ToolSanitizationProxy {
|
||||
port: upstreamUrl.port,
|
||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
||||
method: originalReq.method,
|
||||
timeout: this.config.timeoutMs,
|
||||
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
||||
},
|
||||
(upstreamRes) => {
|
||||
@@ -594,7 +592,6 @@ export class ToolSanitizationProxy {
|
||||
port: upstreamUrl.port,
|
||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
||||
method: originalReq.method,
|
||||
timeout: this.config.timeoutMs,
|
||||
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
||||
},
|
||||
(upstreamRes) => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-d
|
||||
import type { ProfileType } from '../types/profile';
|
||||
import { upsertCcsModel } from './droid-config-manager';
|
||||
import { resolveDroidProvider } from './droid-provider';
|
||||
import { escapeShellArg } from '../utils/shell-executor';
|
||||
import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor';
|
||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||
import { runCleanup } from '../errors';
|
||||
|
||||
@@ -74,10 +74,11 @@ export class DroidAdapter implements TargetAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Droid uses config file for credentials — minimal env needed.
|
||||
* Droid uses config file for credentials — keep parent env, but strip stale
|
||||
* ANTHROPIC_* values so prior CCS/CLIProxy sessions do not leak into Droid.
|
||||
*/
|
||||
buildEnv(_creds: TargetCredentials, _profileType: ProfileType): NodeJS.ProcessEnv {
|
||||
return { ...process.env };
|
||||
return { ...stripAnthropicEnv(process.env) };
|
||||
}
|
||||
|
||||
exec(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}`,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): string {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return path.join(
|
||||
env.HOME || env.USERPROFILE || '',
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Google',
|
||||
'Chrome'
|
||||
);
|
||||
case 'linux':
|
||||
return path.join(env.HOME || env.USERPROFILE || '', '.config', 'google-chrome');
|
||||
case 'win32': {
|
||||
const localAppData = 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(env.HOME || 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
* `--append-system-prompt` and `--append-system-prompt-file` in one request.
|
||||
*/
|
||||
|
||||
import {
|
||||
hasExactFlagValue as hasExactClaudeFlagValue,
|
||||
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
|
||||
} from '../claude-tool-args';
|
||||
import {
|
||||
buildSteeringArg,
|
||||
hasManagedPromptFileArg,
|
||||
@@ -17,67 +21,11 @@ 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(params: {
|
||||
args: string[];
|
||||
flag: string;
|
||||
expectedValue: string;
|
||||
}): boolean {
|
||||
const { args, flag, expectedValue } = params;
|
||||
|
||||
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({
|
||||
args: optionArgs,
|
||||
flag: PROMPT_FLAG_INLINE,
|
||||
expectedValue: IMAGE_ANALYSIS_STEERING_PROMPT.content,
|
||||
})
|
||||
hasExactClaudeFlagValue(optionArgs, PROMPT_FLAG_INLINE, IMAGE_ANALYSIS_STEERING_PROMPT.content)
|
||||
) {
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
* `--append-system-prompt` and `--append-system-prompt-file` in one request.
|
||||
*/
|
||||
|
||||
import {
|
||||
getImmediateFlagValue,
|
||||
hasExactFlagValue as hasExactClaudeFlagValue,
|
||||
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
|
||||
} from '../claude-tool-args';
|
||||
import {
|
||||
buildSteeringArg,
|
||||
hasManagedPromptFileArg,
|
||||
@@ -34,26 +39,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];
|
||||
@@ -79,40 +64,8 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasExactFlagValue(params: {
|
||||
args: string[];
|
||||
flag: string;
|
||||
expectedValue: string;
|
||||
}): boolean {
|
||||
const { args, flag, expectedValue } = params;
|
||||
|
||||
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;
|
||||
@@ -151,14 +104,14 @@ function ensureDisallowedNativeWebSearchTool(args: string[]): string[] {
|
||||
}
|
||||
|
||||
function ensureWebSearchSteeringPrompt(args: string[]): string[] {
|
||||
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
|
||||
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
|
||||
|
||||
if (
|
||||
hasExactFlagValue({
|
||||
args: optionArgs,
|
||||
flag: PROMPT_FLAG_INLINE,
|
||||
expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content,
|
||||
})
|
||||
hasExactClaudeFlagValue(
|
||||
optionArgs,
|
||||
PROMPT_FLAG_INLINE,
|
||||
THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content
|
||||
)
|
||||
) {
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,27 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}
|
||||
timeout: PROXY_TIMEOUT_MS,
|
||||
},
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
||||
const proxyStatus = proxyRes.statusCode ?? 502;
|
||||
const proxyContentLength = proxyRes.headers['content-length'];
|
||||
const hasEmptyBody =
|
||||
typeof proxyContentLength === 'string' && Number.parseInt(proxyContentLength, 10) === 0;
|
||||
const isSyntheticUnreachableResponse =
|
||||
proxyStatus === 502 &&
|
||||
proxyRes.headers['content-type'] === undefined &&
|
||||
proxyRes.headers['proxy-connection'] !== undefined &&
|
||||
hasEmptyBody;
|
||||
|
||||
if (isSyntheticUnreachableResponse) {
|
||||
const payload = JSON.stringify({ error: 'CLIProxy is not reachable' });
|
||||
res.writeHead(502, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': String(Buffer.byteLength(payload)),
|
||||
});
|
||||
res.end(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(proxyStatus, proxyRes.headers);
|
||||
// Manual streaming instead of pipe() for Bun runtime compatibility
|
||||
proxyRes.on('data', (chunk: Buffer) => res.write(chunk));
|
||||
proxyRes.on('end', () => res.end());
|
||||
@@ -116,14 +136,18 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}
|
||||
|
||||
proxyReq.on('error', () => {
|
||||
if (!res.headersSent) {
|
||||
res.status(502).json({ error: 'CLIProxy is not reachable' });
|
||||
const payload = JSON.stringify({ error: 'CLIProxy is not reachable' });
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Length', Buffer.byteLength(payload));
|
||||
res.end(payload);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up proxy connection when client disconnects.
|
||||
// Only use res.on('close') — req.on('close') fires with req.destroyed=true
|
||||
// in Bun after body consumption, which would prematurely kill the proxy.
|
||||
res.on('close', () => {
|
||||
// Clean up proxy connection only when the client aborts the request.
|
||||
// Avoid res.on('close') here because Bun may emit it during local error
|
||||
// responses before the JSON body is flushed, which can truncate 502 payloads.
|
||||
req.on('aborted', () => {
|
||||
if (!res.writableEnded) {
|
||||
proxyReq.destroy();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user