Merge pull request #974 from walker1211/feat/browser-mcp-pr

feat(browser): add CCS browser MCP integration and stabilize launch flow
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-13 09:34:27 -04:00
committed by GitHub
40 changed files with 4166 additions and 163 deletions
+5
View File
@@ -37,6 +37,7 @@
"@semantic-release/release-notes-generator": "^14.1.0",
"@tailwindcss/vite": "^4.1.17",
"@types/bcrypt": "^6.0.0",
"@types/bun": "^1.3.12",
"@types/chokidar": "^2.1.7",
"@types/express": "^4.17.21",
"@types/express-rate-limit": "6.0.0",
@@ -374,6 +375,8 @@
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/chokidar": ["@types/chokidar@2.1.7", "", { "dependencies": { "chokidar": "*" } }, "sha512-A7/MFHf6KF7peCzjEC1BBTF8jpmZTokb3vr/A0NxRGfwRLK3Ws+Hq6ugVn6cJIMfM6wkCak/aplWrxbTcu8oig=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
@@ -506,6 +509,8 @@
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
+877
View File
@@ -0,0 +1,877 @@
#!/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 a minimal mouse event chain with click fallback. 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 || '<untitled>'} | ${page.url || '<empty>'}`),
].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 (!element.isConnected) {
throw new Error('element is detached for selector: ' + selector);
}
if ('disabled' in element && element.disabled) {
throw new Error('element is disabled for selector: ' + selector);
}
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
rect.width <= 0 ||
rect.height <= 0
) {
throw new Error('element is hidden or not interactable for selector: ' + selector);
}
element.scrollIntoView({ block: 'center', inline: 'center' });
const dispatchMouseEvent = (type, init) => {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
view: window,
detail: 1,
...init,
});
return element.dispatchEvent(event);
};
try {
const dispatchResult = {
shouldActivate:
dispatchMouseEvent('mousedown', { button: 0, buttons: 1 }) &&
dispatchMouseEvent('mouseup', { button: 0, buttons: 0 }),
};
if (!dispatchResult.shouldActivate) {
return 'ok';
}
if (!element.isConnected) {
return 'ok';
}
} catch (mouseError) {
// Fall through to the native activation path below.
}
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 || '<missing>';
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();
+1
View File
@@ -124,6 +124,7 @@
"@semantic-release/release-notes-generator": "^14.1.0",
"@tailwindcss/vite": "^4.1.17",
"@types/bcrypt": "^6.0.0",
"@types/bun": "^1.3.12",
"@types/chokidar": "^2.1.7",
"@types/express": "^4.17.21",
"@types/express-rate-limit": "6.0.0",
+75 -2
View File
@@ -36,6 +36,13 @@ import {
syncImageAnalysisMcpToConfigDir,
appendThirdPartyImageAnalysisToolArgs,
} from './utils/image-analysis';
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
resolveBrowserRuntimeEnv,
resolveConfiguredBrowserProfileDir,
syncBrowserMcpToConfigDir,
} from './utils/browser';
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
@@ -69,6 +76,7 @@ import { execClaude } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
// Import target adapter system
import {
@@ -117,6 +125,15 @@ interface RuntimeReasoningResolution {
const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
function resolveCodexRuntimeConfigOverrides(
target: ReturnType<typeof resolveTargetType>
): string[] {
if (target !== 'codex') {
return [];
}
return buildCodexBrowserMcpOverrides();
}
/**
* Smart profile detection
*/
@@ -624,6 +641,7 @@ async function main(): Promise<void> {
// For non-claude targets, verify target binary exists once and pass it through.
const targetBinaryInfo = targetAdapter?.detectBinary() ?? null;
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(resolvedTarget);
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
const displayName = targetAdapter?.displayName || resolvedTarget;
console.error(fail(`${displayName} CLI not found.`));
@@ -867,6 +885,7 @@ async function main(): Promise<void> {
model: envVars['ANTHROPIC_MODEL'],
}),
reasoningOverride: runtimeReasoningOverride,
runtimeConfigOverrides: codexRuntimeConfigOverrides,
envVars,
};
@@ -982,8 +1001,16 @@ async function main(): Promise<void> {
// Settings-based profiles (glm, glmt) are third-party providers
const imageAnalysisMcpReady =
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
const browserProfileDir =
resolvedTarget === 'claude'
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
: undefined;
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
if (browserProfileDir) {
ensureBrowserMcpOrThrow();
}
}
// Display WebSearch status (single line, equilibrium UX)
@@ -1007,6 +1034,15 @@ async function main(): Promise<void> {
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserProfileDir &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
throw new Error(
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
);
}
const expandedSettingsPath =
resolvedSettingsPath ??
(profileInfo.settingsPath
@@ -1208,12 +1244,21 @@ async function main(): Promise<void> {
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
// values from prior sessions cannot leak into the active profile.
if (browserProfileDir) {
browserRuntimeEnv = {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
})),
};
}
const envVars: NodeJS.ProcessEnv = {
...globalEnv,
...settingsEnv,
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
...(browserRuntimeEnv || {}),
CCS_PROFILE_TYPE: 'settings',
};
@@ -1238,6 +1283,7 @@ async function main(): Promise<void> {
model: settingsEnv['ANTHROPIC_MODEL'],
}),
reasoningOverride: runtimeReasoningOverride,
runtimeConfigOverrides: codexRuntimeConfigOverrides,
envVars,
};
await adapter.prepareCredentials(creds);
@@ -1254,10 +1300,13 @@ async function main(): Promise<void> {
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(remainingArgs)
: remainingArgs;
const browserArgs = browserRuntimeEnv
? appendBrowserToolArgs(imageAnalysisArgs)
: imageAnalysisArgs;
const launchArgs = [
'--settings',
expandedSettingsPath,
...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile',
@@ -1314,8 +1363,22 @@ async function main(): Promise<void> {
CCS_WEBSEARCH_SKIP: '1',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
const browserProfileDir =
resolvedTarget === 'claude'
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
: undefined;
if (resolvedTarget === 'claude') {
if (browserProfileDir) {
ensureBrowserMcpOrThrow();
browserRuntimeEnv = {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
})),
};
Object.assign(envVars, browserRuntimeEnv);
}
const defaultContinuityInheritance = await resolveProfileContinuityInheritance({
profileName: profileInfo.name,
profileType: profileInfo.type,
@@ -1330,6 +1393,14 @@ async function main(): Promise<void> {
}
if (defaultContinuityInheritance.claudeConfigDir) {
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
if (
browserProfileDir &&
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
) {
throw new Error(
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
);
}
}
}
@@ -1355,6 +1426,8 @@ async function main(): Promise<void> {
model: process.env['ANTHROPIC_MODEL'],
}),
reasoningOverride: runtimeReasoningOverride,
runtimeConfigOverrides: codexRuntimeConfigOverrides,
browserRuntimeEnv,
};
if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) {
console.error(
@@ -1377,7 +1450,7 @@ async function main(): Promise<void> {
}
const launchArgs = resolveNativeClaudeLaunchArgs(
remainingArgs,
browserRuntimeEnv ? appendBrowserToolArgs(remainingArgs) : remainingArgs,
'default',
envVars.CLAUDE_CONFIG_DIR
);
+4
View File
@@ -74,6 +74,8 @@ export interface ProxyChainConfig {
claudeConfigDir?: string;
/** Execution-aware image analysis env prepared by the caller */
imageAnalysisEnv?: Record<string, string>;
/** Optional browser runtime env for Claude browser MCP reuse. */
browserRuntimeEnv?: Record<string, string>;
}
interface CliproxyImageAnalysisDeps {
@@ -240,6 +242,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
compositeDefaultTier,
claudeConfigDir,
imageAnalysisEnv: resolvedImageAnalysisEnv,
browserRuntimeEnv,
} = config;
// Build base env vars - check remote mode first
@@ -394,6 +397,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
...(browserRuntimeEnv || {}),
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
};
+41 -1
View File
@@ -63,6 +63,13 @@ import {
syncImageAnalysisMcpToConfigDir,
appendThirdPartyImageAnalysisToolArgs,
} from '../../utils/image-analysis';
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
resolveBrowserRuntimeEnv,
resolveConfiguredBrowserProfileDir,
syncBrowserMcpToConfigDir,
} from '../../utils/browser';
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import {
@@ -245,6 +252,10 @@ export async function execClaudeWithCLIProxy(
// Setup first-class CCS WebSearch runtime
ensureWebSearchMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
const browserProfileDir = resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR);
if (browserProfileDir) {
ensureBrowserMcpOrThrow();
}
displayWebSearchStatus();
const providerConfig = getProviderConfig(provider);
@@ -1006,6 +1017,15 @@ export async function execClaudeWithCLIProxy(
}
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserProfileDir &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
throw new Error(
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
);
}
// Build initial env vars to get ANTHROPIC_BASE_URL
const initialEnvVars = buildClaudeEnvironment({
@@ -1101,6 +1121,14 @@ export async function execClaudeWithCLIProxy(
}
// 11. Build final environment with all proxy chains
const browserRuntimeEnv = browserProfileDir
? {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
})),
}
: undefined;
const env = buildClaudeEnvironment({
provider,
useRemoteProxy,
@@ -1128,6 +1156,7 @@ export async function execClaudeWithCLIProxy(
compositeDefaultTier: cfg.compositeDefaultTier,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
browserRuntimeEnv,
});
if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) {
@@ -1143,6 +1172,14 @@ export async function execClaudeWithCLIProxy(
}
const webSearchEnv = getWebSearchHookEnv();
if (process.env.CCS_DEBUG) {
console.error(
`[cliproxy-browser-debug] keys=${Object.keys(env)
.filter((key) => key.startsWith('CCS_BROWSER_'))
.sort()
.join(',')} ws=${env.CCS_BROWSER_DEVTOOLS_WS_URL || ''}`
);
}
logEnvironment(env, webSearchEnv, verbose);
if (imageAnalysisWarning) {
console.error(info(imageAnalysisWarning));
@@ -1222,10 +1259,13 @@ export async function execClaudeWithCLIProxy(
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
: claudeArgs;
const browserArgs = browserRuntimeEnv
? appendBrowserToolArgs(imageAnalysisArgs)
: imageAnalysisArgs;
const launchArgs = [
'--settings',
settingsPath,
...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'cliproxy.executor',
-3
View File
@@ -483,7 +483,6 @@ export class ToolSanitizationProxy {
port: upstreamUrl.port,
path: upstreamUrl.pathname + upstreamUrl.search,
method: originalReq.method,
timeout: this.config.timeoutMs,
headers: this.buildForwardHeaders(originalReq.headers),
},
(upstreamRes) => {
@@ -520,7 +519,6 @@ export class ToolSanitizationProxy {
port: upstreamUrl.port,
path: upstreamUrl.pathname + upstreamUrl.search,
method: originalReq.method,
timeout: this.config.timeoutMs,
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
},
(upstreamRes) => {
@@ -594,7 +592,6 @@ export class ToolSanitizationProxy {
port: upstreamUrl.port,
path: upstreamUrl.pathname + upstreamUrl.search,
method: originalReq.method,
timeout: this.config.timeoutMs,
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
},
(upstreamRes) => {
+2
View File
@@ -229,6 +229,8 @@ export interface ExecutorConfig {
profileName?: string;
/** Optional inherited continuity directory from mapped account profile */
claudeConfigDir?: string;
/** Optional browser runtime env for Claude browser MCP reuse. */
browserRuntimeEnv?: Record<string, string>;
}
/**
+2 -1
View File
@@ -121,9 +121,10 @@ export function loadMigrationCheckData(): MigrationCheckData {
if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) {
const legacyProfiles = legacyConfig.profiles as Record<string, unknown>;
const unifiedProfiles = unifiedConfig.profiles ?? {};
for (const profileName of Object.keys(legacyProfiles)) {
const targetProfileName = resolveAliasToCanonical(profileName);
if (!unifiedConfig.profiles[targetProfileName]) {
if (!unifiedProfiles[targetProfileName]) {
needsMigration = true;
break;
}
+5 -2
View File
@@ -14,7 +14,7 @@ import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
import type { AccountContextPolicy } from '../auth/account-context';
import { getCcsDir, getCcsHome } from '../utils/config-manager';
const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis']);
const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis', 'ccs-browser']);
/** Options for instance creation */
export interface InstanceOptions {
@@ -252,9 +252,12 @@ class InstanceManager {
}
instanceContent.mcpServers = mergedMcpServers;
const fileMode = fs.existsSync(instanceClaudeJson)
? fs.statSync(instanceClaudeJson).mode & 0o777
: 0o600;
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), {
encoding: 'utf8',
mode: 0o600,
mode: fileMode,
});
return true;
} catch (error) {
+14 -2
View File
@@ -12,6 +12,7 @@ import type { ProfileType } from '../types/profile';
import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from '../utils/shell-executor';
import { ErrorManager } from '../utils/error-manager';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { appendBrowserToolArgs } from '../utils/browser';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { runCleanup } from '../errors';
@@ -32,8 +33,16 @@ export class ClaudeAdapter implements TargetAdapter {
// No-op: Claude receives credentials via environment variables
}
buildArgs(_profile: string, userArgs: string[]): string[] {
return userArgs;
buildArgs(
_profile: string,
userArgs: string[],
options?: {
creds?: TargetCredentials;
profileType?: ProfileType;
binaryInfo?: TargetBinaryInfo;
}
): string[] {
return options?.creds?.browserRuntimeEnv ? appendBrowserToolArgs(userArgs) : userArgs;
}
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
@@ -51,6 +60,9 @@ export class ClaudeAdapter implements TargetAdapter {
if (creds.envVars) {
Object.assign(env, creds.envVars);
}
if (creds.browserRuntimeEnv) {
Object.assign(env, creds.browserRuntimeEnv);
}
if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl;
if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey;
+13 -8
View File
@@ -188,20 +188,23 @@ export class CodexAdapter implements TargetAdapter {
const profileType = options?.profileType || 'default';
const creds = options?.creds;
const reasoningOverride = normalizeCodexReasoningOverride(creds?.reasoningOverride);
const runtimeConfigOverrides = creds?.runtimeConfigOverrides ?? [];
if (profileType === 'default') {
const overrides = [...runtimeConfigOverrides];
if (reasoningOverride) {
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
}
if (overrides.length === 0) {
return userArgs;
}
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
if (reasoningOverride) {
throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo));
}
return [
...buildConfigOverrideArgs([
`model_reasoning_effort=${formatTomlString(reasoningOverride)}`,
]),
...userArgs,
];
return userArgs;
}
return userArgs;
return [...buildConfigOverrideArgs(overrides), ...userArgs];
}
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
@@ -233,6 +236,8 @@ export class CodexAdapter implements TargetAdapter {
overrides.push(`model=${formatTomlString(creds.model)}`);
}
overrides.push(...runtimeConfigOverrides);
if (reasoningOverride) {
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
}
+4 -3
View File
@@ -12,7 +12,7 @@ import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-d
import type { ProfileType } from '../types/profile';
import { upsertCcsModel } from './droid-config-manager';
import { resolveDroidProvider } from './droid-provider';
import { escapeShellArg } from '../utils/shell-executor';
import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { runCleanup } from '../errors';
@@ -74,10 +74,11 @@ export class DroidAdapter implements TargetAdapter {
}
/**
* Droid uses config file for credentials minimal env needed.
* Droid uses config file for credentials keep parent env, but strip stale
* ANTHROPIC_* values so prior CCS/CLIProxy sessions do not leak into Droid.
*/
buildEnv(_creds: TargetCredentials, _profileType: ProfileType): NodeJS.ProcessEnv {
return { ...process.env };
return { ...stripAnthropicEnv(process.env) };
}
exec(
+4
View File
@@ -29,8 +29,12 @@ export interface TargetCredentials {
* Targets may ignore this when unsupported.
*/
reasoningOverride?: string | number;
/** Target-native runtime config overrides (for example Codex -c key=value entries). */
runtimeConfigOverrides?: string[];
/** Additional env vars from profile resolution (websearch, hooks, etc.) */
envVars?: NodeJS.ProcessEnv;
/** Runtime browser reuse env passed through to Claude launches when browser MCP is active. */
browserRuntimeEnv?: NodeJS.ProcessEnv;
}
/**
+28
View File
@@ -0,0 +1,28 @@
const CODEX_BROWSER_MCP_SERVER_NAME = 'ccs_browser';
const DEFAULT_BROWSER_TOOL_TIMEOUT_SEC = 30;
const PLAYWRIGHT_MCP_PACKAGE = '@playwright/mcp@0.0.70';
function formatTomlString(value: string): string {
return JSON.stringify(value);
}
function formatTomlArray(values: string[]): string {
return JSON.stringify(values);
}
function getNpxCommand(): string {
return process.platform === 'win32' ? 'npx.cmd' : 'npx';
}
export function getCodexBrowserMcpServerName(): string {
return CODEX_BROWSER_MCP_SERVER_NAME;
}
export function buildCodexBrowserMcpOverrides(): string[] {
return [
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.command=${formatTomlString(getNpxCommand())}`,
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.args=${formatTomlArray(['-y', PLAYWRIGHT_MCP_PACKAGE])}`,
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.enabled=true`,
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.tool_timeout_sec=${DEFAULT_BROWSER_TOOL_TIMEOUT_SEC}`,
];
}
+233
View File
@@ -0,0 +1,233 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { expandPath } from '../helpers';
export interface BrowserReuseOptions {
profileDir?: string;
devtoolsPort?: string;
}
export interface BrowserRuntimeEnv {
CCS_BROWSER_USER_DATA_DIR: string;
CCS_BROWSER_DEVTOOLS_HOST: string;
CCS_BROWSER_DEVTOOLS_PORT: string;
CCS_BROWSER_DEVTOOLS_HTTP_URL: string;
CCS_BROWSER_DEVTOOLS_WS_URL: string;
}
const DEVTOOLS_HOST = '127.0.0.1';
const DEVTOOLS_ACTIVE_PORT_FILE = 'DevToolsActivePort';
export function resolveDefaultChromeUserDataDir(
platform = process.platform,
env: NodeJS.ProcessEnv = process.env
): string {
switch (platform) {
case 'darwin':
return path.join(
env.HOME || env.USERPROFILE || '',
'Library',
'Application Support',
'Google',
'Chrome'
);
case 'linux':
return path.join(env.HOME || env.USERPROFILE || '', '.config', 'google-chrome');
case 'win32': {
const localAppData = env.LOCALAPPDATA;
if (!localAppData) {
throw new Error(
'LOCALAPPDATA is required to resolve the default Chrome user-data-dir on Windows'
);
}
return path.join(localAppData, 'Google', 'Chrome', 'User Data');
}
default:
return path.join(env.HOME || env.USERPROFILE || '', '.config', 'google-chrome');
}
}
export function resolveConfiguredBrowserProfileDir(profileDir?: string): string | undefined {
if (profileDir?.trim()) {
return expandPath(profileDir);
}
try {
const defaultUserDataDir = resolveDefaultChromeUserDataDir();
return fs.existsSync(path.join(defaultUserDataDir, DEVTOOLS_ACTIVE_PORT_FILE))
? defaultUserDataDir
: undefined;
} catch {
return undefined;
}
}
export async function resolveBrowserRuntimeEnv(
options: BrowserReuseOptions
): Promise<BrowserRuntimeEnv> {
const requestedProfileDir = options.profileDir || resolveDefaultChromeUserDataDir();
const userDataDir = expandPath(requestedProfileDir);
const directoryStat = await safeStat(userDataDir);
if (!directoryStat?.isDirectory()) {
throw new Error(`Chrome profile directory is invalid: ${userDataDir}`);
}
const metadataPath = path.join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
const port = await resolveDevToolsPort({
userDataDir,
metadataPath,
explicitPort: options.devtoolsPort || process.env.CCS_BROWSER_DEVTOOLS_PORT,
});
const httpUrl = `http://${DEVTOOLS_HOST}:${port}`;
const versionUrl = `${httpUrl}/json/version`;
let versionPayload: unknown;
try {
const response = await fetch(versionUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
versionPayload = await response.json();
} catch {
throw new Error(`Chrome DevTools endpoint is unreachable: ${httpUrl}`);
}
const websocketUrl =
typeof versionPayload === 'object' &&
versionPayload !== null &&
typeof (versionPayload as Record<string, unknown>).webSocketDebuggerUrl === 'string'
? (versionPayload as Record<string, string>).webSocketDebuggerUrl
: '';
if (!websocketUrl) {
throw new Error(`Chrome DevTools endpoint did not provide a websocket target: ${versionUrl}`);
}
return {
CCS_BROWSER_USER_DATA_DIR: userDataDir,
CCS_BROWSER_DEVTOOLS_HOST: DEVTOOLS_HOST,
CCS_BROWSER_DEVTOOLS_PORT: port,
CCS_BROWSER_DEVTOOLS_HTTP_URL: httpUrl,
CCS_BROWSER_DEVTOOLS_WS_URL: websocketUrl,
};
}
function parseDevToolsPort(metadataPath: string, metadata: string): string {
const [rawPort] = metadata
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (!rawPort || !/^\d+$/.test(rawPort)) {
throw new Error(`Chrome reuse metadata is invalid: ${metadataPath}`);
}
const port = Number.parseInt(rawPort, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Chrome reuse metadata is invalid: ${metadataPath}`);
}
return String(port);
}
interface ResolveDevToolsPortOptions {
userDataDir: string;
metadataPath: string;
explicitPort?: string;
}
async function resolveDevToolsPort(options: ResolveDevToolsPortOptions): Promise<string> {
if (options.explicitPort?.trim()) {
return parsePortValue(options.explicitPort.trim(), 'CCS_BROWSER_DEVTOOLS_PORT');
}
let metadata: string | undefined;
try {
metadata = await fs.promises.readFile(options.metadataPath, 'utf8');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code && code !== 'ENOENT') {
throw new Error(`Chrome reuse metadata is unreadable: ${options.metadataPath}`);
}
}
if (metadata !== undefined) {
return parseDevToolsPort(options.metadataPath, metadata);
}
const discoveredPort = discoverDevToolsPortFromChromeProcess(options.userDataDir);
if (discoveredPort) {
return discoveredPort;
}
throw new Error(`Chrome reuse metadata not found: ${options.metadataPath}`);
}
function parsePortValue(rawPort: string, sourceLabel: string): string {
if (!/^\d+$/.test(rawPort)) {
throw new Error(`Chrome reuse metadata is invalid: ${sourceLabel}`);
}
const port = Number.parseInt(rawPort, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Chrome reuse metadata is invalid: ${sourceLabel}`);
}
return String(port);
}
function discoverDevToolsPortFromChromeProcess(userDataDir: string): string | undefined {
try {
const processList = execFileSync('ps', ['-axww', '-o', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
const lines = processList
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const normalizedDir = normalizePathForComparison(userDataDir);
for (const line of lines) {
if (!line.includes('Chrome') && !line.includes('chromium')) {
continue;
}
if (!matchesUserDataDir(line, normalizedDir)) {
continue;
}
const match = line.match(/--remote-debugging-port=(\d+)/);
if (match?.[1]) {
return parsePortValue(match[1], 'process-list');
}
}
} catch {
return undefined;
}
return undefined;
}
function matchesUserDataDir(commandLine: string, normalizedDir: string): boolean {
const candidates = [
`--user-data-dir=${normalizedDir}`,
`--user-data-dir="${normalizedDir}"`,
`--user-data-dir='${normalizedDir}'`,
];
const normalizedCommand = normalizePathForComparison(commandLine);
return candidates.some((candidate) => normalizedCommand.includes(candidate));
}
function normalizePathForComparison(value: string): string {
return process.platform === 'win32' ? value.toLowerCase() : value;
}
async function safeStat(targetPath: string): Promise<fs.Stats | null> {
try {
return await fs.promises.stat(targetPath);
} catch {
return null;
}
}
+22
View File
@@ -0,0 +1,22 @@
import {
hasExactFlagValue as hasExactClaudeFlagValue,
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
} from '../claude-tool-args';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const BROWSER_STEERING_PROMPT =
'For DOM/screenshots/elements/page actions, prefer the CCS MCP Browser tool, reuse the configured running Chrome context whenever possible, and if the tool or context is unavailable, explain that clearly instead of pretending page state is available.';
function ensureBrowserSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
if (hasExactClaudeFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, BROWSER_STEERING_PROMPT)) {
return args;
}
return [...optionArgs, APPEND_SYSTEM_PROMPT_FLAG, BROWSER_STEERING_PROMPT, ...trailingArgs];
}
export function appendBrowserToolArgs(args: string[]): string[] {
return ensureBrowserSteeringPrompt(args);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Browser Utilities
*/
export {
getBrowserMcpServerName,
getBrowserMcpServerPath,
installBrowserMcpServer,
ensureBrowserMcpConfig,
ensureBrowserMcp,
uninstallBrowserMcpServer,
removeBrowserMcpConfig,
uninstallBrowserMcp,
syncBrowserMcpToConfigDir,
ensureBrowserMcpOrThrow,
} from './mcp-installer';
export { appendBrowserToolArgs } from './claude-tool-args';
export {
resolveBrowserRuntimeEnv,
resolveDefaultChromeUserDataDir,
resolveConfiguredBrowserProfileDir,
} from './chrome-reuse';
export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse';
+375
View File
@@ -0,0 +1,375 @@
/**
* Browser MCP installer and ~/.claude.json provisioning.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { InstanceManager } from '../../management/instance-manager';
import { getClaudeUserConfigPath } from '../claude-config-path';
import { getCcsDir } from '../config-manager';
const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs';
const BROWSER_MCP_SERVER_NAME = 'ccs-browser';
interface ClaudeUserConfig {
mcpServers?: Record<string, unknown>;
[key: string]: unknown;
}
interface ManagedBrowserMcpConfig {
type: 'stdio';
command: 'node';
args: [string];
env: Record<string, string>;
}
function resolvePackageRoot(fromPath: string): string | null {
let currentDir = path.dirname(fromPath);
while (true) {
if (fs.existsSync(path.join(currentDir, 'package.json'))) {
return currentDir;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
return null;
}
currentDir = parentDir;
}
}
function getBrowserMcpServerEnv(): Record<string, string> {
const sourcePath = resolveBundledServerSourcePath();
if (!sourcePath) {
return {};
}
const packageRoot = resolvePackageRoot(sourcePath);
if (!packageRoot) {
return {};
}
const nodeModulesPath = path.join(packageRoot, 'node_modules');
return fs.existsSync(nodeModulesPath) ? { NODE_PATH: nodeModulesPath } : {};
}
function getCcsMcpDir(): string {
return path.join(getCcsDir(), 'mcp');
}
export function getBrowserMcpServerName(): string {
return BROWSER_MCP_SERVER_NAME;
}
export function getBrowserMcpServerPath(): string {
return path.join(getCcsMcpDir(), BROWSER_MCP_SERVER);
}
function hasMatchingContents(sourcePath: string, destinationPath: string): boolean {
if (!fs.existsSync(destinationPath)) {
return false;
}
const source = fs.readFileSync(sourcePath);
try {
const destination = fs.readFileSync(destinationPath);
return source.equals(destination);
} catch {
return false;
}
}
function getTempPath(targetPath: string): string {
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${targetPath}.${suffix}.tmp`;
}
function resolveBundledServerSourcePath(): string | null {
const possiblePaths = [
path.join(__dirname, '..', '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
path.join(__dirname, '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
path.join(__dirname, '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
];
for (const candidate of possiblePaths) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null {
if (!fs.existsSync(configPath)) {
return {};
}
try {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as ClaudeUserConfig;
} catch {
return null;
}
}
function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean {
const tempPath = getTempPath(configPath);
const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600;
try {
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
fs.chmodSync(tempPath, fileMode);
fs.renameSync(tempPath, configPath);
return true;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
const configDir = path.dirname(configPath);
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
let release: (() => void) | undefined;
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
if (!fs.existsSync(lockTarget)) {
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
}
try {
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
return callback();
} finally {
if (release) {
try {
release();
} catch {
// Best-effort release.
}
}
}
}
function isLockUnavailableError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
}
function removeManagedServerConfig(configPath: string): boolean {
if (!fs.existsSync(configPath)) {
return false;
}
try {
return withClaudeUserConfigLock(configPath, () => {
const config = readClaudeUserConfig(configPath);
if (config === null) {
return false;
}
const existingServers =
config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
? { ...(config.mcpServers as Record<string, unknown>) }
: {};
if (!(BROWSER_MCP_SERVER_NAME in existingServers)) {
return false;
}
delete existingServers[BROWSER_MCP_SERVER_NAME];
const nextConfig: ClaudeUserConfig = { ...config };
if (Object.keys(existingServers).length === 0) {
delete nextConfig.mcpServers;
} else {
nextConfig.mcpServers = existingServers;
}
try {
return writeClaudeUserConfig(configPath, nextConfig);
} catch {
return false;
}
});
} catch (error) {
if (isLockUnavailableError(error)) {
return false;
}
throw error;
}
}
export function installBrowserMcpServer(): boolean {
const sourcePath = resolveBundledServerSourcePath();
if (!sourcePath) {
return false;
}
const mcpDir = getCcsMcpDir();
if (!fs.existsSync(mcpDir)) {
fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 });
}
const serverPath = getBrowserMcpServerPath();
const sourceMode = fs.statSync(sourcePath).mode & 0o777;
if (hasMatchingContents(sourcePath, serverPath)) {
if ((fs.statSync(serverPath).mode & 0o777) !== sourceMode) {
fs.chmodSync(serverPath, sourceMode);
}
return true;
}
const tempPath = getTempPath(serverPath);
try {
fs.copyFileSync(sourcePath, tempPath);
fs.chmodSync(tempPath, sourceMode);
try {
fs.renameSync(tempPath, serverPath);
} catch (renameError) {
const errorCode = (renameError as NodeJS.ErrnoException).code;
if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') {
throw renameError;
}
if (!hasMatchingContents(sourcePath, serverPath)) {
fs.copyFileSync(tempPath, serverPath);
fs.chmodSync(serverPath, sourceMode);
}
}
return true;
} catch {
return false;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
export function ensureBrowserMcpConfig(): boolean {
const claudeUserConfigPath = getClaudeUserConfigPath();
const claudeUserConfigDir = path.dirname(claudeUserConfigPath);
if (!fs.existsSync(claudeUserConfigDir)) {
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
}
const desiredServerConfig: ManagedBrowserMcpConfig = {
type: 'stdio',
command: 'node',
args: [getBrowserMcpServerPath()],
env: getBrowserMcpServerEnv(),
};
try {
return withClaudeUserConfigLock(claudeUserConfigPath, () => {
const config = readClaudeUserConfig(claudeUserConfigPath);
if (config === null) {
return false;
}
const existingServers =
config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>)
: {};
const currentConfig = existingServers[BROWSER_MCP_SERVER_NAME];
if (
typeof currentConfig === 'object' &&
currentConfig !== null &&
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
) {
return true;
}
const nextConfig: ClaudeUserConfig = {
...config,
mcpServers: {
...existingServers,
[BROWSER_MCP_SERVER_NAME]: desiredServerConfig,
},
};
try {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
return true;
} catch {
return false;
}
});
} catch (error) {
if (isLockUnavailableError(error)) {
return false;
}
throw error;
}
}
export function ensureBrowserMcp(): boolean {
const installed = installBrowserMcpServer();
const configured = installed && ensureBrowserMcpConfig();
return installed && configured;
}
export function syncBrowserMcpToConfigDir(claudeConfigDir: string | undefined): boolean {
if (!claudeConfigDir) {
return false;
}
return new InstanceManager().syncMcpServers(claudeConfigDir);
}
export function uninstallBrowserMcpServer(): boolean {
const serverPath = getBrowserMcpServerPath();
if (!fs.existsSync(serverPath)) {
return false;
}
try {
fs.unlinkSync(serverPath);
return true;
} catch {
return false;
}
}
export function removeBrowserMcpConfig(): boolean {
let removed = removeManagedServerConfig(getClaudeUserConfigPath());
const instanceManager = new InstanceManager();
for (const instanceName of instanceManager.listInstances()) {
const instancePath = instanceManager.getInstancePath(instanceName);
const instanceClaudeConfigPath = path.join(instancePath, '.claude.json');
removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed;
}
return removed;
}
export function uninstallBrowserMcp(): boolean {
const removedConfig = removeBrowserMcpConfig();
const removedServer = uninstallBrowserMcpServer();
return removedConfig || removedServer;
}
export function ensureBrowserMcpOrThrow(): boolean {
const ready = ensureBrowserMcp();
if (!ready) {
throw new Error('Browser MCP is enabled, but CCS could not prepare the local browser tool.');
}
return ready;
}
+46
View File
@@ -0,0 +1,46 @@
export function splitArgsAtTerminator(args: string[]): {
optionArgs: string[];
trailingArgs: string[];
} {
const terminatorIndex = args.indexOf('--');
if (terminatorIndex === -1) {
return { optionArgs: args, trailingArgs: [] };
}
return {
optionArgs: args.slice(0, terminatorIndex),
trailingArgs: args.slice(terminatorIndex),
};
}
export function getImmediateFlagValue(args: string[], index: number): string | null {
const value = args[index + 1];
if (value === undefined || value === '--' || value.startsWith('--')) {
return null;
}
return value;
}
export function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value === expectedValue) {
return true;
}
continue;
}
if (arg === `${flag}=${expectedValue}`) {
return true;
}
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
return true;
}
}
return false;
}
+9 -46
View File
@@ -2,58 +2,21 @@
* Claude launch argument helpers for first-class Image Analysis.
*/
import {
hasExactFlagValue as hasExactClaudeFlagValue,
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
} from '../claude-tool-args';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const IMAGE_ANALYSIS_STEERING_PROMPT =
'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.';
function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } {
const terminatorIndex = args.indexOf('--');
if (terminatorIndex === -1) {
return { optionArgs: args, trailingArgs: [] };
}
return {
optionArgs: args.slice(0, terminatorIndex),
trailingArgs: args.slice(terminatorIndex),
};
}
function getImmediateFlagValue(args: string[], index: number): string | null {
const value = args[index + 1];
if (value === undefined || value === '--' || value.startsWith('--')) {
return null;
}
return value;
}
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value === expectedValue) {
return true;
}
continue;
}
if (arg === `${flag}=${expectedValue}`) {
return true;
}
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
return true;
}
}
return false;
}
function ensureImageAnalysisSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
if (hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)) {
if (
hasExactClaudeFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)
) {
return args;
}
+13 -47
View File
@@ -2,6 +2,12 @@
* Claude launch argument helpers for third-party WebSearch.
*/
import {
getImmediateFlagValue,
hasExactFlagValue as hasExactClaudeFlagValue,
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
} from '../claude-tool-args';
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
@@ -23,26 +29,6 @@ function mergeToolValues(rawValues: string[], toolName: string): string {
return merged.join(',');
}
function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } {
const terminatorIndex = args.indexOf('--');
if (terminatorIndex === -1) {
return { optionArgs: args, trailingArgs: [] };
}
return {
optionArgs: args.slice(0, terminatorIndex),
trailingArgs: args.slice(terminatorIndex),
};
}
function getImmediateFlagValue(args: string[], index: number): string | null {
const value = args[index + 1];
if (value === undefined || value === '--' || value.startsWith('--')) {
return null;
}
return value;
}
function hasToolInFlag(args: string[], flag: string, toolName: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
@@ -68,32 +54,8 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean
return false;
}
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value === expectedValue) {
return true;
}
continue;
}
if (arg === `${flag}=${expectedValue}`) {
return true;
}
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
return true;
}
}
return false;
}
function ensureDisallowedNativeWebSearchTool(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
if (hasToolInFlag(optionArgs, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) {
return args;
@@ -132,10 +94,14 @@ function ensureDisallowedNativeWebSearchTool(args: string[]): string[] {
}
function ensureWebSearchSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
if (
hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, THIRD_PARTY_WEBSEARCH_STEERING_PROMPT)
hasExactClaudeFlagValue(
optionArgs,
APPEND_SYSTEM_PROMPT_FLAG,
THIRD_PARTY_WEBSEARCH_STEERING_PROMPT
)
) {
return args;
}
+30 -6
View File
@@ -105,7 +105,27 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}
timeout: PROXY_TIMEOUT_MS,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
const proxyStatus = proxyRes.statusCode ?? 502;
const proxyContentLength = proxyRes.headers['content-length'];
const hasEmptyBody =
typeof proxyContentLength === 'string' && Number.parseInt(proxyContentLength, 10) === 0;
const isSyntheticUnreachableResponse =
proxyStatus === 502 &&
proxyRes.headers['content-type'] === undefined &&
proxyRes.headers['proxy-connection'] !== undefined &&
hasEmptyBody;
if (isSyntheticUnreachableResponse) {
const payload = JSON.stringify({ error: 'CLIProxy is not reachable' });
res.writeHead(502, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': String(Buffer.byteLength(payload)),
});
res.end(payload);
return;
}
res.writeHead(proxyStatus, proxyRes.headers);
// Manual streaming instead of pipe() for Bun runtime compatibility
proxyRes.on('data', (chunk: Buffer) => res.write(chunk));
proxyRes.on('end', () => res.end());
@@ -116,14 +136,18 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}
proxyReq.on('error', () => {
if (!res.headersSent) {
res.status(502).json({ error: 'CLIProxy is not reachable' });
const payload = JSON.stringify({ error: 'CLIProxy is not reachable' });
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Length', Buffer.byteLength(payload));
res.end(payload);
}
});
// Clean up proxy connection when client disconnects.
// Only use res.on('close') — req.on('close') fires with req.destroyed=true
// in Bun after body consumption, which would prematurely kill the proxy.
res.on('close', () => {
// Clean up proxy connection only when the client aborts the request.
// Avoid res.on('close') here because Bun may emit it during local error
// responses before the JSON body is flushed, which can truncate 502 payloads.
req.on('aborted', () => {
if (!res.writableEnded) {
proxyReq.destroy();
}
@@ -53,6 +53,11 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
env: {
...process.env,
CCS_IMAGE_ANALYSIS_SKIP: '', // clear any inherited skip flag
CCS_IMAGE_ANALYSIS_SKIP_HOOK: '', // clear any inherited MCP-ready bypass
CCS_IMAGE_ANALYSIS_MODEL: '', // let each test control explicit model fallback behavior
CCS_IMAGE_ANALYSIS_BACKEND_ID: '',
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '',
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '',
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
CCS_CLIPROXY_PORT: String(mockPort),
CCS_IMAGE_ANALYSIS_ENABLED: '1',
+10 -4
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'bun:test';
import { maybeWarnAboutResumeLaneMismatch } from '../../../src/auth/resume-lane-warning';
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
}
describe('resume lane warning', () => {
it('prints guidance when the plain ccs lane differs from the account lane', async () => {
const logs: string[] = [];
@@ -15,10 +19,12 @@ describe('resume lane warning', () => {
}),
});
expect(logs[0]).toContain('Resume for account "work" will search that account lane');
expect(logs).toContain('[i] Account lane: /tmp/account-lane');
expect(logs).toContain('[i] Plain ccs lane: native Claude lane (/tmp/native-lane)');
expect(logs).toContain('[i] Recover the original lane first: ccs -r');
const plainLogs = logs.map((message) => stripAnsi(message));
expect(plainLogs[0]).toContain('Resume for account "work" will search that account lane');
expect(plainLogs).toContain('[i] Account lane: /tmp/account-lane');
expect(plainLogs).toContain('[i] Plain ccs lane: native Claude lane (/tmp/native-lane)');
expect(plainLogs).toContain('[i] Recover the original lane first: ccs -r');
});
it('does not log anything when resume is not requested', async () => {
@@ -774,6 +774,11 @@ describe('ToolSanitizationProxy Integration', () => {
});
it('handles upstream connection errors gracefully', async () => {
const originalNoProxy = process.env.NO_PROXY;
const originalNoProxyLower = process.env.no_proxy;
process.env.NO_PROXY = '127.0.0.1,localhost';
process.env.no_proxy = '127.0.0.1,localhost';
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: 'http://127.0.0.1:1', // Invalid port
timeoutMs: 1000,
@@ -790,6 +795,16 @@ describe('ToolSanitizationProxy Integration', () => {
expect(response.status).toBe(502);
} finally {
proxy.stop();
if (originalNoProxy === undefined) {
delete process.env.NO_PROXY;
} else {
process.env.NO_PROXY = originalNoProxy;
}
if (originalNoProxyLower === undefined) {
delete process.env.no_proxy;
} else {
process.env.no_proxy = originalNoProxyLower;
}
}
});
});
@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
}
let calls: string[] = [];
let logLines: string[] = [];
let originalConsoleLog: typeof console.log;
@@ -60,7 +64,7 @@ describe('config-auth command routing', () => {
await handleConfigAuthCommand(['--help']);
expect(calls).toEqual([]);
expect(logLines.join('\n')).toContain('Dashboard Auth Management');
expect(stripAnsi(logLines.join('\n'))).toContain('Dashboard Auth Management');
});
it('rejects trailing arguments for zero-arg subcommands', async () => {
File diff suppressed because it is too large Load Diff
+53 -1
View File
@@ -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(() => {});
+41 -1
View File
@@ -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', () => {
@@ -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',
],
]);
});
@@ -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');
});
@@ -0,0 +1,214 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { request as httpRequest } from 'http';
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;
}
async function waitForMockDevtoolsPort(portFilePath: string, timeoutMs = 5000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
try {
const port = fs.readFileSync(portFilePath, 'utf8').trim();
if (/^\d+$/.test(port)) {
return port;
}
} catch {
// Keep polling until timeout.
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error('Timed out waiting for mock DevTools server port to become ready');
}
async function waitForDevtoolsVersionEndpoint(port: string, timeoutMs = 5000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
try {
await new Promise<void>((resolve, reject) => {
const req = httpRequest(
{
hostname: '127.0.0.1',
port: Number.parseInt(port, 10),
path: '/json/version',
method: 'GET',
},
(res) => {
res.resume();
if (res.statusCode === 200) {
resolve();
return;
}
reject(new Error(`Unexpected status: ${res.statusCode ?? 'unknown'}`));
}
);
req.on('error', reject);
req.end();
});
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
throw new Error('Timed out waiting for mock DevTools endpoint to become ready');
}
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('does not consume an empty mock DevTools port file before the port is written', async () => {
if (process.platform === 'win32') return;
const delayedPortFile = path.join(tmpHome, 'delayed-port.txt');
fs.writeFileSync(delayedPortFile, '', 'utf8');
setTimeout(() => {
fs.writeFileSync(delayedPortFile, '43123', 'utf8');
}, 50);
await expect(waitForMockDevtoolsPort(delayedPortFile, 500)).resolves.toBe('43123');
});
it('passes browser runtime env through default Claude launches when reuse is configured', 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/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 port = await waitForMockDevtoolsPort(mockServerPortPath);
await waitForDevtoolsVersionEndpoint(port);
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');
});
});
@@ -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');
});
});
+43 -1
View File
@@ -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']);
});
@@ -0,0 +1,237 @@
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<string, unknown>) {
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<number> {
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', () => {
const isolatedHome = createTempDir('ccs-chrome-home-');
const env = {
HOME: isolatedHome,
USERPROFILE: isolatedHome,
LOCALAPPDATA: 'C:/Users/test/AppData/Local',
};
expect(resolveDefaultChromeUserDataDir('darwin', env)).toBe(
path.join(isolatedHome, 'Library', 'Application Support', 'Google', 'Chrome')
);
expect(resolveDefaultChromeUserDataDir('linux', env)).toBe(
path.join(isolatedHome, '.config', 'google-chrome')
);
expect(resolveDefaultChromeUserDataDir('win32', env)).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}`
);
});
});
@@ -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',
]);
});
});
@@ -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<string, unknown>;
};
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<string, { command: string; args: string[] }>;
};
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<string, { command: string; args: string[] }>;
};
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<string, unknown>;
};
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();
});
});
+52 -27
View File
@@ -6,6 +6,25 @@
const assert = require('assert');
function stripAnsi(input) {
return input.replace(/\u001b\[[0-9;]*m/g, '');
}
function withoutForceColor(callback) {
const originalForceColor = process.env.FORCE_COLOR;
delete process.env.FORCE_COLOR;
try {
callback();
} finally {
if (originalForceColor === undefined) {
delete process.env.FORCE_COLOR;
} else {
process.env.FORCE_COLOR = originalForceColor;
}
}
}
describe('UI Module', function () {
let ui;
@@ -25,18 +44,21 @@ describe('UI Module', function () {
});
it('should return plain text when NO_COLOR is set', function () {
const originalNoColor = process.env.NO_COLOR;
process.env.NO_COLOR = '1';
withoutForceColor(() => {
const originalNoColor = process.env.NO_COLOR;
process.env.NO_COLOR = '1';
const result = ui.color('test', 'success');
assert.strictEqual(result, 'test', 'should return unmodified text');
// Restore
if (originalNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = originalNoColor;
}
try {
const result = ui.color('test', 'success');
assert.strictEqual(result, 'test', 'should return unmodified text');
} finally {
if (originalNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = originalNoColor;
}
}
});
});
it('should apply bold formatting', function () {
@@ -51,7 +73,7 @@ describe('UI Module', function () {
it('should apply gradient to text', function () {
const result = ui.gradientText('gradient header');
assert.ok(result.includes('gradient header'), 'should contain original text');
assert.ok(stripAnsi(result).includes('gradient header'), 'should contain original text');
});
});
@@ -129,7 +151,7 @@ describe('UI Module', function () {
describe('Headers', function () {
it('should format section header', function () {
const result = ui.header('Section Title');
assert.ok(result.includes('Section Title'), 'should include title text');
assert.ok(stripAnsi(result).includes('Section Title'), 'should include title text');
});
it('should format subsection header', function () {
@@ -153,21 +175,24 @@ describe('UI Module', function () {
describe('NO_COLOR Compliance', function () {
it('should disable colors when NO_COLOR is set', function () {
const originalNoColor = process.env.NO_COLOR;
process.env.NO_COLOR = '1';
withoutForceColor(() => {
const originalNoColor = process.env.NO_COLOR;
process.env.NO_COLOR = '1';
// All color functions should return plain text
assert.strictEqual(ui.color('text', 'success'), 'text');
assert.strictEqual(ui.bold('text'), 'text');
assert.strictEqual(ui.dim('text'), 'text');
assert.strictEqual(ui.gradientText('text'), 'text');
// Restore
if (originalNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = originalNoColor;
}
try {
// All color functions should return plain text
assert.strictEqual(ui.color('text', 'success'), 'text');
assert.strictEqual(ui.bold('text'), 'text');
assert.strictEqual(ui.dim('text'), 'text');
assert.strictEqual(ui.gradientText('text'), 'text');
} finally {
if (originalNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = originalNoColor;
}
}
});
});
});
});
+4 -1
View File
@@ -42,7 +42,10 @@
"forceConsistentCasingInFileNames": true,
// Performance
"incremental": true
"incremental": true,
// Runtime type declarations
"types": ["node", "bun"]
},
"include": [
"src/**/*"