diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs new file mode 100755 index 00000000..515d367a --- /dev/null +++ b/lib/mcp/ccs-browser-server.cjs @@ -0,0 +1,834 @@ +#!/usr/bin/env node + +const { WebSocket } = require('ws'); + +const PROTOCOL_VERSION = '2024-11-05'; +const SERVER_NAME = 'ccs-browser'; +const SERVER_VERSION = '1.0.0'; +const TOOL_SESSION_INFO = 'browser_get_session_info'; +const TOOL_URL_TITLE = 'browser_get_url_and_title'; +const TOOL_VISIBLE_TEXT = 'browser_get_visible_text'; +const TOOL_DOM_SNAPSHOT = 'browser_get_dom_snapshot'; +const TOOL_NAVIGATE = 'browser_navigate'; +const TOOL_CLICK = 'browser_click'; +const TOOL_TYPE = 'browser_type'; +const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot'; +const TOOL_NAMES = [ + TOOL_SESSION_INFO, + TOOL_URL_TITLE, + TOOL_VISIBLE_TEXT, + TOOL_DOM_SNAPSHOT, + TOOL_NAVIGATE, + TOOL_CLICK, + TOOL_TYPE, + TOOL_TAKE_SCREENSHOT, +]; +const CDP_TIMEOUT_MS = 5000; +const NAVIGATION_POLL_INTERVAL_MS = 100; + +let inputBuffer = Buffer.alloc(0); +let requestCounter = 0; + +function shouldExposeTools() { + return Boolean(process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL); +} + +function writeMessage(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function writeResponse(id, result) { + writeMessage({ jsonrpc: '2.0', id, result }); +} + +function writeError(id, code, message) { + writeMessage({ jsonrpc: '2.0', id, error: { code, message } }); +} + +function getTools() { + if (!shouldExposeTools()) { + return []; + } + + return [ + { + name: TOOL_SESSION_INFO, + description: + 'List the current Chrome session pages available through the configured DevTools connection, including page ids, titles, URLs, and websocket endpoints.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_URL_TITLE, + description: + 'Read the current page URL and title from the configured Chrome session. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_VISIBLE_TEXT, + description: + 'Read visible text from the current page via DOM evaluation in the configured Chrome session. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_DOM_SNAPSHOT, + description: + 'Read a DOM snapshot from the current page by returning the document outerHTML. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_NAVIGATE, + description: + 'Navigate the selected page to an absolute http or https URL and wait until navigation is ready. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + url: { + type: 'string', + description: 'Required absolute http or https URL to navigate to.', + }, + }, + required: ['url'], + additionalProperties: false, + }, + }, + { + name: TOOL_CLICK, + description: + 'Click the first element matching a CSS selector in the selected page using synthetic element.click(). Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the element to click.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_TYPE, + description: + 'Type text into the first element matching a CSS selector when it is a supported text-editable target. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the target element.', + }, + text: { + type: 'string', + description: 'Required text to assign. May be an empty string.', + }, + clearFirst: { + type: 'boolean', + description: 'When true, clear the current value or content before assigning text.', + }, + }, + required: ['selector', 'text'], + additionalProperties: false, + }, + }, + { + name: TOOL_TAKE_SCREENSHOT, + description: + 'Capture a PNG screenshot from the selected page. Optionally choose a page by index or request fullPage capture.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + fullPage: { + type: 'boolean', + description: 'Optional full-page capture flag.', + }, + }, + additionalProperties: false, + }, + }, + ]; +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${url}`); + } + return await response.json(); +} + +function getHttpUrl() { + const value = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + if (!value) { + throw new Error('Browser MCP is unavailable because CCS_BROWSER_DEVTOOLS_HTTP_URL is missing.'); + } + return value.replace(/\/+$/, ''); +} + +async function listPageTargets() { + const targets = await fetchJson(`${getHttpUrl()}/json/list`); + if (!Array.isArray(targets)) { + throw new Error('Browser MCP received an invalid /json/list response.'); + } + + return targets + .filter((target) => target && typeof target === 'object' && target.type === 'page') + .map((target) => ({ + id: typeof target.id === 'string' ? target.id : '', + title: typeof target.title === 'string' ? target.title : '', + url: typeof target.url === 'string' ? target.url : '', + type: typeof target.type === 'string' ? target.type : 'page', + webSocketDebuggerUrl: + typeof target.webSocketDebuggerUrl === 'string' ? target.webSocketDebuggerUrl : '', + })); +} + +function parsePageIndex(toolArgs) { + if (!toolArgs || !Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex')) { + return 0; + } + + if (!Number.isInteger(toolArgs.pageIndex) || toolArgs.pageIndex < 0) { + throw new Error('pageIndex must be a non-negative integer'); + } + + return toolArgs.pageIndex; +} + +async function getSelectedPage(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const pageIndex = parsePageIndex(toolArgs); + + const page = pages[pageIndex]; + if (!page) { + throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`); + } + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + return { page, pageIndex, pages }; +} + +function formatSessionInfo(pages) { + return [ + '[CCS Browser Session]', + '', + ...pages.map((page, index) => `${index}. ${page.title || ''} | ${page.url || ''}`), + ].join('\n'); +} + +function createEvaluateExpression(kind) { + switch (kind) { + case 'url-title': + return `JSON.stringify({ title: document.title, url: location.href })`; + case 'visible-text': + return `document.body ? document.body.innerText : ''`; + case 'dom-snapshot': + return `document.documentElement ? document.documentElement.outerHTML : ''`; + default: + throw new Error(`Unknown browser evaluation kind: ${kind}`); + } +} + +async function sendCdpCommand(page, method, params = {}) { + const ws = new WebSocket(page.webSocketDebuggerUrl); + const requestId = ++requestCounter; + + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + settled = true; + ws.terminate(); + reject(new Error('Browser MCP timed out waiting for a DevTools response.')); + } + }, CDP_TIMEOUT_MS); + + function settleError(error) { + if (settled) { + return; + } + clearTimeout(timer); + settled = true; + reject(error); + } + + ws.on('open', () => { + ws.send( + JSON.stringify({ + id: requestId, + method, + params, + }) + ); + }); + + ws.on('message', (data) => { + if (settled) { + return; + } + + let message; + try { + message = JSON.parse(data.toString()); + } catch { + return; + } + + if (message.id !== requestId) { + return; + } + + clearTimeout(timer); + settled = true; + ws.close(); + + if (message.error) { + reject(new Error(message.error.message || 'DevTools request failed.')); + return; + } + + resolve(message.result || null); + }); + + ws.on('error', (error) => { + settleError(error); + }); + + ws.on('close', () => { + if (settled) { + return; + } + settleError(new Error('Browser MCP lost the DevTools websocket connection.')); + }); + }); +} + +async function evaluateInPage(page, kind) { + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: createEvaluateExpression(kind), + returnByValue: true, + }); + + const result = response && response.result ? response.result : null; + if (!result) { + throw new Error('Browser MCP received an invalid DevTools evaluation response.'); + } + + if (result.subtype === 'error') { + throw new Error(result.description || 'DevTools evaluation returned an error.'); + } + + return typeof result.value === 'string' ? result.value : result.value ?? ''; +} + +async function evaluateExpression(page, expression) { + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression, + returnByValue: true, + }); + + const result = response && response.result ? response.result : null; + if (!result) { + throw new Error('Browser MCP received an invalid DevTools evaluation response.'); + } + + if (result.subtype === 'error') { + throw new Error(result.description || 'DevTools evaluation returned an error.'); + } + + return typeof result.value === 'string' ? result.value : result.value ?? ''; +} + +function requireNonEmptyString(value, label) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} is required`); + } + return value.trim(); +} + +function requireString(value, label) { + if (typeof value !== 'string') { + throw new Error(`${label} is required`); + } + return value; +} + +function requireValidHttpUrl(value) { + const raw = requireNonEmptyString(value, 'url'); + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new Error('url must be an absolute http or https URL'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('url must be an absolute http or https URL'); + } + + return parsed.toString(); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getNavigationState(page) { + const raw = await evaluateExpression(page, `JSON.stringify({ href: location.href, readyState: document.readyState })`); + const parsed = JSON.parse(raw); + return { + href: typeof parsed.href === 'string' ? parsed.href : '', + readyState: typeof parsed.readyState === 'string' ? parsed.readyState : '', + }; +} + +function isSameDocumentHashNavigation(beforeHref, requestedUrl) { + try { + const before = new URL(beforeHref); + const requested = new URL(requestedUrl); + return ( + before.origin === requested.origin && + before.pathname === requested.pathname && + before.search === requested.search && + before.hash !== requested.hash + ); + } catch { + return false; + } +} + +function isNavigationReady(state, beforeHref, requestedUrl) { + if (state.readyState !== 'interactive' && state.readyState !== 'complete') { + return false; + } + + if (state.href === requestedUrl) { + return true; + } + + if (state.href && state.href !== beforeHref) { + return true; + } + + if (isSameDocumentHashNavigation(beforeHref, requestedUrl) && state.href === requestedUrl) { + return true; + } + + return false; +} + +async function waitForNavigationReady(page, beforeHref, requestedUrl) { + const deadline = Date.now() + CDP_TIMEOUT_MS; + + while (Date.now() <= deadline) { + const state = await getNavigationState(page); + if (isNavigationReady(state, beforeHref, requestedUrl)) { + return state.href; + } + if (Date.now() + NAVIGATION_POLL_INTERVAL_MS > deadline) { + break; + } + await sleep(NAVIGATION_POLL_INTERVAL_MS); + } + + throw new Error(`navigation did not complete for URL: ${requestedUrl}`); +} + +async function handleNavigate(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const url = requireValidHttpUrl(toolArgs.url); + const before = await getNavigationState(page); + const navigateResult = await sendCdpCommand(page, 'Page.navigate', { url }); + if (navigateResult && typeof navigateResult.errorText === 'string' && navigateResult.errorText) { + throw new Error(`navigation failed for URL: ${url}: ${navigateResult.errorText}`); + } + const finalUrl = await waitForNavigationReady(page, before.href, url); + return `pageIndex: ${pageIndex}\nurl: ${finalUrl}\nstatus: navigated`; +} + +async function handleClick(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const element = document.querySelector(selector); + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + if ('disabled' in element && element.disabled) { + throw new Error('element is disabled for selector: ' + selector); + } + element.scrollIntoView({ block: 'center', inline: 'center' }); + element.click(); + return 'ok'; + })()`; + + await evaluateExpression(page, expression); + return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: clicked`; +} + +async function handleType(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const text = requireString(toolArgs.text, 'text'); + const clearFirst = toolArgs.clearFirst === true; + + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const text = JSON.parse(${JSON.stringify(JSON.stringify(text))}); + const clearFirst = ${clearFirst ? 'true' : 'false'}; + const element = document.querySelector(selector); + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + + const dispatchEvents = (target) => { + target.dispatchEvent(new Event('input', { bubbles: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + }; + + const focusTarget = (target) => { + if (typeof target.focus === 'function') { + target.focus(); + } + }; + + let readback = ''; + let expectedValue = ''; + + if (element instanceof HTMLTextAreaElement) { + focusTarget(element); + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; + expectedValue = (clearFirst ? '' : element.value) + text; + if (setter) { + setter.call(element, expectedValue); + } else { + element.value = expectedValue; + } + dispatchEvents(element); + readback = element.value; + } else if (element instanceof HTMLInputElement) { + const supportedTypes = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'password']); + const normalizedType = (element.getAttribute('type') || '').toLowerCase(); + if (!supportedTypes.has(normalizedType)) { + throw new Error('element is not text-editable for selector: ' + selector); + } + focusTarget(element); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + expectedValue = (clearFirst ? '' : element.value) + text; + if (setter) { + setter.call(element, expectedValue); + } else { + element.value = expectedValue; + } + dispatchEvents(element); + readback = element.value; + } else if (element.isContentEditable === true) { + focusTarget(element); + expectedValue = (clearFirst ? '' : (element.textContent || '')) + text; + element.textContent = expectedValue; + dispatchEvents(element); + readback = element.textContent || ''; + } else { + throw new Error('element is not text-editable for selector: ' + selector); + } + + if (readback !== expectedValue) { + throw new Error('typed text verification failed for selector: ' + selector); + } + + return JSON.stringify({ value: readback, typedLength: readback.length }); + })()`; + + const raw = await evaluateExpression(page, expression); + const parsed = JSON.parse(raw); + const typedLength = typeof parsed.typedLength === 'number' ? parsed.typedLength : String(parsed.value || '').length; + return `pageIndex: ${pageIndex}\nselector: ${selector}\ntypedLength: ${typedLength}\nstatus: typed`; +} + +async function handleScreenshot(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const fullPage = toolArgs.fullPage === true; + const response = await sendCdpCommand(page, 'Page.captureScreenshot', { + format: 'png', + captureBeyondViewport: fullPage, + }); + + const data = response && typeof response.data === 'string' ? response.data : ''; + if (!data) { + throw new Error('screenshot capture failed'); + } + + return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; +} + +async function handleToolCall(message) { + const id = message.id; + const params = message.params || {}; + const toolName = params.name || ''; + const toolArgs = params.arguments || {}; + + if (!TOOL_NAMES.includes(toolName)) { + writeError(id, -32602, `Unknown tool: ${toolName}`); + return; + } + + if (!shouldExposeTools()) { + writeResponse(id, { + content: [ + { + type: 'text', + text: 'Browser MCP is unavailable because browser reuse is not configured for this Claude session.', + }, + ], + isError: true, + }); + return; + } + + try { + if (toolName === TOOL_SESSION_INFO) { + const pages = await listPageTargets(); + writeResponse(id, { + content: [{ type: 'text', text: formatSessionInfo(pages) }], + }); + return; + } + + if (toolName === TOOL_NAVIGATE) { + const text = await handleNavigate(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CLICK) { + const text = await handleClick(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_TYPE) { + const text = await handleType(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_TAKE_SCREENSHOT) { + const text = await handleScreenshot(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + const { page, pageIndex } = await getSelectedPage(toolArgs); + + if (toolName === TOOL_URL_TITLE) { + const raw = await evaluateInPage(page, 'url-title'); + const parsed = JSON.parse(raw); + writeResponse(id, { + content: [ + { + type: 'text', + text: `pageIndex: ${pageIndex}\ntitle: ${parsed.title || ''}\nurl: ${parsed.url || ''}`, + }, + ], + }); + return; + } + + if (toolName === TOOL_VISIBLE_TEXT) { + const text = await evaluateInPage(page, 'visible-text'); + writeResponse(id, { + content: [{ type: 'text', text: text || '' }], + }); + return; + } + + const html = await evaluateInPage(page, 'dom-snapshot'); + writeResponse(id, { + content: [{ type: 'text', text: html || '' }], + }); + } catch (error) { + writeResponse(id, { + content: [ + { + type: 'text', + text: `Browser MCP failed: ${(error && error.message) || String(error)}`, + }, + ], + isError: true, + }); + } +} + +async function handleMessage(message) { + if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { + return; + } + + switch (message.method) { + case 'initialize': + writeResponse(message.id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + }); + return; + case 'notifications/initialized': + return; + case 'ping': + writeResponse(message.id, {}); + return; + case 'tools/list': + writeResponse(message.id, { tools: getTools() }); + return; + case 'tools/call': + await handleToolCall(message); + return; + default: + if (message.id !== undefined) { + writeError(message.id, -32601, `Method not found: ${message.method}`); + } + } +} + +function parseMessages() { + while (true) { + let body; + const startsWithLegacyHeaders = inputBuffer + .subarray(0, Math.min(inputBuffer.length, 32)) + .toString('utf8') + .toLowerCase() + .startsWith('content-length:'); + + if (startsWithLegacyHeaders) { + const headerEnd = inputBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return; + } + + const headerText = inputBuffer.subarray(0, headerEnd).toString('utf8'); + const match = headerText.match(/content-length:\s*(\d+)/i); + if (!match) { + inputBuffer = Buffer.alloc(0); + return; + } + + const contentLength = Number.parseInt(match[1], 10); + const messageEnd = headerEnd + 4 + contentLength; + if (inputBuffer.length < messageEnd) { + return; + } + + body = inputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8'); + inputBuffer = inputBuffer.subarray(messageEnd); + } else { + const newlineIndex = inputBuffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + body = inputBuffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + inputBuffer = inputBuffer.subarray(newlineIndex + 1); + if (!body) { + continue; + } + } + + let message; + try { + message = JSON.parse(body); + } catch { + continue; + } + + Promise.resolve(handleMessage(message)).catch((error) => { + if (message && message.id !== undefined) { + writeError(message.id, -32603, (error && error.message) || 'Internal error'); + } + }); + } +} + +process.stdin.on('data', (chunk) => { + inputBuffer = Buffer.concat([inputBuffer, chunk]); + parseMessages(); +}); + +process.stdin.on('error', () => { + process.exit(0); +}); + +['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) => { + process.on(signal, () => process.exit(0)); +}); + +process.stdin.resume(); diff --git a/src/ccs.ts b/src/ccs.ts index a6edc978..e3fdfb9d 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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 +): string[] { + if (target !== 'codex') { + return []; + } + return buildCodexBrowserMcpOverrides(); +} + /** * Smart profile detection */ @@ -624,6 +641,7 @@ async function main(): Promise { // 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 { model: envVars['ANTHROPIC_MODEL'], }), reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, envVars, }; @@ -982,8 +1001,16 @@ async function main(): Promise { // 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 { 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 { // 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 { model: settingsEnv['ANTHROPIC_MODEL'], }), reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, envVars, }; await adapter.prepareCredentials(creds); @@ -1254,10 +1300,13 @@ async function main(): Promise { 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 { 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 { } 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 { 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 { } const launchArgs = resolveNativeClaudeLaunchArgs( - remainingArgs, + browserRuntimeEnv ? appendBrowserToolArgs(remainingArgs) : remainingArgs, 'default', envVars.CLAUDE_CONFIG_DIR ); diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index a6233879..bafcea63 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -74,6 +74,8 @@ export interface ProxyChainConfig { claudeConfigDir?: string; /** Execution-aware image analysis env prepared by the caller */ imageAnalysisEnv?: Record; + /** Optional browser runtime env for Claude browser MCP reuse. */ + browserRuntimeEnv?: Record; } interface CliproxyImageAnalysisDeps { @@ -240,6 +242,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record 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', diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 77462b0e..aae1912f 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -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; } /** diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index db206d45..650cd07d 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -121,9 +121,10 @@ export function loadMigrationCheckData(): MigrationCheckData { if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) { const legacyProfiles = legacyConfig.profiles as Record; + 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; } diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index b9729459..8fa15198 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -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) { diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index 4ba0c40c..9dc4f08a 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -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; diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 09a278d7..e7166fdd 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -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)}`); } diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index abf5c3ce..fcbe08b1 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -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; } /** diff --git a/src/utils/browser-codex-overrides.ts b/src/utils/browser-codex-overrides.ts new file mode 100644 index 00000000..02cdf8cb --- /dev/null +++ b/src/utils/browser-codex-overrides.ts @@ -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}`, + ]; +} diff --git a/src/utils/browser/chrome-reuse.ts b/src/utils/browser/chrome-reuse.ts new file mode 100644 index 00000000..8fd77903 --- /dev/null +++ b/src/utils/browser/chrome-reuse.ts @@ -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 { + 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).webSocketDebuggerUrl === 'string' + ? (versionPayload as Record).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 { + 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 { + try { + return await fs.promises.stat(targetPath); + } catch { + return null; + } +} diff --git a/src/utils/browser/claude-tool-args.ts b/src/utils/browser/claude-tool-args.ts new file mode 100644 index 00000000..d0e3338b --- /dev/null +++ b/src/utils/browser/claude-tool-args.ts @@ -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); +} diff --git a/src/utils/browser/index.ts b/src/utils/browser/index.ts new file mode 100644 index 00000000..be30da8e --- /dev/null +++ b/src/utils/browser/index.ts @@ -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'; diff --git a/src/utils/browser/mcp-installer.ts b/src/utils/browser/mcp-installer.ts new file mode 100644 index 00000000..e5e8552c --- /dev/null +++ b/src/utils/browser/mcp-installer.ts @@ -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; + [key: string]: unknown; +} + +interface ManagedBrowserMcpConfig { + type: 'stdio'; + command: 'node'; + args: [string]; + env: Record; +} + +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 { + 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(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) } + : {}; + + 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) + : {}; + 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; +} diff --git a/src/utils/claude-tool-args.ts b/src/utils/claude-tool-args.ts new file mode 100644 index 00000000..31984ed1 --- /dev/null +++ b/src/utils/claude-tool-args.ts @@ -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; +} diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts index f1d0cf0b..267e731a 100644 --- a/src/utils/image-analysis/claude-tool-args.ts +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -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; } diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index 15028178..254353a2 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -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; } diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts new file mode 100644 index 00000000..b0d3eb5a --- /dev/null +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -0,0 +1,827 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { spawn } from 'child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import * as http from 'node:http'; +import { WebSocketServer } from 'ws'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type JsonRpcMessage = Record; + +type MockPageState = { + id: string; + title: string; + currentUrl: string; + readyStateSequence?: string[]; + visibleText?: string; + domSnapshot?: string; + navigate?: Record; + click?: Record; + screenshot?: { + data?: string; + lastCaptureBeyondViewport?: boolean; + }; + type?: Record< + string, + { + kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable'; + inputType?: string; + value?: string; + expectedValueWhenClearFirst?: string; + expectedValueWhenAppend?: string; + requireFocus?: boolean; + focused?: boolean; + } + >; +}; + +const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); + +function encodeMessage(message: unknown): string { + return `${JSON.stringify(message)}\n`; +} + +function collectResponses( + child: ReturnType, + expectedCount: number +): Promise>> { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const responses: Array> = []; + const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 7000); + + function tryParse(): void { + while (true) { + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + const body = buffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + buffer = buffer.subarray(newlineIndex + 1); + if (!body) { + continue; + } + + responses.push(JSON.parse(body) as Record); + if (responses.length >= expectedCount) { + clearTimeout(timer); + resolve(responses); + return; + } + } + } + + if (!child.stdout) { + clearTimeout(timer); + reject(new Error('MCP child stdout is unavailable')); + return; + } + + child.stdout.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + try { + tryParse(); + } catch (error) { + clearTimeout(timer); + reject(error); + } + }); + + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function getResponseText(message: Record | undefined): string { + const result = (message?.result as { content?: Array<{ text?: string }> }) || {}; + return result.content?.[0]?.text || ''; +} + +function parseJsonArgument(expression: string, key: string): string | undefined { + const marker = `const ${key} = JSON.parse(`; + const start = expression.indexOf(marker); + if (start === -1) { + return undefined; + } + + const quoteStart = start + marker.length; + const quote = expression[quoteStart]; + if (quote !== '"' && quote !== "'") { + return undefined; + } + + let index = quoteStart + 1; + while (index < expression.length) { + if (expression[index] === '\\') { + index += 2; + continue; + } + if (expression[index] === quote) { + const encoded = expression.slice(quoteStart, index + 1); + const decoded = JSON.parse(encoded) as string; + if (!decoded.startsWith('"') && !decoded.startsWith("'")) { + return undefined; + } + return JSON.parse(decoded) as string; + } + index += 1; + } + + return undefined; +} + +function createMockBrowser(pagesInput: MockPageState[]) { + let tempDir = ''; + let httpServer: http.Server | null = null; + let wsServer: WebSocketServer | null = null; + const pageStates = new Map(); + + for (const [index, page] of pagesInput.entries()) { + pageStates.set(`/devtools/page/${index + 1}`, { + visibleText: 'Hello from visible text', + domSnapshot: 'Hello from DOM snapshot', + ...page, + }); + } + + async function start() { + tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-')); + + const port = await new Promise((resolve, reject) => { + httpServer = http.createServer((req, res) => { + if (req.url === '/json/list') { + const address = httpServer?.address(); + const serverPort = address && typeof address !== 'string' ? address.port : 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify( + Array.from(pageStates.entries()).map(([wsPath, page]) => ({ + id: page.id, + type: 'page', + title: page.title, + url: page.currentUrl, + webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, + })) + ) + ); + return; + } + res.writeHead(404); + res.end('not found'); + }); + + httpServer.once('error', reject); + httpServer.listen(0, '127.0.0.1', () => { + const address = httpServer?.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to resolve mock browser server port')); + return; + } + resolve(address.port); + }); + }); + + wsServer = new WebSocketServer({ server: httpServer as http.Server }); + wsServer.on('connection', (socket, request) => { + const page = pageStates.get(request.url || ''); + if (!page) { + socket.close(); + return; + } + + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) as { + id: number; + method: string; + params?: Record; + }; + + function reply(result: unknown): void { + socket.send(JSON.stringify({ id: message.id, result })); + } + + function replyError(errorText: string): void { + socket.send(JSON.stringify({ id: message.id, result: { result: { subtype: 'error', description: errorText } } })); + } + + if (message.method === 'Page.navigate') { + const targetUrl = typeof message.params?.url === 'string' ? message.params.url : ''; + const navigatePlan = page.navigate?.[targetUrl]; + if (navigatePlan?.errorText) { + reply({ frameId: 'frame-1', errorText: navigatePlan.errorText }); + return; + } + if (navigatePlan) { + page.currentUrl = navigatePlan.finalUrl; + page.readyStateSequence = [...(navigatePlan.readyStates || ['loading', 'interactive'])]; + } + reply({ frameId: 'frame-1' }); + return; + } + + if (message.method === 'Page.captureScreenshot') { + if (!page.screenshot) { + reply({ data: '' }); + return; + } + page.screenshot.lastCaptureBeyondViewport = message.params?.captureBeyondViewport === true; + reply({ data: page.screenshot.data || '' }); + return; + } + + if (message.method !== 'Runtime.evaluate') { + return; + } + + const expression = String(message.params?.expression || ''); + + if (expression.includes('document.title') && expression.includes('location.href')) { + reply({ result: { type: 'string', value: JSON.stringify({ title: page.title, url: page.currentUrl }) } }); + return; + } + + if (expression.includes('document.body ? document.body.innerText')) { + reply({ result: { type: 'string', value: page.visibleText || '' } }); + return; + } + + if (expression.includes('document.documentElement ? document.documentElement.outerHTML')) { + reply({ result: { type: 'string', value: page.domSnapshot || '' } }); + return; + } + + if (expression.includes('document.readyState') && expression.includes('location.href')) { + const readyState = page.readyStateSequence?.shift() || 'complete'; + reply({ + result: { + type: 'string', + value: JSON.stringify({ href: page.currentUrl, readyState }), + }, + }); + return; + } + + if (expression.includes('scrollIntoView') && expression.includes('.click()')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const clickPlan = page.click?.[selector]; + if (!clickPlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (clickPlan.disabled) { + replyError(`element is disabled for selector: ${selector}`); + return; + } + if (clickPlan.error) { + replyError(clickPlan.error); + return; + } + reply({ result: { type: 'string', value: 'ok' } }); + return; + } + + if (expression.includes('focusTarget') && expression.includes('typedLength')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const text = parseJsonArgument(expression, 'text') ?? ''; + const clearFirst = expression.includes('const clearFirst = true'); + const typePlan = page.type?.[selector]; + if (!typePlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (typePlan.kind === 'unsupported') { + replyError(`element is not text-editable for selector: ${selector}`); + return; + } + if (typePlan.kind === 'noneditable') { + replyError(`element is not text-editable for selector: ${selector}`); + return; + } + if (typePlan.requireFocus && !expression.includes('focusTarget(element)')) { + replyError(`focus was not requested for selector: ${selector}`); + return; + } + + const currentValue = typePlan.value || ''; + const expectedValue = clearFirst + ? (typePlan.expectedValueWhenClearFirst ?? text) + : (typePlan.expectedValueWhenAppend ?? `${currentValue}${text}`); + + typePlan.focused = true; + typePlan.value = expectedValue; + reply({ + result: { + type: 'string', + value: JSON.stringify({ + value: expectedValue, + typedLength: expectedValue.length, + }), + }, + }); + return; + } + }); + }); + + const child = spawn('node', [serverPath], { + cwd: tempDir, + env: { + ...process.env, + CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + return child; + } + + async function stop() { + await new Promise((resolve) => { + wsServer?.close(() => resolve()); + if (!wsServer) resolve(); + }); + wsServer = null; + + await new Promise((resolve) => { + httpServer?.close(() => resolve()); + if (!httpServer) resolve(); + }); + httpServer = null; + + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + } + + return { start, stop }; +} + +async function runMcpRequests(pages: MockPageState[], requests: JsonRpcMessage[]) { + const browser = createMockBrowser(pages); + const child = await browser.start(); + + try { + const responsesPromise = collectResponses(child, requests.length + 1); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + + for (const request of requests) { + child.stdin.write(encodeMessage(request)); + } + + return await responsesPromise; + } finally { + child.kill(); + await browser.stop(); + } +} + +describe('ccs-browser MCP server', () => { + afterEach(() => { + // Cleanup is handled per test via runMcpRequests/createMockBrowser. + }); + + it('lists browser tools including navigate, click, type, and screenshot', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], + [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }] + ); + + const tools = (responses.find((message) => message.id === 2)?.result as { + tools: Array<{ name: string; inputSchema?: { properties?: Record } }>; + }).tools; + + expect(tools.map((tool) => tool.name)).toEqual([ + 'browser_get_session_info', + 'browser_get_url_and_title', + 'browser_get_visible_text', + 'browser_get_dom_snapshot', + 'browser_navigate', + 'browser_click', + 'browser_type', + 'browser_take_screenshot', + ]); + + for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { + expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ + type: 'integer', + minimum: 0, + }); + } + }); + + it('navigates successfully after readiness polling', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + navigate: { + 'https://example.com/next': { + finalUrl: 'https://example.com/next', + readyStates: ['loading', 'interactive'], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/next' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('pageIndex: 0'); + expect(text).toContain('url: https://example.com/next'); + expect(text).toContain('status: navigated'); + }); + + it( + 'returns a handled error when navigation readiness times out', + async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + navigate: { + 'https://example.com/slow': { + finalUrl: 'https://example.com/', + readyStates: new Array(60).fill('loading'), + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/slow' }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: navigation did not complete'); + }, + 8000 + ); + + it('returns handled errors for missing URL, malformed URL, invalid page index, and out-of-range page index', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { url: 'file:///tmp/example' } }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { pageIndex: 1.5, url: 'https://example.com/next' } }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { pageIndex: 9, url: 'https://example.com/next' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('Browser MCP failed: url is required'); + expect(getResponseText(responses.find((message) => message.id === 3))).toContain( + 'Browser MCP failed: url must be an absolute http or https URL' + ); + expect(getResponseText(responses.find((message) => message.id === 4))).toContain( + 'Browser MCP failed: pageIndex must be a non-negative integer' + ); + expect(getResponseText(responses.find((message) => message.id === 5))).toContain('page index 9 is out of range'); + }); + + it('surfaces Page.navigate CDP failures as handled errors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/start', + navigate: { + 'https://example.com/fail': { + finalUrl: 'https://example.com/start', + errorText: 'net::ERR_ABORTED', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/fail' }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: navigation failed for URL: https://example.com/fail: net::ERR_ABORTED' + ); + }); + + it('treats redirects as successful navigation', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/start', + navigate: { + 'https://example.com/redirect': { + finalUrl: 'https://example.com/final', + readyStates: ['loading', 'interactive'], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/redirect' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('status: navigated'); + expect(text).toContain('url: https://example.com/final'); + }); + + it('clicks matching elements and reports selector failures', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#submit': {}, + '#disabled': { disabled: true }, + '#throws': { error: 'click exploded' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#submit' } }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#missing' } }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#disabled' } }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#throws' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #submit'); + + const selectorMiss = responses.find((message) => message.id === 3); + expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(selectorMiss)).toContain('element not found for selector: #missing'); + + const disabledError = responses.find((message) => message.id === 4); + expect((disabledError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(disabledError)).toContain('element is disabled for selector: #disabled'); + + const pageSideError = responses.find((message) => message.id === 5); + expect((pageSideError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(pageSideError)).toContain('click exploded'); + }); + + it('captures screenshots and reports empty payload failures', async () => { + const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + screenshot: screenshotPlan, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: { fullPage: true }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('pageIndex: 0'); + expect(text).toContain('format: png'); + expect(text).toContain('fullPage: true'); + expect(text).toContain('data: c2NyZWVuc2hvdA=='); + expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true); + + const errorResponses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + screenshot: { data: '' }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: {}, + }, + }, + ] + ); + + const errorResponse = errorResponses.find((message) => message.id === 2); + expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(errorResponse)).toContain('Browser MCP failed: screenshot capture failed'); + }); + + it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + type: { + 'input[name="email"]': { + kind: 'input', + inputType: 'email', + value: 'old@example.com', + expectedValueWhenAppend: 'old@example.comhi@example.com', + requireFocus: true, + }, + '#notes': { + kind: 'textarea', + value: 'old note', + expectedValueWhenClearFirst: '', + requireFocus: true, + }, + '#editor': { + kind: 'contenteditable', + value: 'old content', + expectedValueWhenAppend: 'old contentrich text', + requireFocus: true, + }, + '#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' }, + '#plain': { kind: 'noneditable', value: 'plain' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: 'input[name="email"]', text: 'hi@example.com' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#notes', text: '', clearFirst: true }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#editor', text: 'rich text' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#color', text: 'ignored' }, + }, + }, + { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#plain', text: 'ignored' }, + }, + }, + ] + ); + + const typed = getResponseText(responses.find((message) => message.id === 2)); + expect(typed).toContain('status: typed'); + expect(typed).toContain('selector: input[name="email"]'); + expect(typed).toContain('typedLength: 29'); + + const clearFirst = getResponseText(responses.find((message) => message.id === 3)); + expect(clearFirst).toContain('status: typed'); + expect(clearFirst).toContain('selector: #notes'); + expect(clearFirst).toContain('typedLength: 0'); + + const contenteditable = getResponseText(responses.find((message) => message.id === 4)); + expect(contenteditable).toContain('status: typed'); + expect(contenteditable).toContain('selector: #editor'); + expect(contenteditable).toContain('typedLength: 20'); + + const unsupported = responses.find((message) => message.id === 5); + expect((unsupported?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(unsupported)).toContain('element is not text-editable for selector: #color'); + + const nonEditable = responses.find((message) => message.id === 6); + expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(nonEditable)).toContain('element is not text-editable for selector: #plain'); + }); +}); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 27d5dbd0..f7d1b626 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -144,7 +144,7 @@ describe('InstanceManager MCP sync', () => { }); }); - it('repairs managed ccs-websearch entries from global config while preserving other instance MCP overrides', () => { + it('repairs managed ccs-websearch and ccs-browser entries from global config while preserving other instance MCP overrides', () => { fs.writeFileSync( path.join(tempRoot, '.claude.json'), JSON.stringify( @@ -156,6 +156,12 @@ describe('InstanceManager MCP sync', () => { args: ['/global/server.cjs'], env: {}, }, + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/global/browser-server.cjs'], + env: {}, + }, shared: { command: 'global-shared' }, }, }, @@ -179,6 +185,12 @@ describe('InstanceManager MCP sync', () => { args: ['/old/server.cjs'], env: {}, }, + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/old/browser-server.cjs'], + env: {}, + }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }, @@ -201,11 +213,51 @@ describe('InstanceManager MCP sync', () => { args: ['/global/server.cjs'], env: {}, }, + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/global/browser-server.cjs'], + env: {}, + }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }); }); + it('preserves existing instance .claude.json permissions when syncing managed MCP servers', () => { + fs.writeFileSync( + path.join(tempRoot, '.claude.json'), + JSON.stringify( + { + mcpServers: { + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/global/browser.cjs'], + env: {}, + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const manager = new InstanceManager(); + const instancePath = manager.getInstancePath('work'); + fs.mkdirSync(instancePath, { recursive: true }); + const instanceClaudeJson = path.join(instancePath, '.claude.json'); + fs.writeFileSync(instanceClaudeJson, JSON.stringify({ existing: true }, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o640, + }); + fs.chmodSync(instanceClaudeJson, 0o640); + + expect(manager.syncMcpServers(instancePath)).toBe(true); + expect(fs.statSync(instanceClaudeJson).mode & 0o777).toBe(0o640); + }); + it('logs warning when global MCP sync fails', () => { fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8'); const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts index 063a8744..5a48af16 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { CodexAdapter } from '../../../src/targets/codex-adapter'; +import { + buildCodexBrowserMcpOverrides, + getCodexBrowserMcpServerName, +} from '../../../src/utils/browser-codex-overrides'; describe('CodexAdapter', () => { const adapter = new CodexAdapter(); @@ -21,6 +25,16 @@ describe('CodexAdapter', () => { ).toEqual(['--search']); }); + test('builds browser MCP overrides with Codex-safe defaults', () => { + const serverName = getCodexBrowserMcpServerName(); + expect(buildCodexBrowserMcpOverrides()).toEqual([ + `mcp_servers.${serverName}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + `mcp_servers.${serverName}.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, + `mcp_servers.${serverName}.enabled=true`, + `mcp_servers.${serverName}.tool_timeout_sec=30`, + ]); + }); + test('translates default-mode reasoning overrides into transient codex config', () => { const args = adapter.buildArgs('default', ['--search'], { profileType: 'default', @@ -40,6 +54,29 @@ describe('CodexAdapter', () => { expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']); }); + test('injects runtime config overrides for native Codex default launches', () => { + const runtimeConfigOverrides = buildCodexBrowserMcpOverrides(); + const args = adapter.buildArgs('default', ['--search'], { + profileType: 'default', + creds: { + profile: 'default', + baseUrl: '', + apiKey: '', + runtimeConfigOverrides, + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, + }); + + expect(args).toEqual([ + ...runtimeConfigOverrides.flatMap((override) => ['-c', override]), + '--search', + ]); + }); + test('rejects default-mode reasoning overrides when codex lacks config override support', () => { expect(() => adapter.buildArgs('default', ['--search'], { @@ -61,6 +98,7 @@ describe('CodexAdapter', () => { }); test('injects transient config overrides for CCS-backed launches', () => { + const runtimeConfigOverrides = buildCodexBrowserMcpOverrides(); const args = adapter.buildArgs('codex', ['--search'], { profileType: 'cliproxy', creds: { @@ -69,6 +107,7 @@ describe('CodexAdapter', () => { apiKey: 'cliproxy-token', model: 'gpt-5.4', reasoningOverride: 'high', + runtimeConfigOverrides, }, binaryInfo: { path: '/tmp/codex', @@ -81,8 +120,9 @@ describe('CodexAdapter', () => { expect(args).toContain('model_provider="ccs_runtime"'); expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"'); expect(args).toContain('model="gpt-5.4"'); + expect(args).toContain(`mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`); expect(args).toContain('model_reasoning_effort="high"'); - expect(args.at(-1)).toBe('--search'); + expect(args[args.length - 1]).toBe('--search'); }); test('fails fast when Codex binary lacks config override support', () => { diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 439e476d..1afdb956 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -162,7 +162,7 @@ process.exit(0); fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('does not preflight native Codex default launches when no runtime overrides are needed', () => { + it('injects browser MCP runtime overrides for native Codex default launches', () => { if (process.platform === 'win32') return; const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { @@ -177,10 +177,23 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls).toEqual([['fix failing tests']]); + expect(calls).toEqual([ + ['-c', 'model="gpt-5"', '--version'], + [ + '-c', + `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + '-c', + `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + 'fix failing tests', + ], + ]); }); - it('ignores off-style CCS_THINKING env overrides for native Codex default mode', () => { + it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => { if (process.platform === 'win32') return; const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { @@ -195,7 +208,20 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls).toEqual([['fix failing tests']]); + expect(calls).toEqual([ + ['-c', 'model="gpt-5"', '--version'], + [ + '-c', + `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + '-c', + `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + 'fix failing tests', + ], + ]); }); for (const versionFlag of ['--version', '-v']) { @@ -291,7 +317,19 @@ process.exit(0); expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], - ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], + [ + '-c', + 'mcp_servers.ccs_browser.command="npx"', + '-c', + 'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]', + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + '-c', + 'model_reasoning_effort="high"', + 'fix failing tests', + ], ]); const loggedEnv = readLoggedCodexEnv(codexEnvLogPath); expect(loggedEnv).toHaveLength(2); @@ -481,7 +519,19 @@ process.exit(0); expect(result.status).toBe(0); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], - ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], + [ + '-c', + 'mcp_servers.ccs_browser.command="npx"', + '-c', + 'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]', + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + '-c', + 'model_reasoning_effort="high"', + 'fix failing tests', + ], ]); }); diff --git a/tests/unit/targets/codex-settings-bridge-launch.test.ts b/tests/unit/targets/codex-settings-bridge-launch.test.ts index 9e520ae0..edec0748 100644 --- a/tests/unit/targets/codex-settings-bridge-launch.test.ts +++ b/tests/unit/targets/codex-settings-bridge-launch.test.ts @@ -126,6 +126,8 @@ exit 0 expect(argsLog).toContain('model_provider="ccs_runtime"'); expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"'); expect(argsLog).toContain('model_reasoning_effort="high"'); + expect(argsLog).toContain('mcp_servers.ccs_browser.command='); + expect(argsLog).toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]'); expect(argsLog).toContain('smoke'); expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token'); }); diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts new file mode 100644 index 00000000..0af444a9 --- /dev/null +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawn, spawnSync, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { + const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts'); + const result = spawnSync(process.execPath, [ccsEntry, ...args], { + encoding: 'utf8', + env, + timeout: 5000, + }); + + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +describe('default profile browser launch', () => { + let tmpHome = ''; + let fakeClaudePath = ''; + let claudeArgsLogPath = ''; + let claudeEnvLogPath = ''; + let browserProfileDir = ''; + let devtoolsServer: ChildProcess | undefined; + let baseEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + if (process.platform === 'win32') { + return; + } + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-default-browser-launch-')); + fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); + claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt'); + claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt'); + browserProfileDir = path.join(tmpHome, 'chrome-user-data'); + + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh +printf "%s\n" "$@" > "${claudeArgsLogPath}" +{ + printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" + printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" + printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" + printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" +} > "${claudeEnvLogPath}" +exit 0 +`, + { encoding: 'utf8', mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + baseEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CLAUDE_PATH: fakeClaudePath, + CCS_DEBUG: '1', + }; + }); + + afterEach(() => { + if (devtoolsServer) { + devtoolsServer.kill(); + devtoolsServer = undefined; + } + if (process.platform === 'win32') { + return; + } + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('passes browser runtime env through default Claude launches when reuse is configured', () => { + if (process.platform === 'win32') return; + + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/default-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const startDeadline = Date.now() + 5000; + while (!fs.existsSync(mockServerPortPath)) { + if (Date.now() > startDeadline) { + throw new Error('Timed out waiting for mock DevTools server to start'); + } + } + const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/default-target`, + 'utf8' + ); + + const result = runCcs(['default', 'smoke'], { + ...baseEnv, + CCS_BROWSER_PROFILE_DIR: browserProfileDir, + }); + + expect(result.stderr).not.toContain('Browser MCP is enabled, but CCS could not prepare the local browser tool.'); + expect(result.stderr).not.toContain('could not sync the browser MCP config'); + expect(result.stderr).not.toContain('Chrome reuse metadata not found'); + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain('--append-system-prompt'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); + }); +}); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts new file mode 100644 index 00000000..61052620 --- /dev/null +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawn, spawnSync, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { + const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts'); + const result = spawnSync(process.execPath, [ccsEntry, ...args], { + encoding: 'utf8', + env, + timeout: 8000, + }); + + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +describe('settings profile browser launch', () => { + let tmpHome = ''; + let ccsDir = ''; + let settingsPath = ''; + let fakeClaudePath = ''; + let claudeArgsLogPath = ''; + let claudeEnvLogPath = ''; + let browserProfileDir = ''; + let devtoolsServer: ChildProcess | undefined; + let baseEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + if (process.platform === 'win32') { + return; + } + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-launch-')); + ccsDir = path.join(tmpHome, '.ccs'); + settingsPath = path.join(ccsDir, 'glm.settings.json'); + fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); + claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt'); + claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt'); + browserProfileDir = path.join(tmpHome, 'chrome-user-data'); + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: false', 'image_analysis:', ' enabled: false', ''].join('\n'), + 'utf8' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_MODEL: 'glm-5', + }, + }, + null, + 2 + ) + '\n' + ); + + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh +printf "%s\n" "$@" > "${claudeArgsLogPath}" +{ + printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" + printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" + printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" + printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" +} > "${claudeEnvLogPath}" +exit 0 +`, + { encoding: 'utf8', mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + baseEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CLAUDE_PATH: fakeClaudePath, + CCS_DEBUG: '1', + }; + }); + + afterEach(() => { + if (devtoolsServer) { + devtoolsServer.kill(); + devtoolsServer = undefined; + } + if (process.platform === 'win32') { + return; + } + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('fails before Claude launch when browser reuse cannot resolve DevToolsActivePort', () => { + if (process.platform === 'win32') return; + + fs.mkdirSync(browserProfileDir, { recursive: true }); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_PROFILE_DIR: browserProfileDir, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('DevToolsActivePort'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(false); + }); + + it('passes browser reuse env and steering prompt into the Claude launch', async () => { + if (process.platform === 'win32') return; + + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/browser-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const startDeadline = Date.now() + 5000; + while (!fs.existsSync(mockServerPortPath)) { + if (Date.now() > startDeadline) { + throw new Error('Timed out waiting for mock DevTools server to start'); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/browser-target`, + 'utf8' + ); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_PROFILE_DIR: browserProfileDir, + }); + + expect(result.stderr).not.toContain('Browser MCP is enabled, but CCS could not prepare the local browser tool.'); + expect(result.stderr).not.toContain('could not sync the browser MCP config'); + expect(result.stderr).not.toContain('Chrome reuse metadata not found'); + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain('--append-system-prompt'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index 634c199a..45a9f1a5 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -98,7 +98,49 @@ describe('ClaudeAdapter', () => { expect(env['ANTHROPIC_MODEL']).toBe('claude-opus-4-6'); }); - it('should pass through args unchanged', () => { + it('should append browser steering prompt when browser runtime env exists', () => { + const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose'], { + creds: { + profile: 'gemini', + baseUrl: 'https://api.example.com', + apiKey: 'test-key', + browserRuntimeEnv: { + CCS_BROWSER_USER_DATA_DIR: '/tmp/chrome', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9222', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }, + }, + profileType: 'settings', + }); + + expect(args).toContain('--append-system-prompt'); + expect(args.join(' ')).toContain('prefer the CCS MCP Browser tool'); + }); + + it('should merge browser runtime env into Claude launch env', () => { + const env = adapter.buildEnv( + { + profile: 'gemini', + baseUrl: 'https://api.example.com', + apiKey: 'test-key', + browserRuntimeEnv: { + CCS_BROWSER_USER_DATA_DIR: '/tmp/chrome', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9222', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }, + }, + 'settings' + ); + + expect(env['CCS_BROWSER_USER_DATA_DIR']).toBe('/tmp/chrome'); + expect(env['CCS_BROWSER_DEVTOOLS_WS_URL']).toBe('ws://127.0.0.1/devtools/browser/test'); + }); + + it('should pass through args unchanged when browser runtime env is absent', () => { const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose']); expect(args).toEqual(['-p', 'hello', '--verbose']); }); diff --git a/tests/unit/utils/browser/chrome-reuse.test.ts b/tests/unit/utils/browser/chrome-reuse.test.ts new file mode 100644 index 00000000..79a78e81 --- /dev/null +++ b/tests/unit/utils/browser/chrome-reuse.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + resolveBrowserRuntimeEnv, + resolveDefaultChromeUserDataDir, +} from '../../../../src/utils/browser/chrome-reuse'; + +describe('chrome reuse resolver', () => { + const originalLocalAppData = process.env.LOCALAPPDATA; + let tempDirs: string[] = []; + let servers: Array<{ stop: () => void }> = []; + + function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + function writeDevToolsActivePort(profileDir: string, content: string): void { + fs.mkdirSync(profileDir, { recursive: true }); + fs.writeFileSync(path.join(profileDir, 'DevToolsActivePort'), content, 'utf8'); + } + + async function startDevToolsServer(versionPayload: Record) { + const server = Bun.serve({ + port: 0, + fetch(request: Request) { + if (new URL(request.url).pathname === '/json/version') { + return Response.json(versionPayload); + } + return new Response('not found', { status: 404 }); + }, + }); + servers.push(server); + return server; + } + + async function startFailingDevToolsServer(status: number, body = 'error') { + const server = Bun.serve({ + port: 0, + fetch(request: Request) { + if (new URL(request.url).pathname === '/json/version') { + return new Response(body, { status }); + } + return new Response('not found', { status: 404 }); + }, + }); + servers.push(server); + return server; + } + + async function startMalformedJsonDevToolsServer() { + const server = Bun.serve({ + port: 0, + fetch(request: Request) { + if (new URL(request.url).pathname === '/json/version') { + return new Response('{invalid-json', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('not found', { status: 404 }); + }, + }); + servers.push(server); + return server; + } + + async function reserveClosedPort(): Promise { + const server = Bun.serve({ + port: 0, + fetch() { + return new Response('ok'); + }, + }); + const port = server.port; + server.stop(true); + return port; + } + + afterEach(() => { + for (const server of servers) { + server.stop(true); + } + servers = []; + + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; + + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + }); + + it('uses explicit profile-dir before the default path and resolves the websocket target', async () => { + const explicitProfileDir = createTempDir('ccs-chrome-explicit-'); + const defaultProfileDir = createTempDir('ccs-chrome-default-'); + const server = await startDevToolsServer({ + Browser: 'Chrome/136.0.0.0', + webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/target-1', + }); + + writeDevToolsActivePort(explicitProfileDir, `${server.port}\n/devtools/browser/from-explicit`); + + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir: explicitProfileDir, + }); + + expect(runtimeEnv).toEqual({ + CCS_BROWSER_USER_DATA_DIR: explicitProfileDir, + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: String(server.port), + CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${server.port}`, + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/target-1', + }); + expect(fs.existsSync(path.join(defaultProfileDir, 'DevToolsActivePort'))).toBe(false); + }); + + it('uses an explicit devtools port override when metadata is missing', async () => { + const profileDir = createTempDir('ccs-chrome-explicit-port-'); + const server = await startDevToolsServer({ + Browser: 'Chrome/136.0.0.0', + webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/explicit-port', + }); + + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir, + devtoolsPort: String(server.port), + }); + + expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_PORT).toBe(String(server.port)); + expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_WS_URL).toBe('ws://127.0.0.1/devtools/browser/explicit-port'); + }); + + it('throws a clear error when DevToolsActivePort metadata is missing', async () => { + const profileDir = createTempDir('ccs-chrome-missing-metadata-'); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}` + ); + }); + + it('throws a clear error when DevToolsActivePort metadata is invalid', async () => { + const profileDir = createTempDir('ccs-chrome-invalid-metadata-'); + writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target'); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}` + ); + }); + + it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => { + const profileDir = createTempDir('ccs-chrome-unreachable-'); + const port = await reserveClosedPort(); + writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}` + ); + }); + + it('throws a clear error when the DevTools endpoint is reachable without a websocket target', async () => { + const profileDir = createTempDir('ccs-chrome-missing-ws-'); + const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' }); + writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version` + ); + }); + + it('throws a clear error when the DevTools endpoint returns a non-200 response', async () => { + const profileDir = createTempDir('ccs-chrome-bad-status-'); + const server = await startFailingDevToolsServer(500); + writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` + ); + }); + + it('throws a clear error when the DevTools endpoint returns malformed JSON', async () => { + const profileDir = createTempDir('ccs-chrome-bad-json-'); + const server = await startMalformedJsonDevToolsServer(); + writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` + ); + }); + + it('resolves platform default Chrome user-data-dir paths', () => { + process.env.LOCALAPPDATA = 'C:/Users/test/AppData/Local'; + + expect(resolveDefaultChromeUserDataDir('darwin')).toBe( + path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome') + ); + expect(resolveDefaultChromeUserDataDir('linux')).toBe( + path.join(os.homedir(), '.config', 'google-chrome') + ); + expect(resolveDefaultChromeUserDataDir('win32')).toBe( + path.normalize('C:/Users/test/AppData/Local/Google/Chrome/User Data') + ); + }); + + it('throws on win32 when LOCALAPPDATA is missing', () => { + delete process.env.LOCALAPPDATA; + + expect(() => resolveDefaultChromeUserDataDir('win32')).toThrow( + 'LOCALAPPDATA is required to resolve the default Chrome user-data-dir on Windows' + ); + }); + + it('throws a clear error when the resolved profile directory does not exist', async () => { + const missingProfileDir = path.join(createTempDir('ccs-chrome-missing-dir-'), 'missing-profile'); + + await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow( + `Chrome profile directory is invalid: ${missingProfileDir}` + ); + }); +}); diff --git a/tests/unit/utils/browser/claude-tool-args.test.ts b/tests/unit/utils/browser/claude-tool-args.test.ts new file mode 100644 index 00000000..57077dff --- /dev/null +++ b/tests/unit/utils/browser/claude-tool-args.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test'; +import { appendBrowserToolArgs } from '../../../../src/utils/browser/claude-tool-args'; + +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.'; + +describe('appendBrowserToolArgs', () => { + it('appends the browser steering prompt when it is missing', () => { + expect(appendBrowserToolArgs(['navigate'])).toEqual([ + 'navigate', + '--append-system-prompt', + BROWSER_STEERING_PROMPT, + ]); + }); + + it('does not append the prompt when it already exists in equals form', () => { + expect( + appendBrowserToolArgs([ + 'navigate', + `--append-system-prompt=${BROWSER_STEERING_PROMPT}`, + ]) + ).toEqual(['navigate', `--append-system-prompt=${BROWSER_STEERING_PROMPT}`]); + }); + + it('does not append the prompt when it already exists in split-flag form', () => { + expect( + appendBrowserToolArgs([ + 'navigate', + '--append-system-prompt', + BROWSER_STEERING_PROMPT, + ]) + ).toEqual(['navigate', '--append-system-prompt', BROWSER_STEERING_PROMPT]); + }); + + it('inserts the prompt before the end-of-options terminator', () => { + expect(appendBrowserToolArgs(['--', 'take screenshot'])).toEqual([ + '--append-system-prompt', + BROWSER_STEERING_PROMPT, + '--', + 'take screenshot', + ]); + }); +}); diff --git a/tests/unit/utils/browser/mcp-installer.test.ts b/tests/unit/utils/browser/mcp-installer.test.ts new file mode 100644 index 00000000..58ab3b23 --- /dev/null +++ b/tests/unit/utils/browser/mcp-installer.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as browserInstaller from '../../../../src/utils/browser/mcp-installer'; +import { + ensureBrowserMcp, + ensureBrowserMcpConfig, + ensureBrowserMcpOrThrow, + getBrowserMcpServerName, + getBrowserMcpServerPath, + syncBrowserMcpToConfigDir, + uninstallBrowserMcp, +} from '../../../../src/utils/browser'; + +describe('ensureBrowserMcp', () => { + let tempHome: string | undefined; + let originalCcsHome: string | undefined; + + function setupTempHome(): string { + const nextTempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-mcp-')); + tempHome = nextTempHome; + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = nextTempHome; + return nextTempHome; + } + + function getManagedConfig() { + return { + type: 'stdio', + command: 'node', + args: [getBrowserMcpServerPath()], + env: { + NODE_PATH: path.join(process.cwd(), 'node_modules'), + }, + }; + } + + afterEach(() => { + mock.restore(); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + + tempHome = undefined; + originalCcsHome = undefined; + }); + + it('installs the bundled browser MCP server and preserves existing user mcpServers entries', () => { + setupTempHome(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync( + claudeUserConfigPath, + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['some-server'] }, + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(ensureBrowserMcp()).toBe(true); + expect(fs.existsSync(getBrowserMcpServerPath())).toBe(true); + + const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { + mcpServers: Record; + }; + + expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] }); + expect(config.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig()); + }); + + it('preserves the existing ~/.claude.json permissions when provisioning browser MCP', () => { + setupTempHome(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync(claudeUserConfigPath, JSON.stringify({ existing: true }, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); + fs.chmodSync(claudeUserConfigPath, 0o600); + + expect(ensureBrowserMcpConfig()).toBe(true); + expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600); + }); + + it('copies the bundled browser MCP artifact while preserving source permissions', () => { + setupTempHome(); + + const bundledServerPath = path.join( + process.cwd(), + 'lib', + 'mcp', + 'ccs-browser-server.cjs' + ); + const originalMode = fs.statSync(bundledServerPath).mode & 0o777; + + expect(ensureBrowserMcp()).toBe(true); + expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode); + }); + + it('reconciles installed browser MCP permissions when contents already match', () => { + setupTempHome(); + + const bundledServerPath = path.join( + process.cwd(), + 'lib', + 'mcp', + 'ccs-browser-server.cjs' + ); + const originalMode = fs.statSync(bundledServerPath).mode & 0o777; + + expect(ensureBrowserMcp()).toBe(true); + fs.chmodSync(getBrowserMcpServerPath(), 0o600); + + expect(ensureBrowserMcp()).toBe(true); + expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode); + }); + + it('removes the managed browser runtime while preserving unrelated server entries', () => { + setupTempHome(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync( + claudeUserConfigPath, + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['some-server'] }, + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(ensureBrowserMcp()).toBe(true); + + const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work'); + fs.mkdirSync(instancePath, { recursive: true }); + fs.writeFileSync( + path.join(instancePath, '.claude.json'), + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['instance-server'] }, + [getBrowserMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] }, + }, + otherKey: 'keep-me', + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(uninstallBrowserMcp()).toBe(true); + expect(fs.existsSync(getBrowserMcpServerPath())).toBe(false); + + const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { + mcpServers: Record; + }; + expect(globalConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['some-server'] }, + }); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') + ) as { + otherKey: string; + mcpServers: Record; + }; + expect(instanceConfig.otherKey).toBe('keep-me'); + expect(instanceConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['instance-server'] }, + }); + }); + + it('syncs the managed browser MCP entry into an instance config dir', () => { + setupTempHome(); + + fs.writeFileSync( + path.join(tempHome as string, '.claude.json'), + JSON.stringify( + { + mcpServers: { + [getBrowserMcpServerName()]: getManagedConfig(), + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work'); + fs.mkdirSync(instancePath, { recursive: true }); + + expect(syncBrowserMcpToConfigDir(instancePath)).toBe(true); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') + ) as { + mcpServers: Record; + }; + expect(instanceConfig.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig()); + }); + + it('throws when the bundled browser MCP runtime cannot be prepared', () => { + setupTempHome(); + + const ensureSpy = spyOn(browserInstaller, 'ensureBrowserMcp').mockReturnValue(false); + + expect(() => ensureBrowserMcpOrThrow()).toThrow( + 'Browser MCP is enabled, but CCS could not prepare the local browser tool.' + ); + expect(ensureSpy).toHaveBeenCalled(); + }); +});