diff --git a/bun.lock b/bun.lock index c0d9dcc3..dd4dcf00 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 79caee5a..c49a0f3a 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -1,14 +1,26 @@ # Cursor IDE Integration -This guide covers the local Cursor integration in CCS, including CLI setup, daemon lifecycle, and dashboard controls. +This guide covers the current CCS-owned Cursor runtime, including auth import, local daemon lifecycle, live probe checks, and dashboard controls. ## What It Provides - OpenAI-compatible local endpoint powered by Cursor credentials. - Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients. -- Cursor model list and chat completions via local daemon. +- Cursor model list and chat completions via the local CCS daemon. - Dedicated dashboard page: `ccs config` -> `Cursor IDE`. +## What This Runtime Actually Does + +`ccs cursor` does not launch Cursor IDE itself. + +The current workflow is: +1. import Cursor credentials from local SQLite or manual input +2. run a local CCS daemon on `127.0.0.1:` +3. launch Claude Code against that daemon +4. have CCS translate requests to Cursor upstream + +Treat this as a CCS-managed Cursor bridge, not a generic CLIProxy-backed provider path. + ## Prerequisites - Cursor IDE installed and logged in. @@ -43,13 +55,21 @@ ccs cursor auth --manual --token --machine-id ccs cursor start ``` -### 4) Run Cursor-backed Claude +### 4) Run a live probe + +```bash +ccs cursor probe +``` + +Use this to verify that the current build can complete one real authenticated request through the local daemon. + +### 5) Run Cursor-backed Claude ```bash ccs cursor "explain this repo" ``` -### 5) Verify status +### 6) Verify status ```bash ccs cursor status @@ -62,7 +82,7 @@ The admin namespace remains available for setup and inspection: ccs cursor help ``` -### 6) Stop daemon +### 7) Stop daemon ```bash ccs cursor stop @@ -76,6 +96,7 @@ ccs cursor stop - Model list resolution: authenticated live fetch when available, with cached/default fallback. - Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model. - Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`. +- Live verification: `ccs cursor probe` or `POST /api/cursor/probe` These values are managed in unified config and can be updated from CLI or dashboard. @@ -112,10 +133,16 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr - Re-run `ccs cursor auth` (or manual auth command). +### `ccs cursor probe` fails even though status is green + +- `status` proves local config/auth/daemon readiness only. +- `probe` proves the live runtime path. +- If `probe` fails with upstream protocol errors, inspect the current CCS build first rather than assuming the local daemon is healthy. + ### Auto-detect fails - Ensure Cursor is logged in. -- Confirm `sqlite3` is installed (macOS/Linux). +- Confirm `sqlite3` is installed or use manual import. - Use manual auth import if needed. ### Daemon fails to start diff --git a/docs/websearch.md b/docs/websearch.md index 054c75d3..29dcf740 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -1,6 +1,6 @@ # WebSearch Configuration Guide -Last Updated: 2026-04-04 +Last Updated: 2026-04-11 CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API. @@ -17,29 +17,30 @@ Third-party profiles cannot execute Anthropic's server-side WebSearch because th ## Architecture ``` -┌──────────────────────────────────────────────────────────────┐ -│ Claude Code CLI │ -│ │ -│ Search Request │ -│ │ │ -│ ├── Native Claude Account? → Anthropic WebSearch API │ -│ │ │ -│ └── Third-party Profile? → native WebSearch disabled │ -│ │ │ -│ ├── CCS MCP tool when ready│ -│ │ ccs-websearch.WebSearch│ -│ │ │ │ -│ │ ├── 1. Exa │ -│ │ ├── 2. Tavily│ -│ │ ├── 3. Brave │ -│ │ ├── 4. DuckDuckGo│ -│ │ └── 5. Legacy CLI│ -│ │ fallback │ -│ │ (Gemini/ │ -│ │ OpenCode/ │ -│ │ Grok) │ -│ └── Bash/network fallback │ -└──────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────┐ +│ Claude Code CLI │ +│ │ +│ Search Request │ +│ │ │ +│ ├── Native Claude Account? → Anthropic WebSearch API │ +│ │ │ +│ └── Third-party Profile? → native WebSearch disabled │ +│ │ │ +│ ├── CCS MCP tool when ready │ +│ │ ccs-websearch.WebSearch │ +│ │ │ │ +│ │ ├── 1. Exa │ +│ │ ├── 2. Tavily │ +│ │ ├── 3. Brave │ +│ │ ├── 4. SearXNG │ +│ │ ├── 5. DuckDuckGo│ +│ │ └── 6. Legacy CLI│ +│ │ fallback │ +│ │ (Gemini/ │ +│ │ OpenCode/│ +│ │ Grok) │ +│ └── Bash/network fallback │ +└──────────────────────────────────────────────────────────────────┘ ``` ## Why This Changed @@ -66,8 +67,9 @@ That shared launch helper applies to normal third-party settings profiles, CLIPr |----------|------|-------|---------|-------| | Exa | HTTP API | `EXA_API_KEY` | No | High-quality API search with extracted content | | Tavily | HTTP API | `TAVILY_API_KEY` | No | Agent-oriented search API | -| DuckDuckGo | HTML fetch | None | Yes | Built-in zero-setup fallback | | Brave Search | HTTP API | `BRAVE_API_KEY` | No | Cleaner snippets and metadata | +| SearXNG | JSON API | `providers.searxng.url` | No | Self-hosted/public SearXNG backend via `/search?format=json` | +| DuckDuckGo | HTML fetch | None | Yes | Built-in zero-setup fallback | | Gemini CLI | Legacy CLI | `npm i -g @google/gemini-cli` | No | Optional compatibility fallback | | OpenCode | Legacy CLI | `curl -fsSL https://opencode.ai/install \| bash` | No | Optional compatibility fallback | | Grok CLI | Legacy CLI | `npm i -g @vibe-kit/grok-cli` + `GROK_API_KEY` | No | Optional compatibility fallback | @@ -78,7 +80,9 @@ That shared launch helper applies to normal third-party settings profiles, CLIPr Open `ccs config` → `Settings` → `WebSearch`. -- Enable Exa, Tavily, Brave, or DuckDuckGo in the backend chain +- Enable Exa, Tavily, Brave, SearXNG, or DuckDuckGo in the backend chain +- Configure the SearXNG base URL (for example `https://search.example.com`) when SearXNG is enabled + Do not include `/search`, embedded credentials, query parameters, or URL fragments. CCS appends `/search?format=json`. - Set or rotate Exa, Tavily, and Brave API keys directly inside each provider card - Saved keys are persisted in `global_env` and injected at runtime, so readiness updates from the same screen - Review whether any legacy fallback CLIs are still enabled in config @@ -97,12 +101,16 @@ websearch: tavily: enabled: false max_results: 5 - duckduckgo: - enabled: true - max_results: 5 brave: enabled: false max_results: 5 + searxng: + enabled: false + url: "" + max_results: 5 + duckduckgo: + enabled: true + max_results: 5 gemini: enabled: false model: gemini-2.5-flash @@ -125,6 +133,8 @@ Note: `enabled: false` stops provisioning the managed local `ccs-websearch.WebSe | `EXA_API_KEY` | Enables Exa when `providers.exa.enabled: true` | | `TAVILY_API_KEY` | Enables Tavily when `providers.tavily.enabled: true` | | `BRAVE_API_KEY` | Enables Brave Search when `providers.brave.enabled: true` | +| `CCS_WEBSEARCH_SEARXNG_URL` | Runtime URL used when `providers.searxng.enabled: true` | +| `CCS_WEBSEARCH_SEARXNG_MAX_RESULTS` | Optional runtime override for SearXNG result count (clamped 1..10) | | `GROK_API_KEY` | Required only for legacy Grok CLI fallback | | `CCS_WEBSEARCH_SKIP` | Disable the CCS local WebSearch runtime for the current process; third-party launches still keep native Anthropic `WebSearch` disabled | | `CCS_DEBUG` | Verbose WebSearch runtime logging | @@ -156,6 +166,12 @@ ccs config If the dashboard says the key is stored but still not ready, check whether `Settings -> Global Env` is disabled. WebSearch reuses that injection path for dashboard-managed keys. +### SearXNG is enabled but not ready + +1. Confirm the configured base URL is valid (for example `https://search.example.com`) +2. Confirm the instance exposes `GET /search?q=&format=json` +3. If the hook reports `SearXNG returned 403: format=json is disabled on this instance`, enable JSON format on that SearXNG deployment or switch to another backend + ### I still want Gemini/OpenCode/Grok fallback Those providers remain supported, but they are no longer the primary path. Enable them explicitly in `config.yaml` if you want them as last-resort fallback. diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 79c5a433..6da24c3e 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -6,6 +6,7 @@ * - Exa Search API * - Tavily Search API * - Brave Search API + * - SearXNG JSON API * - DuckDuckGo HTML search * * Legacy compatibility fallback: @@ -369,6 +370,36 @@ function getResultCount(provider) { return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10) : DEFAULT_RESULT_COUNT; } +function getSearxngBaseUrl() { + const raw = (process.env.CCS_WEBSEARCH_SEARXNG_URL || '').trim(); + if (!raw) { + return ''; + } + + try { + const parsed = new URL(raw); + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.search || parsed.hash) { + return ''; + } + + if (parsed.username || parsed.password) { + return ''; + } + + let pathname = parsed.pathname.replace(/\/+$/, ''); + if (pathname.toLowerCase().endsWith('/search')) { + pathname = pathname.slice(0, -'/search'.length); + } + + parsed.pathname = pathname || '/'; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString().replace(/\/+$/, ''); + } catch { + return ''; + } +} + function buildPrompt(providerId, query) { const config = PROVIDER_CONFIG[providerId]; const parts = [ @@ -569,6 +600,104 @@ async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { } } +async function trySearxngSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + const baseUrl = getSearxngBaseUrl(); + if (!baseUrl) { + return { success: false, error: 'SearXNG URL is invalid or not configured' }; + } + + const params = new URLSearchParams({ + q: query, + format: 'json', + }); + + try { + const response = await fetchWithTimeout( + `${baseUrl}/search?${params.toString()}`, + { + headers: { + Accept: 'application/json', + 'User-Agent': USER_AGENT, + }, + }, + timeoutSec * 1000 + ); + + if (!response.ok) { + const body = await response.text(); + if ( + response.status === 403 && + /format(?:=|\s*)json|json[^\n]{0,40}disabled|disabled[^\n]{0,40}json/i.test(body) + ) { + return { + success: false, + error: 'SearXNG returned 403: format=json is disabled on this instance', + statusCode: response.status, + retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), + }; + } + + return { + success: false, + error: `SearXNG returned ${response.status}: ${body.slice(0, 160)}`, + statusCode: response.status, + retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), + }; + } + + let body; + try { + body = await response.json(); + } catch { + return { + success: false, + error: 'SearXNG returned malformed JSON payload', + }; + } + + if (!body || typeof body !== 'object' || !Array.isArray(body.results)) { + return { + success: false, + error: 'SearXNG JSON response is missing results[]', + }; + } + + const count = getResultCount('searxng'); + const results = body.results.slice(0, count).map((entry) => { + const result = entry && typeof entry === 'object' ? entry : {}; + const url = typeof result.url === 'string' ? result.url : ''; + const titleSource = + typeof result.title === 'string' && result.title.trim().length > 0 + ? result.title + : url || 'Untitled'; + const descriptionSource = + typeof result.content === 'string' + ? result.content + : typeof result.description === 'string' + ? result.description + : typeof result.snippet === 'string' + ? result.snippet + : ''; + + return { + title: compactText(titleSource, 120), + url, + description: compactText(descriptionSource, 240), + }; + }); + + return { + success: true, + content: formatStructuredSearchResults(query, 'SearXNG', results), + }; + } catch (error) { + return { + success: false, + error: error.name === 'AbortError' ? 'SearXNG timed out' : error.message, + }; + } +} + async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { const apiKey = getProviderApiKey('exa'); if (!apiKey) { @@ -892,6 +1021,12 @@ function getConfiguredProviders() { available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')), fn: tryBraveSearch, }, + { + name: 'SearXNG', + id: 'searxng', + available: () => isProviderEnabled('searxng') && Boolean(getSearxngBaseUrl()), + fn: trySearxngSearch, + }, { name: 'DuckDuckGo', id: 'duckduckgo', @@ -1280,4 +1415,5 @@ module.exports = { tryTavilySearch, tryDuckDuckGoSearch, tryBraveSearch, + trySearxngSearch, }; diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs new file mode 100755 index 00000000..3182c874 --- /dev/null +++ b/lib/mcp/ccs-browser-server.cjs @@ -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 || ''} | ${page.url || ''}`), + ].join('\n'); +} + +function createEvaluateExpression(kind) { + switch (kind) { + case 'url-title': + return `JSON.stringify({ title: document.title, url: location.href })`; + case 'visible-text': + return `document.body ? document.body.innerText : ''`; + case 'dom-snapshot': + return `document.documentElement ? document.documentElement.outerHTML : ''`; + default: + throw new Error(`Unknown browser evaluation kind: ${kind}`); + } +} + +async function sendCdpCommand(page, method, params = {}) { + const ws = new WebSocket(page.webSocketDebuggerUrl); + const requestId = ++requestCounter; + + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + settled = true; + ws.terminate(); + reject(new Error('Browser MCP timed out waiting for a DevTools response.')); + } + }, CDP_TIMEOUT_MS); + + function settleError(error) { + if (settled) { + return; + } + clearTimeout(timer); + settled = true; + reject(error); + } + + ws.on('open', () => { + ws.send( + JSON.stringify({ + id: requestId, + method, + params, + }) + ); + }); + + ws.on('message', (data) => { + if (settled) { + return; + } + + let message; + try { + message = JSON.parse(data.toString()); + } catch { + return; + } + + if (message.id !== requestId) { + return; + } + + clearTimeout(timer); + settled = true; + ws.close(); + + if (message.error) { + reject(new Error(message.error.message || 'DevTools request failed.')); + return; + } + + resolve(message.result || null); + }); + + ws.on('error', (error) => { + settleError(error); + }); + + ws.on('close', () => { + if (settled) { + return; + } + settleError(new Error('Browser MCP lost the DevTools websocket connection.')); + }); + }); +} + +async function evaluateInPage(page, kind) { + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: createEvaluateExpression(kind), + returnByValue: true, + }); + + const result = response && response.result ? response.result : null; + if (!result) { + throw new Error('Browser MCP received an invalid DevTools evaluation response.'); + } + + if (result.subtype === 'error') { + throw new Error(result.description || 'DevTools evaluation returned an error.'); + } + + return typeof result.value === 'string' ? result.value : result.value ?? ''; +} + +async function evaluateExpression(page, expression) { + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression, + returnByValue: true, + }); + + const result = response && response.result ? response.result : null; + if (!result) { + throw new Error('Browser MCP received an invalid DevTools evaluation response.'); + } + + if (result.subtype === 'error') { + throw new Error(result.description || 'DevTools evaluation returned an error.'); + } + + return typeof result.value === 'string' ? result.value : result.value ?? ''; +} + +function requireNonEmptyString(value, label) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} is required`); + } + return value.trim(); +} + +function requireString(value, label) { + if (typeof value !== 'string') { + throw new Error(`${label} is required`); + } + return value; +} + +function requireValidHttpUrl(value) { + const raw = requireNonEmptyString(value, 'url'); + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new Error('url must be an absolute http or https URL'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('url must be an absolute http or https URL'); + } + + return parsed.toString(); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getNavigationState(page) { + const raw = await evaluateExpression(page, `JSON.stringify({ href: location.href, readyState: document.readyState })`); + const parsed = JSON.parse(raw); + return { + href: typeof parsed.href === 'string' ? parsed.href : '', + readyState: typeof parsed.readyState === 'string' ? parsed.readyState : '', + }; +} + +function isSameDocumentHashNavigation(beforeHref, requestedUrl) { + try { + const before = new URL(beforeHref); + const requested = new URL(requestedUrl); + return ( + before.origin === requested.origin && + before.pathname === requested.pathname && + before.search === requested.search && + before.hash !== requested.hash + ); + } catch { + return false; + } +} + +function isNavigationReady(state, beforeHref, requestedUrl) { + if (state.readyState !== 'interactive' && state.readyState !== 'complete') { + return false; + } + + if (state.href === requestedUrl) { + return true; + } + + if (state.href && state.href !== beforeHref) { + return true; + } + + if (isSameDocumentHashNavigation(beforeHref, requestedUrl) && state.href === requestedUrl) { + return true; + } + + return false; +} + +async function waitForNavigationReady(page, beforeHref, requestedUrl) { + const deadline = Date.now() + CDP_TIMEOUT_MS; + + while (Date.now() <= deadline) { + const state = await getNavigationState(page); + if (isNavigationReady(state, beforeHref, requestedUrl)) { + return state.href; + } + if (Date.now() + NAVIGATION_POLL_INTERVAL_MS > deadline) { + break; + } + await sleep(NAVIGATION_POLL_INTERVAL_MS); + } + + throw new Error(`navigation did not complete for URL: ${requestedUrl}`); +} + +async function handleNavigate(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const url = requireValidHttpUrl(toolArgs.url); + const before = await getNavigationState(page); + const navigateResult = await sendCdpCommand(page, 'Page.navigate', { url }); + if (navigateResult && typeof navigateResult.errorText === 'string' && navigateResult.errorText) { + throw new Error(`navigation failed for URL: ${url}: ${navigateResult.errorText}`); + } + const finalUrl = await waitForNavigationReady(page, before.href, url); + return `pageIndex: ${pageIndex}\nurl: ${finalUrl}\nstatus: navigated`; +} + +async function handleClick(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const element = document.querySelector(selector); + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + if (!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 || ''; + const toolArgs = params.arguments || {}; + + if (!TOOL_NAMES.includes(toolName)) { + writeError(id, -32602, `Unknown tool: ${toolName}`); + return; + } + + if (!shouldExposeTools()) { + writeResponse(id, { + content: [ + { + type: 'text', + text: 'Browser MCP is unavailable because browser reuse is not configured for this Claude session.', + }, + ], + isError: true, + }); + return; + } + + try { + if (toolName === TOOL_SESSION_INFO) { + const pages = await listPageTargets(); + writeResponse(id, { + content: [{ type: 'text', text: formatSessionInfo(pages) }], + }); + return; + } + + if (toolName === TOOL_NAVIGATE) { + const text = await handleNavigate(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CLICK) { + const text = await handleClick(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_TYPE) { + const text = await handleType(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_TAKE_SCREENSHOT) { + const text = await handleScreenshot(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + const { page, pageIndex } = await getSelectedPage(toolArgs); + + if (toolName === TOOL_URL_TITLE) { + const raw = await evaluateInPage(page, 'url-title'); + const parsed = JSON.parse(raw); + writeResponse(id, { + content: [ + { + type: 'text', + text: `pageIndex: ${pageIndex}\ntitle: ${parsed.title || ''}\nurl: ${parsed.url || ''}`, + }, + ], + }); + return; + } + + if (toolName === TOOL_VISIBLE_TEXT) { + const text = await evaluateInPage(page, 'visible-text'); + writeResponse(id, { + content: [{ type: 'text', text: text || '' }], + }); + return; + } + + const html = await evaluateInPage(page, 'dom-snapshot'); + writeResponse(id, { + content: [{ type: 'text', text: html || '' }], + }); + } catch (error) { + writeResponse(id, { + content: [ + { + type: 'text', + text: `Browser MCP failed: ${(error && error.message) || String(error)}`, + }, + ], + isError: true, + }); + } +} + +async function handleMessage(message) { + if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { + return; + } + + switch (message.method) { + case 'initialize': + writeResponse(message.id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + }); + return; + case 'notifications/initialized': + return; + case 'ping': + writeResponse(message.id, {}); + return; + case 'tools/list': + writeResponse(message.id, { tools: getTools() }); + return; + case 'tools/call': + await handleToolCall(message); + return; + default: + if (message.id !== undefined) { + writeError(message.id, -32601, `Method not found: ${message.method}`); + } + } +} + +function parseMessages() { + while (true) { + let body; + const startsWithLegacyHeaders = inputBuffer + .subarray(0, Math.min(inputBuffer.length, 32)) + .toString('utf8') + .toLowerCase() + .startsWith('content-length:'); + + if (startsWithLegacyHeaders) { + const headerEnd = inputBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return; + } + + const headerText = inputBuffer.subarray(0, headerEnd).toString('utf8'); + const match = headerText.match(/content-length:\s*(\d+)/i); + if (!match) { + inputBuffer = Buffer.alloc(0); + return; + } + + const contentLength = Number.parseInt(match[1], 10); + const messageEnd = headerEnd + 4 + contentLength; + if (inputBuffer.length < messageEnd) { + return; + } + + body = inputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8'); + inputBuffer = inputBuffer.subarray(messageEnd); + } else { + const newlineIndex = inputBuffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + body = inputBuffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + inputBuffer = inputBuffer.subarray(newlineIndex + 1); + if (!body) { + continue; + } + } + + let message; + try { + message = JSON.parse(body); + } catch { + continue; + } + + Promise.resolve(handleMessage(message)).catch((error) => { + if (message && message.id !== undefined) { + writeError(message.id, -32603, (error && error.message) || 'Internal error'); + } + }); + } +} + +process.stdin.on('data', (chunk) => { + inputBuffer = Buffer.concat([inputBuffer, chunk]); + parseMessages(); +}); + +process.stdin.on('error', () => { + process.exit(0); +}); + +['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) => { + process.on(signal, () => process.exit(0)); +}); + +process.stdin.resume(); diff --git a/lib/mcp/ccs-websearch-server.cjs b/lib/mcp/ccs-websearch-server.cjs index 13880600..1b7d207f 100644 --- a/lib/mcp/ccs-websearch-server.cjs +++ b/lib/mcp/ccs-websearch-server.cjs @@ -16,7 +16,7 @@ const SERVER_VERSION = '1.0.0'; const TOOL_NAME = 'WebSearch'; const TOOL_ALIASES = ['search']; const TOOL_DESCRIPTION = - 'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.'; + 'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, SearXNG, DuckDuckGo, then optional legacy CLI fallback.'; function isSupportedToolName(name) { return name === TOOL_NAME || TOOL_ALIASES.includes(name); diff --git a/src/ccs.ts b/src/ccs.ts index a6edc978..5c907085 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -36,6 +36,13 @@ import { syncImageAnalysisMcpToConfigDir, appendThirdPartyImageAnalysisToolArgs, } from './utils/image-analysis'; +import { + appendBrowserToolArgs, + ensureBrowserMcpOrThrow, + resolveBrowserRuntimeEnv, + resolveConfiguredBrowserProfileDir, + syncBrowserMcpToConfigDir, +} from './utils/browser'; import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks, @@ -69,6 +76,7 @@ import { execClaude } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger } from './services/logging'; +import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides'; // Import target adapter system import { @@ -117,6 +125,15 @@ interface RuntimeReasoningResolution { const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']); +function resolveCodexRuntimeConfigOverrides( + target: ReturnType +): string[] { + if (target !== 'codex') { + return []; + } + return buildCodexBrowserMcpOverrides(); +} + /** * Smart profile detection */ @@ -624,6 +641,7 @@ async function main(): Promise { // For non-claude targets, verify target binary exists once and pass it through. const targetBinaryInfo = targetAdapter?.detectBinary() ?? null; + const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(resolvedTarget); if (resolvedTarget !== 'claude' && !targetBinaryInfo) { const displayName = targetAdapter?.displayName || resolvedTarget; console.error(fail(`${displayName} CLI not found.`)); @@ -867,6 +885,7 @@ async function main(): Promise { model: envVars['ANTHROPIC_MODEL'], }), reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, envVars, }; @@ -982,8 +1001,16 @@ async function main(): Promise { // Settings-based profiles (glm, glmt) are third-party providers const imageAnalysisMcpReady = resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; + let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; + const browserProfileDir = + resolvedTarget === 'claude' + ? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR) + : undefined; if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); + if (browserProfileDir) { + ensureBrowserMcpOrThrow(); + } } // Display WebSearch status (single line, equilibrium UX) @@ -1007,6 +1034,15 @@ async function main(): Promise { const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + if ( + browserProfileDir && + inheritedClaudeConfigDir && + !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) + ) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } const expandedSettingsPath = resolvedSettingsPath ?? (profileInfo.settingsPath @@ -1014,6 +1050,7 @@ async function main(): Promise { : getSettingsPath(profileInfo.name)); const settings = resolvedSettings ?? loadSettings(expandedSettingsPath); const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings); + let imageAnalysisFallbackHookReady: boolean | undefined; if (resolvedTarget === 'claude') { if (imageAnalysisMcpReady) { @@ -1208,12 +1245,21 @@ async function main(): Promise { // Explicitly inject effective settings env vars so stale ANTHROPIC_* // values from prior sessions cannot leak into the active profile. + if (browserProfileDir) { + browserRuntimeEnv = { + ...(await resolveBrowserRuntimeEnv({ + profileDir: browserProfileDir, + })), + }; + } + const envVars: NodeJS.ProcessEnv = { ...globalEnv, ...settingsEnv, ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, + ...(browserRuntimeEnv || {}), CCS_PROFILE_TYPE: 'settings', }; @@ -1238,6 +1284,7 @@ async function main(): Promise { model: settingsEnv['ANTHROPIC_MODEL'], }), reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, envVars, }; await adapter.prepareCredentials(creds); @@ -1254,10 +1301,13 @@ async function main(): Promise { const imageAnalysisArgs = imageAnalysisMcpReady ? appendThirdPartyImageAnalysisToolArgs(remainingArgs) : remainingArgs; + const browserArgs = browserRuntimeEnv + ? appendBrowserToolArgs(imageAnalysisArgs) + : imageAnalysisArgs; const launchArgs = [ '--settings', expandedSettingsPath, - ...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs), + ...appendThirdPartyWebSearchToolArgs(browserArgs), ]; const traceEnv = createWebSearchTraceContext({ launcher: 'ccs.settings-profile', @@ -1266,6 +1316,7 @@ async function main(): Promise { profileType: profileInfo.type, settingsPath: expandedSettingsPath, }); + execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv }); } else if (profileInfo.type === 'account') { // NEW FLOW: Account-based profile (work, personal) @@ -1314,8 +1365,22 @@ async function main(): Promise { CCS_WEBSEARCH_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', }; + let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; + const browserProfileDir = + resolvedTarget === 'claude' + ? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR) + : undefined; if (resolvedTarget === 'claude') { + if (browserProfileDir) { + ensureBrowserMcpOrThrow(); + browserRuntimeEnv = { + ...(await resolveBrowserRuntimeEnv({ + profileDir: browserProfileDir, + })), + }; + Object.assign(envVars, browserRuntimeEnv); + } const defaultContinuityInheritance = await resolveProfileContinuityInheritance({ profileName: profileInfo.name, profileType: profileInfo.type, @@ -1330,6 +1395,14 @@ async function main(): Promise { } if (defaultContinuityInheritance.claudeConfigDir) { envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; + if ( + browserProfileDir && + !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) + ) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } } } @@ -1355,6 +1428,8 @@ async function main(): Promise { model: process.env['ANTHROPIC_MODEL'], }), reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + browserRuntimeEnv, }; if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) { console.error( @@ -1377,7 +1452,7 @@ async function main(): Promise { } const launchArgs = resolveNativeClaudeLaunchArgs( - remainingArgs, + browserRuntimeEnv ? appendBrowserToolArgs(remainingArgs) : remainingArgs, 'default', envVars.CLAUDE_CONFIG_DIR ); diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index a6233879..bafcea63 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -74,6 +74,8 @@ export interface ProxyChainConfig { claudeConfigDir?: string; /** Execution-aware image analysis env prepared by the caller */ imageAnalysisEnv?: Record; + /** Optional browser runtime env for Claude browser MCP reuse. */ + browserRuntimeEnv?: Record; } interface CliproxyImageAnalysisDeps { @@ -240,6 +242,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record key.startsWith('CCS_BROWSER_')) + .sort() + .join(',')} ws=${env.CCS_BROWSER_DEVTOOLS_WS_URL || ''}` + ); + } logEnvironment(env, webSearchEnv, verbose); if (imageAnalysisWarning) { console.error(info(imageAnalysisWarning)); @@ -1222,10 +1259,13 @@ export async function execClaudeWithCLIProxy( const imageAnalysisArgs = imageAnalysisMcpReady ? appendThirdPartyImageAnalysisToolArgs(claudeArgs) : claudeArgs; + const browserArgs = browserRuntimeEnv + ? appendBrowserToolArgs(imageAnalysisArgs) + : imageAnalysisArgs; const launchArgs = [ '--settings', settingsPath, - ...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs), + ...appendThirdPartyWebSearchToolArgs(browserArgs), ]; const traceEnv = createWebSearchTraceContext({ launcher: 'cliproxy.executor', diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index fac34d03..45114812 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -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) => { diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 77462b0e..aae1912f 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -229,6 +229,8 @@ export interface ExecutorConfig { profileName?: string; /** Optional inherited continuity directory from mapped account profile */ claudeConfigDir?: string; + /** Optional browser runtime env for Claude browser MCP reuse. */ + browserRuntimeEnv?: Record; } /** diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 13374e65..adcdb5e5 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -5,10 +5,12 @@ import { SYNCABLE_PROVIDERS, getResolvedCatalog, refreshCatalogFromProxy, + getAllResolvedCatalogs, } from '../../cliproxy/catalog-cache'; import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import type { ThinkingSupport } from '../../cliproxy/model-catalog'; import type { CLIProxyProvider } from '../../cliproxy/types'; import type { RemoteModelInfo } from '../../cliproxy/management-api-types'; import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing'; @@ -173,6 +175,50 @@ export async function handleCatalogRefresh(verbose: boolean): Promise { console.log(''); } +/** JSON-serialisable model entry emitted by `catalog --json`. */ +interface CatalogJsonModel { + id: string; + name: string; + tier?: 'free' | 'pro' | 'ultra'; + description?: string; + deprecated?: boolean; + deprecationReason?: string; + broken?: boolean; + issueUrl?: string; + thinking?: ThinkingSupport; + extendedContext?: boolean; + nativeImageInput?: boolean; +} + +/** + * Output catalog as JSON for programmatic consumption. + * Used by OnSteroids and other tools to get available models per provider. + * Format: { [providerName: string]: CatalogJsonModel[] } + */ +export function handleCatalogJson(): void { + const catalogs = getAllResolvedCatalogs(); + const result: Record = {}; + for (const [provider, catalog] of Object.entries(catalogs)) { + if (!catalog) { + continue; + } + result[provider] = catalog.models.map((m) => { + const entry: CatalogJsonModel = { id: m.id, name: m.name }; + if (m.tier !== undefined) entry.tier = m.tier; + if (m.description !== undefined) entry.description = m.description; + if (m.deprecated !== undefined) entry.deprecated = m.deprecated; + if (m.deprecationReason !== undefined) entry.deprecationReason = m.deprecationReason; + if (m.broken !== undefined) entry.broken = m.broken; + if (m.issueUrl !== undefined) entry.issueUrl = m.issueUrl; + if (m.thinking !== undefined) entry.thinking = m.thinking; + if (m.extendedContext !== undefined) entry.extendedContext = m.extendedContext; + if (m.nativeImageInput !== undefined) entry.nativeImageInput = m.nativeImageInput; + return entry; + }); + } + console.log(JSON.stringify(result)); +} + /** Reset catalog cache */ export async function handleCatalogReset(): Promise { await initUI(); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 56a2863d..823b1317 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -39,6 +39,7 @@ export async function showHelp(): Promise { ['catalog', 'Show catalog status, routing hints, and pinned short prefixes'], ['catalog refresh', 'Sync models from remote CLIProxy'], ['catalog reset', 'Clear cache, revert to static catalog'], + ['catalog --json', 'Output full model catalog as minified JSON'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 50c9013a..52c4cb02 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -40,6 +40,7 @@ import { handleCatalogStatus, handleCatalogRefresh, handleCatalogReset, + handleCatalogJson, } from './catalog-subcommand'; /** @@ -146,6 +147,12 @@ export async function handleCliproxyCommand(args: string[]): Promise { // Catalog commands if (command === 'catalog') { + // --json takes priority over subcommands (refresh/reset) — it always + // outputs the current resolved catalog regardless of other arguments. + if (hasAnyFlag(remainingArgs, ['--json'])) { + handleCatalogJson(); + return; + } const subcommand = remainingArgs[1]; if (subcommand === 'refresh') { await handleCatalogRefresh(verbose); diff --git a/src/commands/cursor-command-display.ts b/src/commands/cursor-command-display.ts index e63c109a..3e7d4c56 100644 --- a/src/commands/cursor-command-display.ts +++ b/src/commands/cursor-command-display.ts @@ -1,4 +1,5 @@ import type { CursorAuthStatus, CursorDaemonStatus, CursorModel } from '../cursor/types'; +import type { CursorProbeResult } from '../cursor/cursor-runtime-probe'; import type { CursorConfig } from '../config/unified-config-types'; import { getCcsDirDisplay } from '../utils/config-manager'; import { color } from '../utils/ui'; @@ -18,6 +19,7 @@ export function renderCursorHelp(): number { 'Subcommands:', ' auth Import Cursor IDE authentication token', ' status Show integration, authentication, and daemon status', + ' probe Run a live authenticated runtime probe', ' models List available models', ' start Start cursor daemon', ' stop Stop cursor daemon', @@ -36,8 +38,9 @@ export function renderCursorHelp(): number { ' 1. ccs cursor enable # Enable integration', ' 2. ccs cursor auth # Import Cursor IDE token', ' 3. ccs cursor start # Start daemon', - ' 4. ccs cursor "task" # Run Claude through Cursor', - ' 5. ccs cursor status # Inspect auth/daemon wiring', + ' 4. ccs cursor probe # Verify live runtime health', + ' 5. ccs cursor "task" # Run Claude through Cursor', + ' 6. ccs cursor status # Inspect auth/daemon wiring', '', 'Or use the web UI: ccs config -> Cursor page', '', @@ -98,6 +101,7 @@ export function renderCursorStatus( console.log('Client setup:'); console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`); console.log(' Runtime entry: ccs cursor [claude args]'); + console.log(' Live probe: ccs cursor probe'); console.log(' Status command: ccs cursor status'); console.log(' Help command: ccs cursor help'); @@ -135,3 +139,21 @@ export function renderCursorModels(models: CursorModel[], defaultModel: string): console.log('Model selection is request-driven by the calling client.'); console.log('Dashboard: ccs config -> Cursor page'); } + +export function renderCursorProbe(result: CursorProbeResult): void { + const statusIcon = result.ok ? color('[OK]', 'success') : color('[X]', 'error'); + console.log('Cursor Live Probe'); + console.log('─────────────────'); + console.log(''); + console.log(`Result: ${statusIcon} ${result.ok ? 'Success' : 'Failure'}`); + console.log(`Stage: ${result.stage}`); + console.log(`HTTP status: ${result.status}`); + console.log(`Duration: ${result.duration_ms} ms`); + if (result.model) { + console.log(`Model: ${result.model}`); + } + if (result.error_type) { + console.log(`Error type: ${result.error_type}`); + } + console.log(`Message: ${result.message}`); +} diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index 6699fba1..b0f8f12d 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -14,10 +14,16 @@ import { getDaemonStatus, getAvailableModels, getDefaultModel, + probeCursorRuntime, } from '../cursor'; import { getCursorConfig, mutateUnifiedConfig } from '../config/unified-config-loader'; import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types'; -import { renderCursorHelp, renderCursorModels, renderCursorStatus } from './cursor-command-display'; +import { + renderCursorHelp, + renderCursorModels, + renderCursorProbe, + renderCursorStatus, +} from './cursor-command-display'; import { ok, fail, info } from '../utils/ui'; /** @@ -31,6 +37,8 @@ export async function handleCursorCommand(args: string[]): Promise { return handleAuth(args.slice(1)); case 'status': return handleStatus(); + case 'probe': + return handleProbe(); case 'models': return handleModels(); case 'start': @@ -74,6 +82,34 @@ function parseOptionValue(args: string[], key: string): string | undefined { return undefined; } +function printAutoDetectFailure(result: { + error?: string; + checkedPaths?: string[]; + reason?: string; +}): void { + console.error(fail(`Auto-detection failed: ${result.error ?? 'Unknown error'}`)); + + if (result.checkedPaths?.length && result.reason === 'db_not_found') { + console.log(''); + console.log('Checked paths:'); + for (const candidate of result.checkedPaths) { + console.log(` - ${candidate}`); + } + } + + if (result.reason === 'sqlite_unavailable') { + console.log(''); + console.log('Recommended next steps:'); + console.log(' 1. Install sqlite3 so CCS can read Cursor state automatically'); + console.log(' 2. Or use manual import immediately'); + } else if (result.reason === 'db_query_failed') { + console.log(''); + console.log('Recommended next steps:'); + console.log(' 1. Close Cursor IDE and retry auto-detect'); + console.log(' 2. If the database remains unreadable, use manual import'); + } +} + /** * Handle auth subcommand. */ @@ -139,7 +175,7 @@ async function handleAuth(args: string[]): Promise { } console.log(''); - console.error(fail(`Auto-detection failed: ${autoResult.error ?? 'Unknown error'}`)); + printAutoDetectFailure(autoResult); console.log(''); console.log('Manual fallback:'); console.log(' ccs cursor auth --manual --token --machine-id '); @@ -156,6 +192,13 @@ async function handleStatus(): Promise { return 0; } +async function handleProbe(): Promise { + const cursorConfig = getCursorConfig(); + const result = await probeCursorRuntime(cursorConfig); + renderCursorProbe(result); + return result.ok ? 0 : 1; +} + async function handleModels(): Promise { const cursorConfig = getCursorConfig(); const models = await getAvailableModels(cursorConfig.port); diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index db206d45..650cd07d 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -121,9 +121,10 @@ export function loadMigrationCheckData(): MigrationCheckData { if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) { const legacyProfiles = legacyConfig.profiles as Record; + const unifiedProfiles = unifiedConfig.profiles ?? {}; for (const profileName of Object.keys(legacyProfiles)) { const targetProfileName = resolveAliasToCanonical(profileName); - if (!unifiedConfig.profiles[targetProfileName]) { + if (!unifiedProfiles[targetProfileName]) { needsMigration = true; break; } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index b0c3ecfd..5865ba5d 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -47,6 +47,7 @@ import { resolveLegacyDiscordSelection, } from '../channels/official-channels-runtime'; import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver'; +import { normalizeSearxngBaseUrl } from '../utils/websearch/types'; const CONFIG_YAML = 'config.yaml'; const CONFIG_JSON = 'config.json'; @@ -407,14 +408,19 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { enabled: partial.websearch?.providers?.tavily?.enabled ?? false, max_results: partial.websearch?.providers?.tavily?.max_results ?? 5, }, - duckduckgo: { - enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true, - max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5, - }, brave: { enabled: partial.websearch?.providers?.brave?.enabled ?? false, max_results: partial.websearch?.providers?.brave?.max_results ?? 5, }, + searxng: { + enabled: partial.websearch?.providers?.searxng?.enabled ?? false, + url: normalizeSearxngBaseUrl(partial.websearch?.providers?.searxng?.url) ?? '', + max_results: partial.websearch?.providers?.searxng?.max_results ?? 5, + }, + duckduckgo: { + enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true, + max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5, + }, gemini: { enabled: partial.websearch?.providers?.gemini?.enabled ?? @@ -1093,15 +1099,16 @@ export interface GeminiWebSearchInfo { /** * Get websearch configuration. * Returns defaults if not configured. - * Supports Gemini CLI, OpenCode, and Grok CLI providers. + * Supports deterministic providers and optional Gemini/OpenCode/Grok CLI fallbacks. */ export function getWebSearchConfig(): { enabled: boolean; providers?: { exa?: { enabled?: boolean; max_results?: number }; tavily?: { enabled?: boolean; max_results?: number }; - duckduckgo?: { enabled?: boolean; max_results?: number }; brave?: { enabled?: boolean; max_results?: number }; + searxng?: { enabled?: boolean; url?: string; max_results?: number }; + duckduckgo?: { enabled?: boolean; max_results?: number }; gemini?: GeminiWebSearchInfo; opencode?: { enabled?: boolean; model?: string; timeout?: number }; grok?: { enabled?: boolean; timeout?: number }; @@ -1132,6 +1139,12 @@ export function getWebSearchConfig(): { max_results: config.websearch?.providers?.brave?.max_results ?? 5, }; + const searxngConfig = { + enabled: config.websearch?.providers?.searxng?.enabled ?? false, + url: normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '', + max_results: config.websearch?.providers?.searxng?.max_results ?? 5, + }; + const geminiConfig: GeminiWebSearchInfo = { enabled: config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false, @@ -1155,8 +1168,9 @@ export function getWebSearchConfig(): { const anyProviderEnabled = exaConfig.enabled || tavilyConfig.enabled || - duckDuckGoConfig.enabled || braveConfig.enabled || + searxngConfig.enabled || + duckDuckGoConfig.enabled || geminiConfig.enabled || opencodeConfig.enabled || grokConfig.enabled; @@ -1167,8 +1181,9 @@ export function getWebSearchConfig(): { providers: { exa: exaConfig, tavily: tavilyConfig, - duckduckgo: duckDuckGoConfig, brave: braveConfig, + searxng: searxngConfig, + duckduckgo: duckDuckGoConfig, gemini: geminiConfig, opencode: opencodeConfig, grok: grokConfig, diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 5ce9c304..3ab0f545 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -316,6 +316,18 @@ export interface TavilyWebSearchConfig { max_results?: number; } +/** + * SearXNG WebSearch configuration. + */ +export interface SearxngWebSearchConfig { + /** Enable SearXNG JSON search backend (default: false) */ + enabled?: boolean; + /** Base SearXNG URL, e.g. https://search.example.com (default: '') */ + url?: string; + /** Number of results to fetch (default: 5) */ + max_results?: number; +} + /** * Gemini CLI WebSearch configuration. */ @@ -359,10 +371,12 @@ export interface WebSearchProvidersConfig { exa?: ExaWebSearchConfig; /** Tavily Search API - API-backed search optimized for agent/tool usage */ tavily?: TavilyWebSearchConfig; - /** DuckDuckGo HTML search - zero setup default backend */ - duckduckgo?: DuckDuckGoWebSearchConfig; /** Brave Search API - higher quality results when BRAVE_API_KEY is set */ brave?: BraveWebSearchConfig; + /** SearXNG JSON search - self-hosted or public instance backend */ + searxng?: SearxngWebSearchConfig; + /** DuckDuckGo HTML search - zero setup default backend */ + duckduckgo?: DuckDuckGoWebSearchConfig; /** Gemini CLI - optional legacy LLM fallback */ gemini?: GeminiWebSearchConfig; /** Grok CLI - optional legacy LLM fallback */ @@ -961,14 +975,19 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { enabled: false, max_results: 5, }, - duckduckgo: { - enabled: true, - max_results: 5, - }, brave: { enabled: false, max_results: 5, }, + searxng: { + enabled: false, + url: '', + max_results: 5, + }, + duckduckgo: { + enabled: true, + max_results: 5, + }, gemini: { enabled: false, model: 'gemini-2.5-flash', diff --git a/src/cursor/constants.ts b/src/cursor/constants.ts index 8a971469..b9c0d18d 100644 --- a/src/cursor/constants.ts +++ b/src/cursor/constants.ts @@ -1,6 +1,7 @@ export const CURSOR_SUBCOMMANDS = [ 'auth', 'status', + 'probe', 'models', 'start', 'stop', diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts index 924077bd..313ec2a8 100644 --- a/src/cursor/cursor-auth.ts +++ b/src/cursor/cursor-auth.ts @@ -21,6 +21,13 @@ import * as os from 'os'; import type { CursorCredentials, CursorAuthStatus, AutoDetectResult } from './types'; import { getCcsDir } from '../utils/config-manager'; +const ACCESS_TOKEN_KEYS = ['cursorAuth/accessToken', 'cursorAuth/token'] as const; +const MACHINE_ID_KEYS = [ + 'storage.serviceMachineId', + 'storage.machineId', + 'telemetry.machineId', +] as const; + /** * Resolve home directory from environment first for deterministic testability, * then fall back to os.homedir() when env vars are unavailable. @@ -36,98 +43,247 @@ function resolveHomeDir(): string { /** * Get platform-specific path to Cursor's state.vscdb */ -export function getTokenStoragePath(): string { +export function getTokenStorageCandidates(): string[] { const platform = process.platform; const home = resolveHomeDir(); if (platform === 'win32') { const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - return path.join(appData, 'Cursor', 'User', 'globalStorage', 'state.vscdb'); - } else if (platform === 'darwin') { - return path.join( - home, - 'Library', - 'Application Support', - 'Cursor', - 'User', - 'globalStorage', - 'state.vscdb' - ); - } else { - // Linux - return path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + + return [ + path.join(appData, 'Cursor', 'User', 'globalStorage', 'state.vscdb'), + path.join(appData, 'Cursor - Insiders', 'User', 'globalStorage', 'state.vscdb'), + path.join(localAppData, 'Cursor', 'User', 'globalStorage', 'state.vscdb'), + path.join(localAppData, 'Programs', 'Cursor', 'User', 'globalStorage', 'state.vscdb'), + ]; } + + if (platform === 'darwin') { + return [ + path.join( + home, + 'Library', + 'Application Support', + 'Cursor', + 'User', + 'globalStorage', + 'state.vscdb' + ), + path.join( + home, + 'Library', + 'Application Support', + 'Cursor - Insiders', + 'User', + 'globalStorage', + 'state.vscdb' + ), + ]; + } + + return [ + path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb'), + path.join(home, '.config', 'cursor', 'User', 'globalStorage', 'state.vscdb'), + ]; +} + +export function getTokenStoragePath(): string { + return getTokenStorageCandidates()[0]; +} + +function normalizeStateValue(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + + try { + const parsed = JSON.parse(trimmed); + return typeof parsed === 'string' ? parsed : trimmed; + } catch { + return trimmed; + } +} + +function getSqliteBinary(): string { + return process.env.CCS_CURSOR_SQLITE_BIN || 'sqlite3'; } /** * Query Cursor's SQLite database using sqlite3 CLI */ -function queryStateDb(dbPath: string, key: string): string | null { +function queryStateDb( + dbPath: string, + key: string +): { value: string | null; sqliteAvailable: boolean; queryFailed: boolean } { try { // Escape single quotes to prevent SQL injection const sanitizedKey = key.replace(/'/g, "''"); const result = execFileSync( - 'sqlite3', + getSqliteBinary(), [dbPath, `SELECT value FROM itemTable WHERE key='${sanitizedKey}'`], { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'ignore'] } - ).trim(); - return result || null; + ); + + return { + value: normalizeStateValue(result) || null, + sqliteAvailable: true, + queryFailed: false, + }; } catch (err) { // Check if sqlite3 is not installed if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - // sqlite3 not found - could log this if needed - return null; + return { value: null, sqliteAvailable: false, queryFailed: false }; } - return null; + return { value: null, sqliteAvailable: true, queryFailed: true }; } } +function queryStateDbKeys( + dbPath: string, + keys: readonly string[] +): { value: string | null; sqliteAvailable: boolean; queryFailed: boolean } { + for (const key of keys) { + const result = queryStateDb(dbPath, key); + if (!result.sqliteAvailable || result.queryFailed) return result; + if (result.value) return result; + } + + return { value: null, sqliteAvailable: true, queryFailed: false }; +} + /** * Auto-detect tokens from Cursor's SQLite database */ export function autoDetectTokens(): AutoDetectResult { - // sqlite3 CLI is not bundled with Windows - if (process.platform === 'win32') { + const checkedPaths = getTokenStorageCandidates(); + const existingPaths = checkedPaths.filter((candidate) => fs.existsSync(candidate)); + + if (existingPaths.length === 0) { return { found: false, + checkedPaths, + reason: 'db_not_found', + error: `Cursor state database not found. Checked:\n${checkedPaths.join('\n')}`, + }; + } + + let sawSqliteUnavailable = false; + let sawQueryFailure = false; + let sawTokenMissing = false; + let sawMachineIdMissing = false; + let sawInvalidCredentials = false; + let firstQueryFailurePath: string | undefined; + let firstInvalidCredentialsPath: string | undefined; + + for (const dbPath of existingPaths) { + const accessTokenResult = queryStateDbKeys(dbPath, ACCESS_TOKEN_KEYS); + if (!accessTokenResult.sqliteAvailable) { + sawSqliteUnavailable = true; + continue; + } + if (accessTokenResult.queryFailed) { + sawQueryFailure = true; + firstQueryFailurePath ??= dbPath; + continue; + } + + if (!accessTokenResult.value) { + sawTokenMissing = true; + continue; + } + + const machineIdResult = queryStateDbKeys(dbPath, MACHINE_ID_KEYS); + if (!machineIdResult.sqliteAvailable) { + sawSqliteUnavailable = true; + continue; + } + if (machineIdResult.queryFailed) { + sawQueryFailure = true; + firstQueryFailurePath ??= dbPath; + continue; + } + + if (!machineIdResult.value) { + sawMachineIdMissing = true; + continue; + } + + if (!validateToken(accessTokenResult.value, machineIdResult.value)) { + sawInvalidCredentials = true; + firstInvalidCredentialsPath ??= dbPath; + continue; + } + + return { + found: true, + accessToken: accessTokenResult.value, + machineId: machineIdResult.value, + dbPath, + checkedPaths, + }; + } + + if (sawSqliteUnavailable) { + return { + found: false, + checkedPaths, + dbPath: existingPaths[0], + reason: 'sqlite_unavailable', error: - 'Auto-detection is not supported on Windows. Please import tokens manually using ccs cursor auth --manual.', + 'Cursor state database was found, but sqlite3 is not available in PATH. Install sqlite3 or use manual import.', }; } - const dbPath = getTokenStoragePath(); - - // Check if database exists - if (!fs.existsSync(dbPath)) { + if (sawQueryFailure) { return { found: false, + checkedPaths, + dbPath: firstQueryFailurePath ?? existingPaths[0], + reason: 'db_query_failed', error: - 'Cursor state database not found. Make sure Cursor IDE is installed and you are logged in.', + 'Cursor state database was found, but CCS could not query it. The database may be locked, corrupted, or use an unexpected schema.', }; } - // Try to query access token - const accessToken = queryStateDb(dbPath, 'cursorAuth/accessToken'); - if (!accessToken) { + if (sawInvalidCredentials) { return { found: false, - error: 'Access token not found in database. Please log in to Cursor IDE first.', + checkedPaths, + dbPath: firstInvalidCredentialsPath ?? existingPaths[0], + reason: 'invalid_token_format', + error: + 'Cursor credentials were found, but the access token or machine ID format was invalid. Re-authenticate in Cursor IDE or use manual import.', }; } - // Try to query machine ID - const machineId = queryStateDb(dbPath, 'storage.serviceMachineId'); - if (!machineId) { + if (sawMachineIdMissing) { return { found: false, - error: 'Machine ID not found in database.', + checkedPaths, + dbPath: existingPaths[0], + reason: 'machine_id_not_found', + error: + 'Cursor access token was found, but the machine ID was not present in the database. Re-open Cursor IDE or use manual import.', + }; + } + + if (sawTokenMissing) { + return { + found: false, + checkedPaths, + dbPath: existingPaths[0], + reason: 'access_token_not_found', + error: + 'Access token not found in Cursor state database. Make sure you are logged in to Cursor IDE first.', }; } return { - found: true, - accessToken, - machineId, + found: false, + checkedPaths, + dbPath: existingPaths[0], + reason: 'db_not_found', + error: 'Cursor credentials could not be detected from the discovered database paths.', }; } diff --git a/src/cursor/cursor-executor.ts b/src/cursor/cursor-executor.ts index 7f46ec42..d7344be0 100644 --- a/src/cursor/cursor-executor.ts +++ b/src/cursor/cursor-executor.ts @@ -6,10 +6,20 @@ import type { IncomingHttpHeaders } from 'http'; import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js'; import { buildCursorRequest } from './cursor-translator.js'; -import type { CursorTool, CursorApiCredentials } from './cursor-protobuf-schema.js'; +import { + isEndStreamConnectFrame, + type CursorTool, + type CursorApiCredentials, +} from './cursor-protobuf-schema.js'; import { buildCursorConnectHeaders, generateCursorChecksum } from './cursor-client-policy.js'; -import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js'; +import { + CursorConnectFrameError, + type FrameResult, + StreamingFrameParser, + decompressPayload, + mapCursorConnectError, +} from './cursor-stream-parser.js'; /** Executor parameters */ interface ExecutorParams { @@ -41,6 +51,13 @@ interface Http2Response { body: Buffer; } +interface CursorExecutorErrorPayload { + message: string; + status: number; + errorType: string; + code: string; +} + /** Lazy import http2 */ let http2Module: typeof import('http2') | null = null; async function getHttp2() { @@ -59,34 +76,63 @@ async function getHttp2() { /** * Create error response from JSON error */ -function createErrorResponse(jsonError: { +function toCursorErrorPayloadFromJson(jsonError: { error?: { code?: string; message?: string; details?: Array<{ debug?: { details?: { title?: string; detail?: string }; error?: string } }>; }; -}): Response { +}): CursorExecutorErrorPayload { const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title || jsonError?.error?.details?.[0]?.debug?.details?.detail || jsonError?.error?.message || 'API Error'; - const isRateLimit = jsonError?.error?.code === 'resource_exhausted'; + const mappedError = mapCursorConnectError(jsonError?.error?.code); - return new Response( - JSON.stringify({ - error: { - message: errorMsg, - type: isRateLimit ? 'rate_limit_error' : 'api_error', - code: jsonError?.error?.details?.[0]?.debug?.error || 'unknown', - }, - }), - { - status: isRateLimit ? 429 : 400, - headers: { 'Content-Type': 'application/json' }, - } - ); + return { + message: errorMsg, + status: mappedError.status, + errorType: mappedError.errorType, + code: jsonError?.error?.details?.[0]?.debug?.error || 'unknown', + }; +} + +function buildCursorErrorEnvelope(error: CursorExecutorErrorPayload): string { + return JSON.stringify({ + error: { + message: error.message, + type: error.errorType, + code: error.code, + status: error.status, + }, + }); +} + +function createCursorErrorResponse(error: CursorExecutorErrorPayload): Response { + return new Response(buildCursorErrorEnvelope(error), { + status: error.status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function toCursorExecutorErrorPayload(error: unknown): CursorExecutorErrorPayload { + if (error instanceof CursorConnectFrameError) { + return { + message: error.message, + status: error.status, + errorType: error.errorType, + code: 'cursor_protocol_error', + }; + } + + return { + message: error instanceof Error ? error.message : 'Cursor streaming failed.', + status: 502, + errorType: 'server_error', + code: 'cursor_error', + }; } export class CursorExecutor { @@ -413,180 +459,243 @@ export class CursorExecutor { index: number; } >(); + const pendingPackets: string[] = []; let chunkCount = 0; let toolCallCount = 0; + let streamResponseResolved = false; + + const flushPendingPackets = () => { + if (!streamController || pendingPackets.length === 0 || streamClosed) return; + for (const packet of pendingPackets.splice(0)) { + streamController.enqueue(enc.encode(packet)); + } + }; + + const queuePacket = (packet: string) => { + if (streamClosed) return; + if (streamController) { + streamController.enqueue(enc.encode(packet)); + return; + } + pendingPackets.push(packet); + }; + + const emitSSE = (data: string) => { + queuePacket(`data: ${data}\n\n`); + }; + + const emitSSEEvent = (event: string, data: string) => { + queuePacket(`event: ${event}\ndata: ${data}\n\n`); + }; const readable = new ReadableStream({ start(controller) { streamController = controller; - - const emitSSE = (data: string) => { - if (streamClosed) return; - controller.enqueue(enc.encode(`data: ${data}\n\n`)); - }; - - const closeStream = () => { - if (streamClosed) return; - streamClosed = true; - try { - controller.close(); - } catch { - /* already closed */ - } - client.close(); - }; - - const buildChunk = (delta: Record, finishReason: string | null) => - JSON.stringify({ - id: responseId, - object: 'chat.completion.chunk', - created, - model, - choices: [{ index: 0, delta, finish_reason: finishReason }], - }); - - req.on('data', (chunk: Buffer) => { - if (streamClosed) return; - for (const frame of parser.push(chunk)) { - if (frame.type === 'error') { - emitSSE( - JSON.stringify({ - error: { message: frame.message, type: frame.errorType, code: '' }, - }) - ); - emitSSE('[DONE]'); - closeStream(); - return; - } - - if (frame.type === 'toolCall') { - const tc = frame.toolCall; - - // Emit role chunk on first content - if (chunkCount === 0) { - emitSSE(buildChunk({ role: 'assistant', content: '' }, null)); - chunkCount++; - } - - if (toolCallsMap.has(tc.id)) { - const existing = toolCallsMap.get(tc.id); - if (!existing) continue; - existing.function.arguments += tc.function.arguments; - existing.isLast = tc.isLast; - if (tc.function.arguments) { - emitSSE( - buildChunk( - { - tool_calls: [ - { - index: existing.index, - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - }, - ], - }, - null - ) - ); - chunkCount++; - } - } else { - const idx = toolCallCount++; - toolCallsMap.set(tc.id, { ...tc, index: idx }); - emitSSE( - buildChunk( - { - tool_calls: [ - { - index: idx, - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - }, - ], - }, - null - ) - ); - chunkCount++; - } - } - - if (frame.type === 'text') { - const delta = - chunkCount === 0 && toolCallCount === 0 - ? { role: 'assistant', content: frame.text } - : { content: frame.text }; - emitSSE(buildChunk(delta, null)); - chunkCount++; - } - - if (frame.type === 'thinking') { - const delta = - chunkCount === 0 && toolCallCount === 0 - ? { role: 'assistant', reasoning_content: frame.text } - : { reasoning_content: frame.text }; - emitSSE(buildChunk(delta, null)); - chunkCount++; - } - } - }); - - req.on('end', () => { - if (streamClosed) return; - if (chunkCount === 0 && toolCallCount === 0) { - emitSSE(buildChunk({ role: 'assistant', content: '' }, null)); - } - emitSSE( - JSON.stringify({ - id: responseId, - object: 'chat.completion.chunk', - created, - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: toolCallCount > 0 ? 'tool_calls' : 'stop', - }, - ], - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, - }) - ); - emitSSE('[DONE]'); - closeStream(); - }); - - req.on('error', (err) => { - if (!streamClosed) { - try { - controller.error(err); - } catch { - /* already closed */ - } - } - client.close(); - }); + flushPendingPackets(); }, }); - resolveOnce( - new Response(readable, { - status: 200, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }) - ); + const resolveStreamingResponse = () => { + if (streamResponseResolved || settled) return; + streamResponseResolved = true; + resolveOnce( + new Response(readable, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) + ); + flushPendingPackets(); + }; + + const closeStream = () => { + if (streamClosed) return; + streamClosed = true; + if (streamController) { + try { + streamController.close(); + } catch { + /* already closed */ + } + } + client.close(); + }; + + const buildChunk = (delta: Record, finishReason: string | null) => + JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + }); + + const handleFrameError = (frame: Extract) => { + const errorPayload = buildCursorErrorEnvelope({ + message: frame.message, + status: frame.status, + errorType: frame.errorType, + code: frame.errorType === 'rate_limit_error' ? 'rate_limited' : 'cursor_error', + }); + + if (!streamResponseResolved && chunkCount === 0 && toolCallCount === 0) { + streamClosed = true; + req.close(); + client.close(); + resolveOnce( + new Response(errorPayload, { + status: frame.status, + headers: { 'Content-Type': 'application/json' }, + }) + ); + return; + } + + resolveStreamingResponse(); + emitSSEEvent('error', errorPayload); + closeStream(); + }; + + req.on('data', (chunk: Buffer) => { + if (streamClosed) return; + for (const frame of parser.push(chunk)) { + if (frame.type === 'error') { + handleFrameError(frame); + return; + } + + resolveStreamingResponse(); + + if (frame.type === 'toolCall') { + const tc = frame.toolCall; + + // Emit role chunk on first content + if (chunkCount === 0) { + emitSSE(buildChunk({ role: 'assistant', content: '' }, null)); + chunkCount++; + } + + if (toolCallsMap.has(tc.id)) { + const existing = toolCallsMap.get(tc.id); + if (!existing) continue; + existing.function.arguments += tc.function.arguments; + existing.isLast = tc.isLast; + if (tc.function.arguments) { + emitSSE( + buildChunk( + { + tool_calls: [ + { + index: existing.index, + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + }, + ], + }, + null + ) + ); + chunkCount++; + } + } else { + const idx = toolCallCount++; + toolCallsMap.set(tc.id, { ...tc, index: idx }); + emitSSE( + buildChunk( + { + tool_calls: [ + { + index: idx, + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + }, + ], + }, + null + ) + ); + chunkCount++; + } + } + + if (frame.type === 'text') { + const delta = + chunkCount === 0 && toolCallCount === 0 + ? { role: 'assistant', content: frame.text } + : { content: frame.text }; + emitSSE(buildChunk(delta, null)); + chunkCount++; + } + + if (frame.type === 'thinking') { + const delta = + chunkCount === 0 && toolCallCount === 0 + ? { role: 'assistant', reasoning_content: frame.text } + : { reasoning_content: frame.text }; + emitSSE(buildChunk(delta, null)); + chunkCount++; + } + } + }); + + req.on('end', () => { + if (streamClosed) return; + for (const frame of parser.finish()) { + if (frame.type === 'error') { + handleFrameError(frame); + return; + } + } + resolveStreamingResponse(); + if (chunkCount === 0 && toolCallCount === 0) { + emitSSE(buildChunk({ role: 'assistant', content: '' }, null)); + } + emitSSE( + JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: toolCallCount > 0 ? 'tool_calls' : 'stop', + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }) + ); + emitSSE('[DONE]'); + closeStream(); + }); + + req.on('error', (err) => { + client.close(); + if (!streamResponseResolved) { + rejectOnce(err instanceof Error ? err : new Error(String(err))); + return; + } + + if (!streamClosed) { + try { + streamController?.error(err); + } catch { + /* already closed */ + } + } + }); }); req.on('error', (err) => { @@ -604,7 +713,7 @@ export class CursorExecutor { * Shared logic between JSON and SSE transformers. */ private *parseProtobufFrames(buffer: Buffer): Generator< - | { type: 'error'; response: Response } + | { type: 'error'; error: CursorExecutorErrorPayload } | { type: 'text'; text: string } | { type: 'thinking'; text: string } | { @@ -630,13 +739,54 @@ export class CursorExecutor { let payload = buffer.slice(offset + 5, offset + 5 + length); offset += 5 + length; - payload = decompressPayload(payload, flags); + try { + payload = decompressPayload(payload, flags); + } catch (error) { + yield { type: 'error', error: toCursorExecutorErrorPayload(error) }; + return; + } + + if (isEndStreamConnectFrame(flags)) { + try { + const json = JSON.parse(payload.toString('utf-8')) as { + error?: { + code?: string; + message?: string; + details?: Array<{ + debug?: { details?: { title?: string; detail?: string }; error?: string }; + }>; + }; + }; + + const msg = + json?.error?.details?.[0]?.debug?.details?.title || + json?.error?.details?.[0]?.debug?.details?.detail || + json?.error?.message; + + if (msg) { + const mappedError = mapCursorConnectError(json?.error?.code); + yield { + type: 'error', + error: { + message: msg, + status: mappedError.status, + errorType: mappedError.errorType, + code: json?.error?.code || 'cursor_error', + }, + }; + return; + } + } catch { + // Ignore successful end-stream metadata trailers. + } + continue; + } // Check for JSON error format try { const text = payload.toString('utf-8'); if (text.startsWith('{') && text.includes('"error"')) { - yield { type: 'error', response: createErrorResponse(JSON.parse(text)) }; + yield { type: 'error', error: toCursorErrorPayloadFromJson(JSON.parse(text)) }; return; } } catch (err) { @@ -656,19 +806,12 @@ export class CursorExecutor { errorLower.includes('too many requests'); yield { type: 'error', - response: new Response( - JSON.stringify({ - error: { - message: result.error, - type: isRateLimit ? 'rate_limit_error' : 'server_error', - code: isRateLimit ? 'rate_limited' : 'cursor_error', - }, - }), - { - status: isRateLimit ? 429 : 400, - headers: { 'Content-Type': 'application/json' }, - } - ), + error: { + message: result.error, + status: isRateLimit ? 429 : 502, + errorType: isRateLimit ? 'rate_limit_error' : 'server_error', + code: isRateLimit ? 'rate_limited' : 'cursor_error', + }, }; return; } @@ -685,6 +828,18 @@ export class CursorExecutor { yield { type: 'thinking', text: result.thinking }; } } + + if (offset !== buffer.length) { + yield { + type: 'error', + error: { + message: 'Truncated Cursor ConnectRPC frame.', + status: 502, + errorType: 'server_error', + code: 'cursor_protocol_error', + }, + }; + } } transformProtobufToJSON(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response { @@ -711,7 +866,7 @@ export class CursorExecutor { for (const frame of this.parseProtobufFrames(buffer)) { if (frame.type === 'error') { - return frame.response; + return createCursorErrorResponse(frame.error); } if (frame.type === 'toolCall') { @@ -840,7 +995,19 @@ export class CursorExecutor { for (const frame of this.parseProtobufFrames(buffer)) { if (frame.type === 'error') { - return frame.response; + if (chunks.length === 0 && toolCalls.length === 0) { + return createCursorErrorResponse(frame.error); + } + + chunks.push(`event: error\ndata: ${buildCursorErrorEnvelope(frame.error)}\n\n`); + return new Response(chunks.join(''), { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); } if (frame.type === 'toolCall') { diff --git a/src/cursor/cursor-protobuf-decoder.ts b/src/cursor/cursor-protobuf-decoder.ts index a5b5fe8d..8a12423a 100644 --- a/src/cursor/cursor-protobuf-decoder.ts +++ b/src/cursor/cursor-protobuf-decoder.ts @@ -4,7 +4,12 @@ */ import * as zlib from 'zlib'; -import { WIRE_TYPE, FIELD, COMPRESS_FLAG, type WireType } from './cursor-protobuf-schema.js'; +import { + WIRE_TYPE, + FIELD, + isCompressedConnectFrame, + type WireType, +} from './cursor-protobuf-schema.js'; /** * Decode a varint from buffer @@ -121,11 +126,7 @@ export function parseConnectRPCFrame(buffer: Buffer): { let payload = buffer.slice(5, 5 + length); // Decompress if gzip - if ( - flags === COMPRESS_FLAG.GZIP || - flags === COMPRESS_FLAG.GZIP_ALT || - flags === COMPRESS_FLAG.GZIP_BOTH - ) { + if (isCompressedConnectFrame(flags)) { try { payload = Buffer.from(zlib.gunzipSync(payload)); } catch (err) { diff --git a/src/cursor/cursor-protobuf-schema.ts b/src/cursor/cursor-protobuf-schema.ts index 296cfd49..d81ac5b1 100644 --- a/src/cursor/cursor-protobuf-schema.ts +++ b/src/cursor/cursor-protobuf-schema.ts @@ -192,10 +192,31 @@ export interface MessageId { role: RoleType; } -/** Compression flags for ConnectRPC frames */ +/** Bit flags for ConnectRPC envelopes. */ +export const CONNECT_FRAME_FLAG = { + COMPRESSED: 0x01, + END_STREAM: 0x02, +} as const; + +/** ConnectRPC frame flag presets. */ export const COMPRESS_FLAG = { NONE: 0x00, - GZIP: 0x01, - GZIP_ALT: 0x02, - GZIP_BOTH: 0x03, + GZIP: CONNECT_FRAME_FLAG.COMPRESSED, + END_STREAM: CONNECT_FRAME_FLAG.END_STREAM, + GZIP_END_STREAM: CONNECT_FRAME_FLAG.COMPRESSED | CONNECT_FRAME_FLAG.END_STREAM, } as const; + +export const CONNECT_FRAME_FLAG_MASK = + CONNECT_FRAME_FLAG.COMPRESSED | CONNECT_FRAME_FLAG.END_STREAM; + +export function isCompressedConnectFrame(flags: number): boolean { + return (flags & CONNECT_FRAME_FLAG.COMPRESSED) === CONNECT_FRAME_FLAG.COMPRESSED; +} + +export function isEndStreamConnectFrame(flags: number): boolean { + return (flags & CONNECT_FRAME_FLAG.END_STREAM) === CONNECT_FRAME_FLAG.END_STREAM; +} + +export function hasUnknownConnectFrameFlags(flags: number): boolean { + return (flags & ~CONNECT_FRAME_FLAG_MASK) !== 0; +} diff --git a/src/cursor/cursor-protobuf.ts b/src/cursor/cursor-protobuf.ts index 7579cc42..e2bf76bc 100644 --- a/src/cursor/cursor-protobuf.ts +++ b/src/cursor/cursor-protobuf.ts @@ -153,7 +153,7 @@ export function buildChatRequest( } /** - * Generate complete Cursor request body with ConnectRPC framing + * Generate the raw top-level protobuf request body expected by Cursor upstream. */ export function generateCursorBody( messages: CursorMessage[], @@ -161,9 +161,7 @@ export function generateCursorBody( tools: CursorTool[] = [], reasoningEffort: string | null = null ): Uint8Array { - const protobuf = buildChatRequest(messages, modelName, tools, reasoningEffort); - const framed = wrapConnectRPCFrame(protobuf, false); // Cursor doesn't support compressed requests - return framed; + return buildChatRequest(messages, modelName, tools, reasoningEffort); } // Re-export all functions diff --git a/src/cursor/cursor-runtime-probe.ts b/src/cursor/cursor-runtime-probe.ts new file mode 100644 index 00000000..3dadfa36 --- /dev/null +++ b/src/cursor/cursor-runtime-probe.ts @@ -0,0 +1,262 @@ +import type { CursorConfig } from '../config/unified-config-types'; +import { checkAuthStatus } from './cursor-auth'; +import { isDaemonRunning, startDaemon } from './cursor-daemon'; +import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; + +export interface CursorProbeResult { + ok: boolean; + stage: 'config' | 'auth' | 'daemon' | 'runtime'; + status: number; + duration_ms: number; + model?: string; + error_type?: string | null; + message: string; +} + +const PROBE_PROMPT = 'Reply with OK only.'; +const PROBE_TIMEOUT_MS = 15_000; +const PROBE_SUCCESS_PATTERN = /^ok[.!]?$/i; + +function isDaemonReachabilityError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + const message = error.message.toLowerCase(); + if ( + message.includes('fetch failed') || + message.includes('econnrefused') || + message.includes('econnreset') || + message.includes('socket hang up') || + message.includes('connection refused') + ) { + return true; + } + + const cause = (error as Error & { cause?: unknown }).cause; + if (!cause || typeof cause !== 'object' || !('code' in cause)) { + return false; + } + + const code = String((cause as { code?: unknown }).code ?? '').toUpperCase(); + return ['ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'EPIPE', 'UND_ERR_SOCKET'].includes(code); +} + +function parseProbeError(text: string): { errorType: string | null; message: string } { + try { + const parsed = JSON.parse(text) as { + error?: { type?: string; message?: string }; + type?: string; + }; + + if (parsed.error?.message) { + return { + errorType: parsed.error.type ?? null, + message: parsed.error.message, + }; + } + + if (parsed.type === 'error') { + return { + errorType: null, + message: text, + }; + } + } catch { + // fall through to raw text + } + + return { + errorType: null, + message: text || 'Unknown probe failure', + }; +} + +function parseProbeSuccess(text: string): { ok: boolean; message: string } { + try { + const parsed = JSON.parse(text) as { + choices?: Array<{ + message?: { content?: string | null }; + }>; + error?: { message?: string }; + }; + + if (parsed.error?.message) { + return { ok: false, message: parsed.error.message }; + } + + const content = parsed.choices?.[0]?.message?.content; + if (typeof content !== 'string' || !content.trim()) { + return { ok: false, message: 'Probe response was missing assistant content.' }; + } + + return { + ok: PROBE_SUCCESS_PATTERN.test(content.trim()), + message: PROBE_SUCCESS_PATTERN.test(content.trim()) + ? 'Live probe succeeded.' + : `Probe returned unexpected assistant content: ${content.trim()}`, + }; + } catch { + return { ok: false, message: 'Probe response was not valid JSON.' }; + } +} + +export async function probeCursorRuntime(config: CursorConfig): Promise { + const startedAt = Date.now(); + + if (!config.enabled) { + return { + ok: false, + stage: 'config', + status: 400, + duration_ms: Date.now() - startedAt, + message: 'Cursor integration is disabled.', + error_type: 'configuration_error', + }; + } + + const authStatus = checkAuthStatus(); + if (!authStatus.authenticated || !authStatus.credentials) { + return { + ok: false, + stage: 'auth', + status: 401, + duration_ms: Date.now() - startedAt, + message: 'Cursor credentials not found. Run `ccs cursor auth` first.', + error_type: 'authentication_error', + }; + } + + if (authStatus.expired) { + return { + ok: false, + stage: 'auth', + status: 401, + duration_ms: Date.now() - startedAt, + message: 'Cursor credentials expired. Run `ccs cursor auth` again.', + error_type: 'authentication_error', + }; + } + + let daemonRunning = await isDaemonRunning(config.port); + if (!daemonRunning && config.auto_start) { + const startResult = await startDaemon({ + port: config.port, + ghost_mode: config.ghost_mode, + }); + + if (!startResult.success) { + daemonRunning = await isDaemonRunning(config.port); + } else { + daemonRunning = true; + } + + if (!daemonRunning) { + return { + ok: false, + stage: 'daemon', + status: 503, + duration_ms: Date.now() - startedAt, + message: startResult.error || 'Failed to start Cursor daemon for live probe.', + error_type: 'daemon_start_failed', + }; + } + } + + if (!daemonRunning) { + return { + ok: false, + stage: 'daemon', + status: 503, + duration_ms: Date.now() - startedAt, + message: + 'Cursor daemon is not running. Start it with `ccs cursor start` or enable auto_start.', + error_type: 'daemon_not_running', + }; + } + + try { + const credentials = { + accessToken: authStatus.credentials.accessToken, + machineId: authStatus.credentials.machineId, + ghostMode: config.ghost_mode, + }; + const availableModels = await getModelsForDaemon({ credentials }); + const model = resolveCursorRequestModel(config.model, availableModels); + + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), PROBE_TIMEOUT_MS); + + try { + const response = await fetch(`http://127.0.0.1:${config.port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + max_tokens: 8, + messages: [{ role: 'user', content: PROBE_PROMPT }], + }), + signal: abortController.signal, + }); + const text = await response.text(); + const duration = Date.now() - startedAt; + + if (!response.ok) { + const error = parseProbeError(text); + return { + ok: false, + stage: 'runtime', + status: response.status, + duration_ms: duration, + model, + error_type: error.errorType, + message: error.message, + }; + } + + const success = parseProbeSuccess(text); + return { + ok: success.ok, + stage: 'runtime', + status: success.ok ? response.status : 502, + duration_ms: duration, + model, + error_type: success.ok ? null : 'probe_validation_failed', + message: success.message, + }; + } finally { + clearTimeout(timeout); + } + } catch (error) { + const daemonReachabilityError = isDaemonReachabilityError(error); + + return { + ok: false, + stage: + error instanceof Error && error.name === 'AbortError' + ? 'runtime' + : daemonReachabilityError + ? 'daemon' + : 'runtime', + status: + error instanceof Error && error.name === 'AbortError' + ? 504 + : daemonReachabilityError + ? 503 + : 500, + duration_ms: Date.now() - startedAt, + error_type: + error instanceof Error && error.name === 'AbortError' + ? 'probe_timeout' + : daemonReachabilityError + ? 'daemon_unreachable' + : 'runtime_error', + message: + error instanceof Error && error.name === 'AbortError' + ? `Live probe timed out after ${PROBE_TIMEOUT_MS}ms.` + : daemonReachabilityError + ? 'Cursor daemon became unreachable during the live probe. Start it again and retry.' + : error instanceof Error + ? error.message + : 'Unknown runtime probe failure.', + }; + } +} diff --git a/src/cursor/cursor-stream-parser.ts b/src/cursor/cursor-stream-parser.ts index 7fa06091..042381b3 100644 --- a/src/cursor/cursor-stream-parser.ts +++ b/src/cursor/cursor-stream-parser.ts @@ -4,7 +4,11 @@ */ import * as zlib from 'zlib'; -import { COMPRESS_FLAG } from './cursor-protobuf-schema.js'; +import { + hasUnknownConnectFrameFlags, + isCompressedConnectFrame, + isEndStreamConnectFrame, +} from './cursor-protobuf-schema.js'; import { extractTextFromResponse } from './cursor-protobuf-decoder.js'; /** Frame parsing result types */ @@ -22,12 +26,85 @@ export type FrameResult = }; }; +export class CursorConnectFrameError extends Error { + constructor( + message: string, + readonly status = 502, + readonly errorType = 'server_error' + ) { + super(message); + this.name = 'CursorConnectFrameError'; + } +} + +export function mapCursorConnectError(code?: string): { status: number; errorType: string } { + switch (code?.toLowerCase()) { + case 'resource_exhausted': + return { status: 429, errorType: 'rate_limit_error' }; + case 'unauthenticated': + return { status: 401, errorType: 'authentication_error' }; + case 'permission_denied': + return { status: 403, errorType: 'permission_error' }; + case 'not_found': + return { status: 404, errorType: 'api_error' }; + case 'already_exists': + case 'aborted': + return { status: 409, errorType: 'api_error' }; + case 'deadline_exceeded': + return { status: 504, errorType: 'api_error' }; + case 'unimplemented': + return { status: 501, errorType: 'api_error' }; + case 'unavailable': + return { status: 503, errorType: 'api_error' }; + case 'invalid_argument': + case 'failed_precondition': + case 'out_of_range': + return { status: 400, errorType: 'api_error' }; + default: + return { status: 500, errorType: 'api_error' }; + } +} + +function formatConnectFrameFlags(flags: number): string { + return `0x${flags.toString(16).padStart(2, '0')}`; +} + +function toFrameErrorResult(error: unknown): Extract { + if (error instanceof CursorConnectFrameError) { + return { + type: 'error', + message: error.message, + status: error.status, + errorType: error.errorType, + }; + } + + return { + type: 'error', + message: error instanceof Error ? error.message : 'Cursor stream parsing failed.', + status: 502, + errorType: 'server_error', + }; +} + +function createTruncatedFrameError(): CursorConnectFrameError { + return new CursorConnectFrameError('Truncated Cursor ConnectRPC frame.', 502, 'server_error'); +} + /** * Decompress payload if gzip-compressed. * Skips decompression for JSON error payloads. * NOTE: Uses synchronous gzip for single-request CLI tool. Async not warranted for small payloads. */ export function decompressPayload(payload: Buffer, flags: number): Buffer { + if (hasUnknownConnectFrameFlags(flags)) { + throw new CursorConnectFrameError( + `Unsupported ConnectRPC frame flags: ${formatConnectFrameFlags(flags)}`, + 502, + 'server_error' + ); + } + if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) { try { const text = payload.toString('utf-8'); @@ -37,18 +114,18 @@ export function decompressPayload(payload: Buffer, flags: number): Buffer { } } - if ( - flags === COMPRESS_FLAG.GZIP || - flags === COMPRESS_FLAG.GZIP_ALT || - flags === COMPRESS_FLAG.GZIP_BOTH - ) { + if (isCompressedConnectFrame(flags)) { try { return zlib.gunzipSync(payload); } catch (err) { if (process.env.CCS_DEBUG) { console.error('[cursor] gzip decompression failed:', err); } - return Buffer.alloc(0); + throw new CursorConnectFrameError( + 'Failed to decompress Cursor ConnectRPC frame.', + 502, + 'server_error' + ); } } return payload; @@ -80,7 +157,46 @@ export class StreamingFrameParser { let payload = this.buffer.slice(5, frameSize); this.buffer = this.buffer.slice(frameSize); - payload = decompressPayload(payload, flags); + try { + payload = decompressPayload(payload, flags); + } catch (error) { + results.push(toFrameErrorResult(error)); + return results; + } + + if (isEndStreamConnectFrame(flags)) { + try { + const text = payload.toString('utf-8'); + const json = JSON.parse(text) as { + error?: { + code?: string; + message?: string; + details?: Array<{ + debug?: { details?: { title?: string; detail?: string }; error?: string }; + }>; + }; + }; + + const msg = + json?.error?.details?.[0]?.debug?.details?.title || + json?.error?.details?.[0]?.debug?.details?.detail || + json?.error?.message; + + if (msg) { + const mappedError = mapCursorConnectError(json?.error?.code); + results.push({ + type: 'error', + message: msg, + status: mappedError.status, + errorType: mappedError.errorType, + }); + return results; + } + } catch { + // Ignore successful end-stream metadata trailers. + } + continue; + } // Check for JSON error try { @@ -92,12 +208,12 @@ export class StreamingFrameParser { json?.error?.details?.[0]?.debug?.details?.detail || json?.error?.message || 'API Error'; - const isRateLimit = json?.error?.code === 'resource_exhausted'; + const mappedError = mapCursorConnectError(json?.error?.code); results.push({ type: 'error', message: msg, - status: isRateLimit ? 429 : 400, - errorType: isRateLimit ? 'rate_limit_error' : 'api_error', + status: mappedError.status, + errorType: mappedError.errorType, }); return results; } @@ -116,7 +232,7 @@ export class StreamingFrameParser { results.push({ type: 'error', message: result.error, - status: isRateLimit ? 429 : 400, + status: isRateLimit ? 429 : 502, errorType: isRateLimit ? 'rate_limit_error' : 'server_error', }); return results; @@ -133,4 +249,13 @@ export class StreamingFrameParser { hasPartial(): boolean { return this.buffer.length > 0; } + + finish(): FrameResult[] { + if (this.buffer.length === 0) { + return []; + } + + this.buffer = Buffer.alloc(0); + return [toFrameErrorResult(createTruncatedFrameError())]; + } } diff --git a/src/cursor/index.ts b/src/cursor/index.ts index 42e42860..053e5d6f 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -46,3 +46,4 @@ export { // Executor export { CursorExecutor } from './cursor-executor'; export { executeCursorProfile, generateCursorEnv } from './cursor-profile-executor'; +export { probeCursorRuntime } from './cursor-runtime-probe'; diff --git a/src/cursor/types.ts b/src/cursor/types.ts index df9c152f..199c03cf 100644 --- a/src/cursor/types.ts +++ b/src/cursor/types.ts @@ -54,6 +54,18 @@ export interface AutoDetectResult { accessToken?: string; /** Machine ID (if found) */ machineId?: string; + /** SQLite database path used during detection */ + dbPath?: string; + /** Paths checked while looking for Cursor state */ + checkedPaths?: string[]; + /** Structured failure reason when detection fails */ + reason?: + | 'db_not_found' + | 'sqlite_unavailable' + | 'db_query_failed' + | 'access_token_not_found' + | 'machine_id_not_found' + | 'invalid_token_format'; /** Error message (if detection failed) */ error?: string; } diff --git a/src/docker/supervisord-lifecycle.ts b/src/docker/supervisord-lifecycle.ts new file mode 100644 index 00000000..21ca5d0f --- /dev/null +++ b/src/docker/supervisord-lifecycle.ts @@ -0,0 +1,36 @@ +/** + * Supervisord lifecycle helpers for Docker deployments (`ccs docker up`). + * + * In Docker, supervisord owns the CLIProxy process lifecycle. Direct + * stop+start via session-tracker / service-manager creates orphaned + * processes and causes supervisord to enter FATAL state (EADDRINUSE). + * All restart operations must delegate to `supervisorctl` instead. + */ + +import * as fs from 'fs'; +import { execSync } from 'child_process'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; + +const SUPERVISOR_SOCK = '/var/run/supervisor.sock'; +const SUPERVISOR_CONF = '/etc/supervisord.conf'; + +/** True when running inside a supervisord-managed container. */ +export function isRunningUnderSupervisord(): boolean { + return fs.existsSync(SUPERVISOR_SOCK); +} + +/** Restart the cliproxy program via supervisorctl. Returns port on success. */ +export function restartCliproxyViaSupervisord(): { + success: boolean; + port?: number; + error?: string; +} { + try { + execSync(`supervisorctl -c ${SUPERVISOR_CONF} restart cliproxy`, { timeout: 15_000 }); + return { success: true, port: CLIPROXY_DEFAULT_PORT }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error(`[cliproxy] supervisorctl restart failed: ${detail}`); + return { success: false, error: 'supervisorctl restart failed' }; + } +} diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index b9729459..8fa15198 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -14,7 +14,7 @@ import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; import type { AccountContextPolicy } from '../auth/account-context'; import { getCcsDir, getCcsHome } from '../utils/config-manager'; -const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis']); +const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis', 'ccs-browser']); /** Options for instance creation */ export interface InstanceOptions { @@ -252,9 +252,12 @@ class InstanceManager { } instanceContent.mcpServers = mergedMcpServers; + const fileMode = fs.existsSync(instanceClaudeJson) + ? fs.statSync(instanceClaudeJson).mode & 0o777 + : 0o600; fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), { encoding: 'utf8', - mode: 0o600, + mode: fileMode, }); return true; } catch (error) { diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index 4ba0c40c..9dc4f08a 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -12,6 +12,7 @@ import type { ProfileType } from '../types/profile'; import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from '../utils/shell-executor'; import { ErrorManager } from '../utils/error-manager'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; +import { appendBrowserToolArgs } from '../utils/browser'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { runCleanup } from '../errors'; @@ -32,8 +33,16 @@ export class ClaudeAdapter implements TargetAdapter { // No-op: Claude receives credentials via environment variables } - buildArgs(_profile: string, userArgs: string[]): string[] { - return userArgs; + buildArgs( + _profile: string, + userArgs: string[], + options?: { + creds?: TargetCredentials; + profileType?: ProfileType; + binaryInfo?: TargetBinaryInfo; + } + ): string[] { + return options?.creds?.browserRuntimeEnv ? appendBrowserToolArgs(userArgs) : userArgs; } buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv { @@ -51,6 +60,9 @@ export class ClaudeAdapter implements TargetAdapter { if (creds.envVars) { Object.assign(env, creds.envVars); } + if (creds.browserRuntimeEnv) { + Object.assign(env, creds.browserRuntimeEnv); + } if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl; if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey; diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 09a278d7..e7166fdd 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -188,20 +188,23 @@ export class CodexAdapter implements TargetAdapter { const profileType = options?.profileType || 'default'; const creds = options?.creds; const reasoningOverride = normalizeCodexReasoningOverride(creds?.reasoningOverride); + const runtimeConfigOverrides = creds?.runtimeConfigOverrides ?? []; if (profileType === 'default') { + const overrides = [...runtimeConfigOverrides]; if (reasoningOverride) { - if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { + overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`); + } + if (overrides.length === 0) { + return userArgs; + } + if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { + if (reasoningOverride) { throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo)); } - return [ - ...buildConfigOverrideArgs([ - `model_reasoning_effort=${formatTomlString(reasoningOverride)}`, - ]), - ...userArgs, - ]; + return userArgs; } - return userArgs; + return [...buildConfigOverrideArgs(overrides), ...userArgs]; } if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { @@ -233,6 +236,8 @@ export class CodexAdapter implements TargetAdapter { overrides.push(`model=${formatTomlString(creds.model)}`); } + overrides.push(...runtimeConfigOverrides); + if (reasoningOverride) { overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`); } diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index e42ed2d0..2a108c00 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -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( diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index abf5c3ce..fcbe08b1 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -29,8 +29,12 @@ export interface TargetCredentials { * Targets may ignore this when unsupported. */ reasoningOverride?: string | number; + /** Target-native runtime config overrides (for example Codex -c key=value entries). */ + runtimeConfigOverrides?: string[]; /** Additional env vars from profile resolution (websearch, hooks, etc.) */ envVars?: NodeJS.ProcessEnv; + /** Runtime browser reuse env passed through to Claude launches when browser MCP is active. */ + browserRuntimeEnv?: NodeJS.ProcessEnv; } /** diff --git a/src/utils/browser-codex-overrides.ts b/src/utils/browser-codex-overrides.ts new file mode 100644 index 00000000..02cdf8cb --- /dev/null +++ b/src/utils/browser-codex-overrides.ts @@ -0,0 +1,28 @@ +const CODEX_BROWSER_MCP_SERVER_NAME = 'ccs_browser'; +const DEFAULT_BROWSER_TOOL_TIMEOUT_SEC = 30; +const PLAYWRIGHT_MCP_PACKAGE = '@playwright/mcp@0.0.70'; + +function formatTomlString(value: string): string { + return JSON.stringify(value); +} + +function formatTomlArray(values: string[]): string { + return JSON.stringify(values); +} + +function getNpxCommand(): string { + return process.platform === 'win32' ? 'npx.cmd' : 'npx'; +} + +export function getCodexBrowserMcpServerName(): string { + return CODEX_BROWSER_MCP_SERVER_NAME; +} + +export function buildCodexBrowserMcpOverrides(): string[] { + return [ + `mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.command=${formatTomlString(getNpxCommand())}`, + `mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.args=${formatTomlArray(['-y', PLAYWRIGHT_MCP_PACKAGE])}`, + `mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.enabled=true`, + `mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.tool_timeout_sec=${DEFAULT_BROWSER_TOOL_TIMEOUT_SEC}`, + ]; +} diff --git a/src/utils/browser/chrome-reuse.ts b/src/utils/browser/chrome-reuse.ts new file mode 100644 index 00000000..eacefe8b --- /dev/null +++ b/src/utils/browser/chrome-reuse.ts @@ -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 { + const requestedProfileDir = options.profileDir || resolveDefaultChromeUserDataDir(); + const userDataDir = expandPath(requestedProfileDir); + + const directoryStat = await safeStat(userDataDir); + if (!directoryStat?.isDirectory()) { + throw new Error(`Chrome profile directory is invalid: ${userDataDir}`); + } + + const metadataPath = path.join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE); + const port = await resolveDevToolsPort({ + userDataDir, + metadataPath, + explicitPort: options.devtoolsPort || process.env.CCS_BROWSER_DEVTOOLS_PORT, + }); + const httpUrl = `http://${DEVTOOLS_HOST}:${port}`; + const versionUrl = `${httpUrl}/json/version`; + + let versionPayload: unknown; + try { + const response = await fetch(versionUrl); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + versionPayload = await response.json(); + } catch { + throw new Error(`Chrome DevTools endpoint is unreachable: ${httpUrl}`); + } + + const websocketUrl = + typeof versionPayload === 'object' && + versionPayload !== null && + typeof (versionPayload as Record).webSocketDebuggerUrl === 'string' + ? (versionPayload as Record).webSocketDebuggerUrl + : ''; + + if (!websocketUrl) { + throw new Error(`Chrome DevTools endpoint did not provide a websocket target: ${versionUrl}`); + } + + return { + CCS_BROWSER_USER_DATA_DIR: userDataDir, + CCS_BROWSER_DEVTOOLS_HOST: DEVTOOLS_HOST, + CCS_BROWSER_DEVTOOLS_PORT: port, + CCS_BROWSER_DEVTOOLS_HTTP_URL: httpUrl, + CCS_BROWSER_DEVTOOLS_WS_URL: websocketUrl, + }; +} + +function parseDevToolsPort(metadataPath: string, metadata: string): string { + const [rawPort] = metadata + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (!rawPort || !/^\d+$/.test(rawPort)) { + throw new Error(`Chrome reuse metadata is invalid: ${metadataPath}`); + } + + const port = Number.parseInt(rawPort, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Chrome reuse metadata is invalid: ${metadataPath}`); + } + + return String(port); +} + +interface ResolveDevToolsPortOptions { + userDataDir: string; + metadataPath: string; + explicitPort?: string; +} + +async function resolveDevToolsPort(options: ResolveDevToolsPortOptions): Promise { + if (options.explicitPort?.trim()) { + return parsePortValue(options.explicitPort.trim(), 'CCS_BROWSER_DEVTOOLS_PORT'); + } + + let metadata: string | undefined; + try { + metadata = await fs.promises.readFile(options.metadataPath, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code && code !== 'ENOENT') { + throw new Error(`Chrome reuse metadata is unreadable: ${options.metadataPath}`); + } + } + + if (metadata !== undefined) { + return parseDevToolsPort(options.metadataPath, metadata); + } + + const discoveredPort = discoverDevToolsPortFromChromeProcess(options.userDataDir); + if (discoveredPort) { + return discoveredPort; + } + + throw new Error(`Chrome reuse metadata not found: ${options.metadataPath}`); +} + +function parsePortValue(rawPort: string, sourceLabel: string): string { + if (!/^\d+$/.test(rawPort)) { + throw new Error(`Chrome reuse metadata is invalid: ${sourceLabel}`); + } + + const port = Number.parseInt(rawPort, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Chrome reuse metadata is invalid: ${sourceLabel}`); + } + + return String(port); +} + +function discoverDevToolsPortFromChromeProcess(userDataDir: string): string | undefined { + try { + const processList = execFileSync('ps', ['-axww', '-o', 'command='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const lines = processList + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const normalizedDir = normalizePathForComparison(userDataDir); + + for (const line of lines) { + if (!line.includes('Chrome') && !line.includes('chromium')) { + continue; + } + if (!matchesUserDataDir(line, normalizedDir)) { + continue; + } + + const match = line.match(/--remote-debugging-port=(\d+)/); + if (match?.[1]) { + return parsePortValue(match[1], 'process-list'); + } + } + } catch { + return undefined; + } + + return undefined; +} + +function matchesUserDataDir(commandLine: string, normalizedDir: string): boolean { + const candidates = [ + `--user-data-dir=${normalizedDir}`, + `--user-data-dir="${normalizedDir}"`, + `--user-data-dir='${normalizedDir}'`, + ]; + const normalizedCommand = normalizePathForComparison(commandLine); + return candidates.some((candidate) => normalizedCommand.includes(candidate)); +} + +function normalizePathForComparison(value: string): string { + return process.platform === 'win32' ? value.toLowerCase() : value; +} + +async function safeStat(targetPath: string): Promise { + try { + return await fs.promises.stat(targetPath); + } catch { + return null; + } +} diff --git a/src/utils/browser/claude-tool-args.ts b/src/utils/browser/claude-tool-args.ts new file mode 100644 index 00000000..d0e3338b --- /dev/null +++ b/src/utils/browser/claude-tool-args.ts @@ -0,0 +1,22 @@ +import { + hasExactFlagValue as hasExactClaudeFlagValue, + splitArgsAtTerminator as splitClaudeArgsAtTerminator, +} from '../claude-tool-args'; + +const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; +const BROWSER_STEERING_PROMPT = + 'For DOM/screenshots/elements/page actions, prefer the CCS MCP Browser tool, reuse the configured running Chrome context whenever possible, and if the tool or context is unavailable, explain that clearly instead of pretending page state is available.'; + +function ensureBrowserSteeringPrompt(args: string[]): string[] { + const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args); + + if (hasExactClaudeFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, BROWSER_STEERING_PROMPT)) { + return args; + } + + return [...optionArgs, APPEND_SYSTEM_PROMPT_FLAG, BROWSER_STEERING_PROMPT, ...trailingArgs]; +} + +export function appendBrowserToolArgs(args: string[]): string[] { + return ensureBrowserSteeringPrompt(args); +} diff --git a/src/utils/browser/index.ts b/src/utils/browser/index.ts new file mode 100644 index 00000000..be30da8e --- /dev/null +++ b/src/utils/browser/index.ts @@ -0,0 +1,25 @@ +/** + * Browser Utilities + */ + +export { + getBrowserMcpServerName, + getBrowserMcpServerPath, + installBrowserMcpServer, + ensureBrowserMcpConfig, + ensureBrowserMcp, + uninstallBrowserMcpServer, + removeBrowserMcpConfig, + uninstallBrowserMcp, + syncBrowserMcpToConfigDir, + ensureBrowserMcpOrThrow, +} from './mcp-installer'; + +export { appendBrowserToolArgs } from './claude-tool-args'; + +export { + resolveBrowserRuntimeEnv, + resolveDefaultChromeUserDataDir, + resolveConfiguredBrowserProfileDir, +} from './chrome-reuse'; +export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse'; diff --git a/src/utils/browser/mcp-installer.ts b/src/utils/browser/mcp-installer.ts new file mode 100644 index 00000000..e5e8552c --- /dev/null +++ b/src/utils/browser/mcp-installer.ts @@ -0,0 +1,375 @@ +/** + * Browser MCP installer and ~/.claude.json provisioning. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; +import { InstanceManager } from '../../management/instance-manager'; +import { getClaudeUserConfigPath } from '../claude-config-path'; +import { getCcsDir } from '../config-manager'; + +const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs'; +const BROWSER_MCP_SERVER_NAME = 'ccs-browser'; + +interface ClaudeUserConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +interface ManagedBrowserMcpConfig { + type: 'stdio'; + command: 'node'; + args: [string]; + env: Record; +} + +function resolvePackageRoot(fromPath: string): string | null { + let currentDir = path.dirname(fromPath); + while (true) { + if (fs.existsSync(path.join(currentDir, 'package.json'))) { + return currentDir; + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +function getBrowserMcpServerEnv(): Record { + const sourcePath = resolveBundledServerSourcePath(); + if (!sourcePath) { + return {}; + } + + const packageRoot = resolvePackageRoot(sourcePath); + if (!packageRoot) { + return {}; + } + + const nodeModulesPath = path.join(packageRoot, 'node_modules'); + return fs.existsSync(nodeModulesPath) ? { NODE_PATH: nodeModulesPath } : {}; +} + +function getCcsMcpDir(): string { + return path.join(getCcsDir(), 'mcp'); +} + +export function getBrowserMcpServerName(): string { + return BROWSER_MCP_SERVER_NAME; +} + +export function getBrowserMcpServerPath(): string { + return path.join(getCcsMcpDir(), BROWSER_MCP_SERVER); +} + +function hasMatchingContents(sourcePath: string, destinationPath: string): boolean { + if (!fs.existsSync(destinationPath)) { + return false; + } + + const source = fs.readFileSync(sourcePath); + try { + const destination = fs.readFileSync(destinationPath); + return source.equals(destination); + } catch { + return false; + } +} + +function getTempPath(targetPath: string): string { + const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${targetPath}.${suffix}.tmp`; +} + +function resolveBundledServerSourcePath(): string | null { + const possiblePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER), + path.join(__dirname, '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER), + path.join(__dirname, '..', 'lib', 'mcp', BROWSER_MCP_SERVER), + ]; + + for (const candidate of possiblePaths) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null { + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return null; + } + return parsed as ClaudeUserConfig; + } catch { + return null; + } +} + +function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean { + const tempPath = getTempPath(configPath); + const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600; + + try { + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.chmodSync(tempPath, fileMode); + fs.renameSync(tempPath, configPath); + return true; + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} + +function withClaudeUserConfigLock(configPath: string, callback: () => T): T { + const configDir = path.dirname(configPath); + const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`); + let release: (() => void) | undefined; + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + } + + if (!fs.existsSync(lockTarget)) { + fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 }); + } + + try { + release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void; + return callback(); + } finally { + if (release) { + try { + release(); + } catch { + // Best-effort release. + } + } + } +} + +function isLockUnavailableError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === 'ELOCKED' || code === 'ENOTACQUIRED'; +} + +function removeManagedServerConfig(configPath: string): boolean { + if (!fs.existsSync(configPath)) { + return false; + } + + try { + return withClaudeUserConfigLock(configPath, () => { + const config = readClaudeUserConfig(configPath); + if (config === null) { + return false; + } + + const existingServers = + config.mcpServers && + typeof config.mcpServers === 'object' && + !Array.isArray(config.mcpServers) + ? { ...(config.mcpServers as Record) } + : {}; + + if (!(BROWSER_MCP_SERVER_NAME in existingServers)) { + return false; + } + + delete existingServers[BROWSER_MCP_SERVER_NAME]; + + const nextConfig: ClaudeUserConfig = { ...config }; + if (Object.keys(existingServers).length === 0) { + delete nextConfig.mcpServers; + } else { + nextConfig.mcpServers = existingServers; + } + + try { + return writeClaudeUserConfig(configPath, nextConfig); + } catch { + return false; + } + }); + } catch (error) { + if (isLockUnavailableError(error)) { + return false; + } + throw error; + } +} + +export function installBrowserMcpServer(): boolean { + const sourcePath = resolveBundledServerSourcePath(); + if (!sourcePath) { + return false; + } + + const mcpDir = getCcsMcpDir(); + if (!fs.existsSync(mcpDir)) { + fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 }); + } + + const serverPath = getBrowserMcpServerPath(); + const sourceMode = fs.statSync(sourcePath).mode & 0o777; + if (hasMatchingContents(sourcePath, serverPath)) { + if ((fs.statSync(serverPath).mode & 0o777) !== sourceMode) { + fs.chmodSync(serverPath, sourceMode); + } + return true; + } + + const tempPath = getTempPath(serverPath); + + try { + fs.copyFileSync(sourcePath, tempPath); + fs.chmodSync(tempPath, sourceMode); + try { + fs.renameSync(tempPath, serverPath); + } catch (renameError) { + const errorCode = (renameError as NodeJS.ErrnoException).code; + if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') { + throw renameError; + } + + if (!hasMatchingContents(sourcePath, serverPath)) { + fs.copyFileSync(tempPath, serverPath); + fs.chmodSync(serverPath, sourceMode); + } + } + return true; + } catch { + return false; + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} + +export function ensureBrowserMcpConfig(): boolean { + const claudeUserConfigPath = getClaudeUserConfigPath(); + const claudeUserConfigDir = path.dirname(claudeUserConfigPath); + if (!fs.existsSync(claudeUserConfigDir)) { + fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 }); + } + + const desiredServerConfig: ManagedBrowserMcpConfig = { + type: 'stdio', + command: 'node', + args: [getBrowserMcpServerPath()], + env: getBrowserMcpServerEnv(), + }; + + try { + return withClaudeUserConfigLock(claudeUserConfigPath, () => { + const config = readClaudeUserConfig(claudeUserConfigPath); + if (config === null) { + return false; + } + + const existingServers = + config.mcpServers && + typeof config.mcpServers === 'object' && + !Array.isArray(config.mcpServers) + ? (config.mcpServers as Record) + : {}; + const currentConfig = existingServers[BROWSER_MCP_SERVER_NAME]; + if ( + typeof currentConfig === 'object' && + currentConfig !== null && + JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig) + ) { + return true; + } + + const nextConfig: ClaudeUserConfig = { + ...config, + mcpServers: { + ...existingServers, + [BROWSER_MCP_SERVER_NAME]: desiredServerConfig, + }, + }; + + try { + writeClaudeUserConfig(claudeUserConfigPath, nextConfig); + return true; + } catch { + return false; + } + }); + } catch (error) { + if (isLockUnavailableError(error)) { + return false; + } + throw error; + } +} + +export function ensureBrowserMcp(): boolean { + const installed = installBrowserMcpServer(); + const configured = installed && ensureBrowserMcpConfig(); + return installed && configured; +} + +export function syncBrowserMcpToConfigDir(claudeConfigDir: string | undefined): boolean { + if (!claudeConfigDir) { + return false; + } + + return new InstanceManager().syncMcpServers(claudeConfigDir); +} + +export function uninstallBrowserMcpServer(): boolean { + const serverPath = getBrowserMcpServerPath(); + if (!fs.existsSync(serverPath)) { + return false; + } + + try { + fs.unlinkSync(serverPath); + return true; + } catch { + return false; + } +} + +export function removeBrowserMcpConfig(): boolean { + let removed = removeManagedServerConfig(getClaudeUserConfigPath()); + + const instanceManager = new InstanceManager(); + for (const instanceName of instanceManager.listInstances()) { + const instancePath = instanceManager.getInstancePath(instanceName); + const instanceClaudeConfigPath = path.join(instancePath, '.claude.json'); + removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed; + } + + return removed; +} + +export function uninstallBrowserMcp(): boolean { + const removedConfig = removeBrowserMcpConfig(); + const removedServer = uninstallBrowserMcpServer(); + return removedConfig || removedServer; +} + +export function ensureBrowserMcpOrThrow(): boolean { + const ready = ensureBrowserMcp(); + if (!ready) { + throw new Error('Browser MCP is enabled, but CCS could not prepare the local browser tool.'); + } + + return ready; +} diff --git a/src/utils/claude-tool-args.ts b/src/utils/claude-tool-args.ts new file mode 100644 index 00000000..31984ed1 --- /dev/null +++ b/src/utils/claude-tool-args.ts @@ -0,0 +1,46 @@ +export function splitArgsAtTerminator(args: string[]): { + optionArgs: string[]; + trailingArgs: string[]; +} { + const terminatorIndex = args.indexOf('--'); + if (terminatorIndex === -1) { + return { optionArgs: args, trailingArgs: [] }; + } + + return { + optionArgs: args.slice(0, terminatorIndex), + trailingArgs: args.slice(terminatorIndex), + }; +} + +export function getImmediateFlagValue(args: string[], index: number): string | null { + const value = args[index + 1]; + if (value === undefined || value === '--' || value.startsWith('--')) { + return null; + } + return value; +} + +export function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === flag) { + const value = getImmediateFlagValue(args, index); + if (value === expectedValue) { + return true; + } + continue; + } + + if (arg === `${flag}=${expectedValue}`) { + return true; + } + + if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) { + return true; + } + } + + return false; +} diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts index f1d0cf0b..c4be4df1 100644 --- a/src/utils/image-analysis/claude-tool-args.ts +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -1,68 +1,48 @@ /** * Claude launch argument helpers for first-class Image Analysis. + * + * Uses the same prompt injection mode as the user to avoid mixing + * `--append-system-prompt` and `--append-system-prompt-file` in one request. */ -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.'; +import { + hasExactFlagValue as hasExactClaudeFlagValue, + splitArgsAtTerminator as splitClaudeArgsAtTerminator, +} from '../claude-tool-args'; +import { + buildSteeringArg, + hasManagedPromptFileArg, + PROMPT_FLAG_INLINE, +} from '../prompt-injection-strategy'; -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; -} +const IMAGE_ANALYSIS_STEERING_PROMPT = { + name: 'ccs-prompt-image-analysis-tool', + content: + '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 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, PROMPT_FLAG_INLINE, IMAGE_ANALYSIS_STEERING_PROMPT.content) + ) { return args; } - return [ - ...optionArgs, - APPEND_SYSTEM_PROMPT_FLAG, - IMAGE_ANALYSIS_STEERING_PROMPT, - ...trailingArgs, - ]; + if ( + hasManagedPromptFileArg({ args: optionArgs, promptName: IMAGE_ANALYSIS_STEERING_PROMPT.name }) + ) { + return args; + } + + const steeringArg = buildSteeringArg({ + args: optionArgs, + promptName: IMAGE_ANALYSIS_STEERING_PROMPT.name, + promptContent: IMAGE_ANALYSIS_STEERING_PROMPT.content, + }); + + return [...optionArgs, ...steeringArg, ...trailingArgs]; } export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] { @@ -70,5 +50,5 @@ export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] } export function getImageAnalysisSteeringPrompt(): string { - return IMAGE_ANALYSIS_STEERING_PROMPT; + return IMAGE_ANALYSIS_STEERING_PROMPT.content; } diff --git a/src/utils/prompt-injection-strategy.ts b/src/utils/prompt-injection-strategy.ts new file mode 100644 index 00000000..7624556d --- /dev/null +++ b/src/utils/prompt-injection-strategy.ts @@ -0,0 +1,127 @@ +/** + * Shared prompt injection strategy. + * + * Detects which prompt injection mode the user is using and ensures CCS + * always uses the SAME mode so Claude CLI never receives mixed + * `--append-system-prompt` and `--append-system-prompt-file` flags. + * + * Rules: + * - User passes `--append-system-prompt` → all CCS prompts use inline + * - User passes `--append-system-prompt-file` → all CCS prompts use file + * - Neither present → default to inline (`--append-system-prompt`) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from './config-manager'; + +export type PromptInjectionMode = 'inline' | 'file'; + +/** `--append-system-prompt` — inline prompt text */ +export const PROMPT_FLAG_INLINE = '--append-system-prompt'; +/** `--append-system-prompt-file` — prompt read from file */ +export const PROMPT_FLAG_FILE = '--append-system-prompt-file'; + +function getManagedPromptsDir(): string { + return path.join(getCcsDir(), 'prompts'); +} + +export function getManagedPromptFileName(promptName: string): string { + return `${promptName}.txt`; +} + +export function getManagedPromptFilePath(promptName: string): string { + return path.join(getManagedPromptsDir(), getManagedPromptFileName(promptName)); +} + +export function hasManagedPromptFileArg(params: { args: string[]; promptName: string }): boolean { + const expectedPath = path.resolve(getManagedPromptFilePath(params.promptName)); + + for (let index = 0; index < params.args.length; index += 1) { + const arg = params.args[index]; + + if (arg === PROMPT_FLAG_FILE) { + const filePath = params.args[index + 1]; + if (filePath && path.resolve(filePath) === expectedPath) { + return true; + } + continue; + } + + if ( + arg.startsWith(`${PROMPT_FLAG_FILE}=`) && + path.resolve(arg.slice(PROMPT_FLAG_FILE.length + 1)) === expectedPath + ) { + return true; + } + } + + return false; +} + +/** + * Detect which prompt injection mode to use based on user-provided args. + * + * - `--append-system-prompt-file` found (space or `=` form) → 'file' + * - `--append-system-prompt` found (space or `=` form) → 'inline' + * - Neither → 'inline' (default) + */ +export function detectPromptInjectionMode(args: string[]): PromptInjectionMode { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + + if (arg === PROMPT_FLAG_FILE || arg.startsWith(`${PROMPT_FLAG_FILE}=`)) { + return 'file'; + } + } + + return 'inline'; +} + +/** + * Build a `--append-system-prompt ` arg pair. + */ +export function buildInlineSteeringArg(params: { promptContent: string }): string[] { + return [PROMPT_FLAG_INLINE, params.promptContent]; +} + +/** + * Build a `--append-system-prompt-file ` arg pair. + * Writes the prompt to a temp file first. + */ +export function buildFileSteeringArg(params: { + promptFileName: string; + promptContent: string; +}): string[] { + const promptsFolder = getManagedPromptsDir(); + + if (!fs.existsSync(promptsFolder)) { + fs.mkdirSync(promptsFolder, { recursive: true }); + } + + const promptFile = path.join(promptsFolder, params.promptFileName); + + fs.writeFileSync(promptFile, params.promptContent); + + return [PROMPT_FLAG_FILE, promptFile]; +} + +/** + * Build steering prompt args in the given mode. + */ +export function buildSteeringArg(params: { + args: string[]; + promptName: string; + promptContent: string; +}): string[] { + const mode = detectPromptInjectionMode(params.args); + + if (mode === 'file') { + return buildFileSteeringArg({ + promptFileName: getManagedPromptFileName(params.promptName), + promptContent: params.promptContent, + }); + } + + return buildInlineSteeringArg({ promptContent: params.promptContent }); +} diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index 15028178..a680fedd 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -1,12 +1,28 @@ /** * Claude launch argument helpers for third-party WebSearch. + * + * Uses the same prompt injection mode as the user to avoid mixing + * `--append-system-prompt` and `--append-system-prompt-file` in one request. */ +import { + getImmediateFlagValue, + hasExactFlagValue as hasExactClaudeFlagValue, + splitArgsAtTerminator as splitClaudeArgsAtTerminator, +} from '../claude-tool-args'; +import { + buildSteeringArg, + hasManagedPromptFileArg, + PROMPT_FLAG_INLINE, +} from '../prompt-injection-strategy'; + const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; const DISALLOWED_TOOLS_FLAG = '--disallowedTools'; -const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; -const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT = - 'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.'; +export const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT = { + name: 'ccs-prompt-websearch-tool', + content: + 'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.', +}; function parseToolValue(rawValue: string): string[] { return rawValue @@ -23,26 +39,6 @@ function mergeToolValues(rawValues: string[], toolName: string): string { return merged.join(','); } -function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } { - const terminatorIndex = args.indexOf('--'); - if (terminatorIndex === -1) { - return { optionArgs: args, trailingArgs: [] }; - } - - return { - optionArgs: args.slice(0, terminatorIndex), - trailingArgs: args.slice(terminatorIndex), - }; -} - -function getImmediateFlagValue(args: string[], index: number): string | null { - const value = args[index + 1]; - if (value === undefined || value === '--' || value.startsWith('--')) { - return null; - } - return value; -} - function hasToolInFlag(args: string[], flag: string, toolName: string): boolean { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -68,32 +64,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,20 +104,34 @@ 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, + PROMPT_FLAG_INLINE, + THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content + ) ) { return args; } - return [ - ...optionArgs, - APPEND_SYSTEM_PROMPT_FLAG, - THIRD_PARTY_WEBSEARCH_STEERING_PROMPT, - ...trailingArgs, - ]; + if ( + hasManagedPromptFileArg({ + args: optionArgs, + promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, + }) + ) { + return args; + } + + const steeringArgs = buildSteeringArg({ + args: optionArgs, + promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, + promptContent: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content, + }); + + return [...optionArgs, ...steeringArgs, ...trailingArgs]; } export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] { diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index d5e69018..2e79e649 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -7,6 +7,7 @@ */ import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { normalizeSearxngBaseUrl } from './types'; import { resolveAllowedWebSearchTraceFile } from './trace'; /** @@ -18,7 +19,18 @@ import { resolveAllowedWebSearchTraceFile } from './trace'; */ export function getWebSearchHookEnv(): Record { const wsConfig = getWebSearchConfig(); - const env: Record = {}; + const env: Record = { + CCS_WEBSEARCH_ENABLED: '0', + CCS_WEBSEARCH_SKIP: '0', + CCS_WEBSEARCH_EXA: '0', + CCS_WEBSEARCH_TAVILY: '0', + CCS_WEBSEARCH_BRAVE: '0', + CCS_WEBSEARCH_SEARXNG: '0', + CCS_WEBSEARCH_DUCKDUCKGO: '0', + CCS_WEBSEARCH_GEMINI: '0', + CCS_WEBSEARCH_OPENCODE: '0', + CCS_WEBSEARCH_GROK: '0', + }; if (process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1') { env.CCS_WEBSEARCH_TRACE = '1'; @@ -61,6 +73,13 @@ export function getWebSearchHookEnv(): Record { env.CCS_WEBSEARCH_BRAVE_MAX_RESULTS = String(wsConfig.providers.brave.max_results || 5); } + const searxngBaseUrl = normalizeSearxngBaseUrl(wsConfig.providers?.searxng?.url); + if (wsConfig.providers?.searxng?.enabled && searxngBaseUrl) { + env.CCS_WEBSEARCH_SEARXNG = '1'; + env.CCS_WEBSEARCH_SEARXNG_URL = searxngBaseUrl; + env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = String(wsConfig.providers.searxng.max_results || 5); + } + if (wsConfig.providers?.gemini?.enabled) { env.CCS_WEBSEARCH_GEMINI = '1'; if (wsConfig.providers.gemini.model) { diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index ecb5ce94..5f496624 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -15,7 +15,7 @@ import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli'; import { getGrokCliStatus } from './grok-cli'; import { getOpenCodeCliStatus } from './opencode-cli'; import { getWebSearchApiKeyStates } from './provider-secrets'; -import type { WebSearchCliInfo, WebSearchStatus } from './types'; +import { normalizeSearxngBaseUrl, type WebSearchCliInfo, type WebSearchStatus } from './types'; const PROVIDER_STATE_FILE = 'websearch-provider-state.json'; @@ -28,6 +28,11 @@ function hasEnvValue(name: string): boolean { return (process.env[name] || '').trim().length > 0; } +function hasValidSearxngUrl(url: string | undefined): boolean { + const normalized = normalizeSearxngBaseUrl(url); + return normalized !== null && normalized !== ''; +} + function getProviderStatePath(): string { return join(getCcsDir(), 'cache', PROVIDER_STATE_FILE); } @@ -209,18 +214,6 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] { ? 'Stored in dashboard, but Global Env is disabled' : 'Set TAVILY_API_KEY', }, - { - id: 'duckduckgo', - kind: 'backend', - name: 'DuckDuckGo', - enabled: wsConfig.providers?.duckduckgo?.enabled ?? true, - available: wsConfig.providers?.duckduckgo?.enabled ?? true, - version: null, - docsUrl: 'https://duckduckgo.com', - requiresApiKey: false, - description: 'Default built-in HTML search backend. Zero setup.', - detail: `Built-in (${wsConfig.providers?.duckduckgo?.max_results ?? 5} results)`, - }, { id: 'brave', kind: 'backend', @@ -238,6 +231,34 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] { ? 'Stored in dashboard, but Global Env is disabled' : 'Set BRAVE_API_KEY', }, + { + id: 'searxng', + kind: 'backend', + name: 'SearXNG', + enabled: wsConfig.providers?.searxng?.enabled ?? false, + available: + (wsConfig.providers?.searxng?.enabled ?? false) && + hasValidSearxngUrl(wsConfig.providers?.searxng?.url), + version: null, + docsUrl: 'https://docs.searxng.org/dev/search_api.html', + requiresApiKey: false, + description: 'Configurable SearXNG JSON backend for self-hosted or public instances.', + detail: hasValidSearxngUrl(wsConfig.providers?.searxng?.url) + ? `Configured (${wsConfig.providers?.searxng?.max_results ?? 5} results)` + : 'Set a valid SearXNG base URL', + }, + { + id: 'duckduckgo', + kind: 'backend', + name: 'DuckDuckGo', + enabled: wsConfig.providers?.duckduckgo?.enabled ?? true, + available: wsConfig.providers?.duckduckgo?.enabled ?? true, + version: null, + docsUrl: 'https://duckduckgo.com', + requiresApiKey: false, + description: 'Default built-in HTML search backend. Zero setup.', + detail: `Built-in (${wsConfig.providers?.duckduckgo?.max_results ?? 5} results)`, + }, ]; return [...providers, ...getLegacyProviderStatuses()].map((provider) => @@ -263,6 +284,7 @@ export function getCliInstallHints(): string[] { return [ 'WebSearch: no ready providers', ' Enable DuckDuckGo in Settings > WebSearch for zero-setup search', + ' Or enable SearXNG and set a valid base URL (must support /search?format=json)', ' Or export EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY for API-backed search', ' Optional legacy fallback: npm i -g @google/gemini-cli', ]; diff --git a/src/utils/websearch/trace.ts b/src/utils/websearch/trace.ts index 5629be7a..114cc48c 100644 --- a/src/utils/websearch/trace.ts +++ b/src/utils/websearch/trace.ts @@ -11,13 +11,12 @@ import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../config-manager'; import { createLogger } from '../../services/logging'; +import { hasManagedPromptFileArg, PROMPT_FLAG_INLINE } from '../prompt-injection-strategy'; +import { THIRD_PARTY_WEBSEARCH_STEERING_PROMPT } from './claude-tool-args'; const TRACE_FILE_NAME = 'websearch-trace.jsonl'; const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; const DISALLOWED_TOOLS_FLAG = '--disallowedTools'; -const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; -const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT = - 'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.'; const logger = createLogger('websearch'); function parseToolValue(rawValue: string): string[] { @@ -58,14 +57,23 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean return false; } -function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { +function hasExactFlagValue(params: { + args: string[]; + flag: string; + expectedValue: string; +}): boolean { + const { args, flag, expectedValue } = params; + for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === flag) { - if (getImmediateFlagValue(args, index) === expectedValue) { + const immediateFlagValue = getImmediateFlagValue(args, index); + + if (immediateFlagValue === expectedValue) { return true; } + continue; } @@ -179,16 +187,30 @@ function buildLaunchId(): string { return `websearch-${Date.now()}-${process.pid}-${random}`; } +function hasSteeringPromptInArgs(args: string[]): boolean { + if ( + hasExactFlagValue({ + args, + flag: PROMPT_FLAG_INLINE, + expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content, + }) + ) { + return true; + } + + if (hasManagedPromptFileArg({ args, promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name })) { + return true; + } + + return false; +} + function summarizeLaunchArgs(args: string[]): Record { return { argCount: args.length, hasSettingsFlag: args.includes('--settings'), nativeWebSearchDisallowed: hasToolInFlag(args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL), - steeringPromptApplied: hasExactFlagValue( - args, - APPEND_SYSTEM_PROMPT_FLAG, - THIRD_PARTY_WEBSEARCH_STEERING_PROMPT - ), + steeringPromptApplied: hasSteeringPromptInArgs(args), }; } diff --git a/src/utils/websearch/types.ts b/src/utils/websearch/types.ts index 8d737617..38f94499 100644 --- a/src/utils/websearch/types.ts +++ b/src/utils/websearch/types.ts @@ -37,8 +37,9 @@ export type WebSearchReadiness = 'ready' | 'needs_setup' | 'unavailable'; export type WebSearchProviderId = | 'exa' | 'tavily' - | 'duckduckgo' | 'brave' + | 'searxng' + | 'duckduckgo' | 'gemini' | 'grok' | 'opencode'; @@ -97,6 +98,7 @@ export interface WebSearchProviderConfig { model?: string; timeout?: number; max_results?: number; + url?: string; } /** @@ -107,10 +109,53 @@ export interface WebSearchConfig { providers?: { exa?: WebSearchProviderConfig; tavily?: WebSearchProviderConfig; - duckduckgo?: WebSearchProviderConfig; brave?: WebSearchProviderConfig; + searxng?: WebSearchProviderConfig; + duckduckgo?: WebSearchProviderConfig; gemini?: WebSearchProviderConfig; opencode?: WebSearchProviderConfig; grok?: WebSearchProviderConfig; }; } + +/** + * Normalize a SearXNG base URL so runtime code can safely append `/search`. + * + * Accepts optional subpaths (for reverse-proxy deployments), strips a trailing + * `/search` endpoint suffix, and rejects query/hash-bearing URLs because the + * runtime owns the request path and query params. + */ +export function normalizeSearxngBaseUrl(url: string | undefined): string | null { + const normalized = String(url || '').trim(); + if (!normalized) { + return ''; + } + + try { + const parsed = new URL(normalized); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + + if (parsed.username || parsed.password) { + return null; + } + + if (parsed.search || parsed.hash) { + return null; + } + + let pathname = parsed.pathname.replace(/\/+$/, ''); + if (pathname.toLowerCase().endsWith('/search')) { + pathname = pathname.slice(0, -'/search'.length); + } + + parsed.pathname = pathname || '/'; + parsed.search = ''; + parsed.hash = ''; + + return parsed.toString().replace(/\/+$/, ''); + } catch { + return null; + } +} diff --git a/src/web-server/health/websearch-checks.ts b/src/web-server/health/websearch-checks.ts index 3a4faa7d..b75abb1d 100644 --- a/src/web-server/health/websearch-checks.ts +++ b/src/web-server/health/websearch-checks.ts @@ -42,7 +42,7 @@ export function checkWebSearchClis(): HealthCheck[] { name: 'WebSearch Status', status: 'warning', message: 'No ready provider', - fix: 'Enable DuckDuckGo or set EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY', + fix: 'Enable DuckDuckGo, configure SearXNG URL, or set EXA_API_KEY/TAVILY_API_KEY/BRAVE_API_KEY', details: 'Third-party profiles need a local WebSearch backend.', }); } diff --git a/src/web-server/routes/cliproxy-local-proxy.ts b/src/web-server/routes/cliproxy-local-proxy.ts index a8359186..dc55e0fb 100644 --- a/src/web-server/routes/cliproxy-local-proxy.ts +++ b/src/web-server/routes/cliproxy-local-proxy.ts @@ -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(); } diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index a6bd9d44..a3fc3b05 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -5,6 +5,10 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; +import { + isRunningUnderSupervisord, + restartCliproxyViaSupervisord, +} from '../../docker/supervisord-lifecycle'; import { fetchCliproxyStats, fetchCliproxyModels, @@ -1014,7 +1018,14 @@ router.post('/install', async (req: Request, res: Response): Promise => { */ router.post('/restart', async (_req: Request, res: Response): Promise => { try { - // Stop proxy first + if (isRunningUnderSupervisord()) { + // Docker mode: delegate to supervisord which owns the process lifecycle + const result = restartCliproxyViaSupervisord(); + res.json(result); + return; + } + + // Local mode: direct process management await stopProxy(); // Small delay to ensure port is released diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts index b28c3705..defcc96e 100644 --- a/src/web-server/routes/cursor-routes.ts +++ b/src/web-server/routes/cursor-routes.ts @@ -11,6 +11,7 @@ import { stopDaemon, checkAuthStatus, autoDetectTokens, + probeCursorRuntime, saveCredentials, validateToken, } from '../../cursor'; @@ -30,6 +31,34 @@ interface DaemonStartPreconditionError { error: string; } +export function getAutoDetectFailureStatus( + reason?: ReturnType['reason'] +): number { + switch (reason) { + case 'sqlite_unavailable': + return 503; + case 'db_query_failed': + return 500; + case 'invalid_token_format': + return 400; + default: + return 404; + } +} + +function getPublicAutoDetectError(result: ReturnType): string { + switch (result.reason) { + case 'db_not_found': + return 'Cursor state database not found on this host.'; + case 'sqlite_unavailable': + return 'Cursor state database was found, but sqlite3 is not available in PATH.'; + case 'db_query_failed': + return 'Cursor state database was found, but CCS could not query it.'; + default: + return result.error ?? 'Token not found'; + } +} + export function getDaemonStartPreconditionError( input: DaemonStartPreconditionInput ): DaemonStartPreconditionError | null { @@ -124,7 +153,11 @@ router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise => { } }); +/** + * POST /api/cursor/probe - Run a live authenticated runtime probe + */ +router.post('/probe', async (_req: Request, res: Response): Promise => { + try { + const cursorConfig = getCursorConfig(); + const result = await probeCursorRuntime(cursorConfig); + res.status(result.status).json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * POST /api/cursor/daemon/start - Start cursor proxy daemon * Path matches copilot convention: /api/{provider}/daemon/{action} diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index fc550868..22f88100 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -12,11 +12,14 @@ import { WEBSEARCH_API_KEY_PROVIDERS, type WebSearchApiKeyProviderId, } from '../../utils/websearch/provider-secrets'; +import { normalizeSearxngBaseUrl } from '../../utils/websearch/types'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); const WEBSEARCH_LOCAL_ACCESS_ERROR = 'WebSearch endpoints require localhost access when dashboard auth is disabled.'; +const DEFAULT_WEBSEARCH_MAX_RESULTS = 5; +const MAX_WEBSEARCH_MAX_RESULTS = 10; type WebSearchApiKeyUpdates = Partial>; @@ -28,6 +31,12 @@ function isWebSearchApiKeyProviderId(value: string): value is WebSearchApiKeyPro return Object.prototype.hasOwnProperty.call(WEBSEARCH_API_KEY_PROVIDERS, value); } +function clampWebSearchMaxResults(value: number | undefined, fallback: number): number { + const candidate = Number.isFinite(value) ? (value as number) : fallback; + const normalized = Number.isFinite(candidate) ? candidate : DEFAULT_WEBSEARCH_MAX_RESULTS; + return Math.max(1, Math.min(MAX_WEBSEARCH_MAX_RESULTS, Math.floor(normalized))); +} + router.use((req: Request, res: Response, next) => { if (requireLocalAccessWhenAuthDisabled(req, res, WEBSEARCH_LOCAL_ACCESS_ERROR)) { next(); @@ -82,6 +91,35 @@ router.put('/', (req: Request, res: Response): void => { return; } + if (providers?.searxng?.url !== undefined && typeof providers.searxng.url !== 'string') { + res.status(400).json({ error: 'Invalid value for providers.searxng.url. Must be a string.' }); + return; + } + + const normalizedSearxngUrl = + providers?.searxng?.url !== undefined + ? normalizeSearxngBaseUrl(providers.searxng.url) + : undefined; + + if (providers?.searxng?.url !== undefined && normalizedSearxngUrl === null) { + res.status(400).json({ + error: + 'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.', + }); + return; + } + + if ( + providers?.searxng?.max_results !== undefined && + (typeof providers.searxng.max_results !== 'number' || + !Number.isFinite(providers.searxng.max_results)) + ) { + res.status(400).json({ + error: 'Invalid value for providers.searxng.max_results. Must be a number.', + }); + return; + } + if ( apiKeys !== undefined && (apiKeys === null || Array.isArray(apiKeys) || typeof apiKeys !== 'object') @@ -106,6 +144,9 @@ router.put('/', (req: Request, res: Response): void => { try { mutateUnifiedConfig((config) => { + const existingSearxngUrl = + normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? ''; + config.websearch = { enabled: enabled ?? config.websearch?.enabled ?? true, providers: providers @@ -144,6 +185,17 @@ router.put('/', (req: Request, res: Response): void => { config.websearch?.providers?.brave?.max_results ?? 5, }, + searxng: { + enabled: + providers.searxng?.enabled ?? + config.websearch?.providers?.searxng?.enabled ?? + false, + url: normalizedSearxngUrl ?? existingSearxngUrl, + max_results: clampWebSearchMaxResults( + providers.searxng?.max_results, + config.websearch?.providers?.searxng?.max_results ?? DEFAULT_WEBSEARCH_MAX_RESULTS + ), + }, gemini: { enabled: providers.gemini?.enabled ?? diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 7091965f..8ea78bb5 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -3,6 +3,10 @@ import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/s import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker'; import { isCliproxyRunning } from '../../cliproxy/stats-fetcher'; import type { CLIProxyBackend } from '../../cliproxy/types'; +import { + isRunningUnderSupervisord, + restartCliproxyViaSupervisord, +} from '../../docker/supervisord-lifecycle'; interface ProxyStatusLike { running: boolean; @@ -63,6 +67,20 @@ export async function installDashboardCliproxyVersion( }; } + // In Docker, supervisord owns process lifecycle — delegate restart to it + if (isRunningUnderSupervisord()) { + const result = restartCliproxyViaSupervisord(); + return { + success: result.success, + restarted: result.success, + port: result.port, + error: result.error, + message: result.success + ? `Successfully installed ${backendLabel} v${version} and restarted it on port ${result.port}` + : `Installed ${backendLabel} v${version}, but restart failed`, + }; + } + const startResult = await deps.ensureCliproxyService(); if (!startResult.started && !startResult.alreadyRunning) { return { diff --git a/tests/integration/image-analyzer-hook.test.ts b/tests/integration/image-analyzer-hook.test.ts index 288e1ce5..57b8cc27 100644 --- a/tests/integration/image-analyzer-hook.test.ts +++ b/tests/integration/image-analyzer-hook.test.ts @@ -53,6 +53,11 @@ function invokeHook(env: Record = {}): Promise { 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', diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index 77c3a0a5..5d4d20ce 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -85,6 +85,20 @@ describe('npm CLI', () => { assert(!output.includes("Profile '--verbose' not found"), 'Should not treat flags as profiles'); } }); + + it('routes cursor probe through the cursor command handler', function() { + try { + execSync(`bun "${srcCcsPath}" cursor probe`, { + stdio: 'pipe', + timeout: 3000, + env: { ...process.env, CCS_HOME: testCcsHome } + }); + } catch (e) { + const output = e.stderr?.toString() || e.stdout?.toString() || ''; + assert(!output.includes("Profile 'cursor' not found"), 'Should not fall through to profile lookup'); + assert(output.includes('Cursor Live Probe'), 'Should render the cursor probe command output'); + } + }); }); describe('Profile handling', () => { diff --git a/tests/unit/auth/resume-lane-warning.test.ts b/tests/unit/auth/resume-lane-warning.test.ts index 49eb3064..83205178 100644 --- a/tests/unit/auth/resume-lane-warning.test.ts +++ b/tests/unit/auth/resume-lane-warning.test.ts @@ -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 () => { diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index 4f4c6890..e92a56a3 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -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; + } } }); }); diff --git a/tests/unit/commands/cliproxy-catalog-json.test.ts b/tests/unit/commands/cliproxy-catalog-json.test.ts new file mode 100644 index 00000000..7bc3e5ea --- /dev/null +++ b/tests/unit/commands/cliproxy-catalog-json.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { handleCatalogJson } from '../../../src/commands/cliproxy/catalog-subcommand'; +import { handleCliproxyCommand } from '../../../src/commands/cliproxy/index'; + +let originalConsoleLog: typeof console.log; +let capturedOutput: string[]; + +beforeEach(() => { + originalConsoleLog = console.log; + capturedOutput = []; + console.log = (...args: unknown[]) => { + capturedOutput.push(args.map(String).join(' ')); + }; +}); + +afterEach(() => { + console.log = originalConsoleLog; +}); + +describe('cliproxy catalog --json output', () => { + it('outputs valid JSON mapping provider names to model arrays', () => { + handleCatalogJson(); + + expect(capturedOutput).toHaveLength(1); + const parsed = JSON.parse(capturedOutput[0]) as Record; + + // Must be a non-empty object (static catalog always has providers) + expect(typeof parsed).toBe('object'); + expect(Object.keys(parsed).length).toBeGreaterThan(0); + + // Every provider entry must be an array of objects with at least id and name + for (const models of Object.values(parsed)) { + expect(Array.isArray(models)).toBe(true); + expect(models.length).toBeGreaterThan(0); + for (const model of models as Array>) { + expect(typeof model.id).toBe('string'); + expect(typeof model.name).toBe('string'); + } + } + }); + + it('includes metadata fields when present on model entries', () => { + handleCatalogJson(); + + const parsed = JSON.parse(capturedOutput[0]) as Record< + string, + Array> + >; + const allModels = Object.values(parsed).flat(); + + // At least some models in the static catalog have tier set + const withTier = allModels.filter((m) => m.tier !== undefined); + expect(withTier.length).toBeGreaterThan(0); + + // Tier values must be one of the allowed strings + for (const model of withTier) { + expect(['free', 'pro', 'ultra']).toContain(model.tier); + } + }); + + it('omits undefined optional fields instead of including nulls', () => { + handleCatalogJson(); + + const parsed = JSON.parse(capturedOutput[0]) as Record< + string, + Array> + >; + const allModels = Object.values(parsed).flat(); + + for (const model of allModels) { + for (const value of Object.values(model)) { + expect(value).not.toBeNull(); + expect(value).not.toBeUndefined(); + } + } + }); + + it('includes explicit false boolean values in output', () => { + handleCatalogJson(); + + const parsed = JSON.parse(capturedOutput[0]) as Record< + string, + Array> + >; + const allModels = Object.values(parsed).flat(); + + // Static catalog has models with extendedContext: false + const withExplicitFalse = allModels.filter((m) => m.extendedContext === false); + expect(withExplicitFalse.length).toBeGreaterThan(0); + }); + + it('includes thinking configuration when present on models', () => { + handleCatalogJson(); + + const parsed = JSON.parse(capturedOutput[0]) as Record< + string, + Array> + >; + const allModels = Object.values(parsed).flat(); + + // Static catalog has thinking models (e.g. Claude Opus 4.6 Thinking) + const withThinking = allModels.filter((m) => m.thinking !== undefined); + expect(withThinking.length).toBeGreaterThan(0); + + for (const model of withThinking) { + const thinking = model.thinking as Record; + expect(['budget', 'levels', 'none']).toContain(thinking.type); + } + }); + + it('outputs minified JSON (single line, no whitespace formatting)', () => { + handleCatalogJson(); + + const output = capturedOutput[0]; + expect(output.includes('\n')).toBe(false); + expect(JSON.stringify(JSON.parse(output))).toBe(output); + }); +}); + +describe('cliproxy catalog --json routing', () => { + it('routes catalog --json through handleCliproxyCommand', async () => { + await handleCliproxyCommand(['catalog', '--json']); + + expect(capturedOutput).toHaveLength(1); + const parsed = JSON.parse(capturedOutput[0]); + expect(typeof parsed).toBe('object'); + expect(Object.keys(parsed).length).toBeGreaterThan(0); + }); + + it('--json takes priority over refresh subcommand', async () => { + await handleCliproxyCommand(['catalog', 'refresh', '--json']); + + expect(capturedOutput).toHaveLength(1); + // Should output JSON, not refresh output + const parsed = JSON.parse(capturedOutput[0]); + expect(typeof parsed).toBe('object'); + }); + + it('--json takes priority when placed before subcommand', async () => { + await handleCliproxyCommand(['catalog', '--json', 'reset']); + + expect(capturedOutput).toHaveLength(1); + const parsed = JSON.parse(capturedOutput[0]); + expect(typeof parsed).toBe('object'); + }); +}); diff --git a/tests/unit/commands/config-auth-command.test.ts b/tests/unit/commands/config-auth-command.test.ts index 5674b652..8fba6e33 100644 --- a/tests/unit/commands/config-auth-command.test.ts +++ b/tests/unit/commands/config-auth-command.test.ts @@ -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 () => { diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index 6235be55..80a6dcd6 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -73,6 +73,8 @@ function stubProcessExit(): void { beforeEach(async () => { tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-')); originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + // Clear CLAUDE_CONFIG_DIR so scoped CCS_HOME takes effect (leaks from host CCS session) + delete process.env.CLAUDE_CONFIG_DIR; originalProcessExit = process.exit; originalFsOpen = fs.promises.open; originalFsRename = fs.promises.rename; diff --git a/tests/unit/cursor/cursor-auth.test.ts b/tests/unit/cursor/cursor-auth.test.ts index b2aaa6bf..25003f12 100644 --- a/tests/unit/cursor/cursor-auth.test.ts +++ b/tests/unit/cursor/cursor-auth.test.ts @@ -3,6 +3,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -15,6 +16,7 @@ import { checkAuthStatus, deleteCredentials, autoDetectTokens, + getTokenStorageCandidates, } from '../../../src/cursor/cursor-auth'; // Test isolation @@ -403,26 +405,40 @@ describe('deleteCredentials', () => { }); describe('autoDetectTokens', () => { - it('should return not found for Windows platform', () => { - // Save original platform + it('should report checked paths when no Windows database exists', () => { const originalPlatform = process.platform; + const originalUserProfile = process.env.USERPROFILE; + const originalAppData = process.env.APPDATA; + const originalLocalAppData = process.env.LOCALAPPDATA; + const fakeHome = path.join(tempDir, 'win-home'); + process.env.USERPROFILE = fakeHome; + delete process.env.APPDATA; + delete process.env.LOCALAPPDATA; - // Mock Windows platform Object.defineProperty(process, 'platform', { value: 'win32', configurable: true, }); - const result = autoDetectTokens(); + try { + const result = autoDetectTokens(); - expect(result.found).toBe(false); - expect(result.error).toContain('not supported on Windows'); - - // Restore original platform - Object.defineProperty(process, 'platform', { - value: originalPlatform, - configurable: true, - }); + expect(result.found).toBe(false); + expect(result.reason).toBe('db_not_found'); + expect(result.checkedPaths?.length).toBeGreaterThan(0); + expect(result.error).toContain('Checked:'); + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + if (originalUserProfile !== undefined) process.env.USERPROFILE = originalUserProfile; + else delete process.env.USERPROFILE; + if (originalAppData !== undefined) process.env.APPDATA = originalAppData; + else delete process.env.APPDATA; + if (originalLocalAppData !== undefined) process.env.LOCALAPPDATA = originalLocalAppData; + else delete process.env.LOCALAPPDATA; + } }); it('should return not found when database file does not exist', () => { @@ -438,9 +454,10 @@ describe('autoDetectTokens', () => { try { const result = autoDetectTokens(); - // Should fail because isolated test home has no Cursor database expect(result.found).toBe(false); - expect(result.error).toBeDefined(); + expect(result.reason).toBe('db_not_found'); + expect(result.checkedPaths?.length).toBeGreaterThan(0); + expect(result.error).toContain('Checked:'); } finally { if (originalHome !== undefined) { process.env.HOME = originalHome; @@ -457,4 +474,152 @@ describe('autoDetectTokens', () => { expect(result).toHaveProperty('found'); expect(typeof result.found).toBe('boolean'); }); + + it('should report sqlite_unavailable when database exists but sqlite3 is missing', () => { + const originalHome = process.env.HOME; + const originalPath = process.env.PATH; + const originalSqliteBin = process.env.CCS_CURSOR_SQLITE_BIN; + const fakeHome = path.join(tempDir, 'sqlite-missing-home'); + process.env.HOME = fakeHome; + process.env.CCS_CURSOR_SQLITE_BIN = 'definitely-missing-sqlite3'; + + try { + const dbPath = getTokenStorageCandidates()[0]; + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.writeFileSync(dbPath, ''); + + const result = autoDetectTokens(); + expect(result.found).toBe(false); + expect(result.reason).toBe('sqlite_unavailable'); + expect(result.dbPath).toBe(dbPath); + } finally { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + if (originalPath !== undefined) process.env.PATH = originalPath; + else delete process.env.PATH; + if (originalSqliteBin !== undefined) process.env.CCS_CURSOR_SQLITE_BIN = originalSqliteBin; + else delete process.env.CCS_CURSOR_SQLITE_BIN; + } + }); + + it('should use fallback keys and normalize JSON-encoded sqlite values', () => { + if (process.platform === 'win32') return; + + try { + execFileSync('sqlite3', ['--version'], { stdio: 'ignore' }); + } catch { + return; + } + + const originalHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'sqlite-fallback-home'); + process.env.HOME = fakeHome; + + try { + const dbPath = getTokenStorageCandidates()[0]; + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + execFileSync('sqlite3', [ + dbPath, + 'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);', + ]); + execFileSync('sqlite3', [ + dbPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/token', '"${'a'.repeat(60)}"');`, + ]); + execFileSync('sqlite3', [ + dbPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.machineId', '"1234567890abcdef1234567890abcdef"');`, + ]); + + const result = autoDetectTokens(); + expect(result.found).toBe(true); + expect(result.accessToken).toBe('a'.repeat(60)); + expect(result.machineId).toBe('1234567890abcdef1234567890abcdef'); + } finally { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + } + }); + + it('should continue scanning later database candidates after one invalid credential pair', () => { + if (process.platform !== 'darwin') { + return; + } + + try { + execFileSync('sqlite3', ['--version'], { stdio: 'ignore' }); + } catch { + return; + } + + const originalHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'candidate-scan-home'); + process.env.HOME = fakeHome; + + try { + const [stablePath, insidersPath] = getTokenStorageCandidates(); + fs.mkdirSync(path.dirname(stablePath), { recursive: true }); + fs.mkdirSync(path.dirname(insidersPath), { recursive: true }); + + execFileSync('sqlite3', [ + stablePath, + 'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);', + ]); + execFileSync('sqlite3', [ + stablePath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/accessToken', 'short');`, + ]); + execFileSync('sqlite3', [ + stablePath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.serviceMachineId', 'bad');`, + ]); + + execFileSync('sqlite3', [ + insidersPath, + 'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);', + ]); + execFileSync('sqlite3', [ + insidersPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/accessToken', '${'a'.repeat(60)}');`, + ]); + execFileSync('sqlite3', [ + insidersPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.serviceMachineId', '1234567890abcdef1234567890abcdef');`, + ]); + + const result = autoDetectTokens(); + expect(result.found).toBe(true); + expect(result.dbPath).toBe(insidersPath); + } finally { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + } + }); + + it('should report db_query_failed when the database exists but sqlite cannot query it', () => { + if (process.platform === 'win32') return; + + try { + execFileSync('sqlite3', ['--version'], { stdio: 'ignore' }); + } catch { + return; + } + + const originalHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'query-failed-home'); + process.env.HOME = fakeHome; + + try { + const dbPath = getTokenStorageCandidates()[0]; + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.writeFileSync(dbPath, 'not-a-sqlite-database'); + + const result = autoDetectTokens(); + expect(result.found).toBe(false); + expect(result.reason).toBe('db_query_failed'); + } finally { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + } + }); }); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index bcfc301a..6dd2afee 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -484,6 +484,9 @@ describe('renderCursorHelp', () => { expect(exitCode).toBe(0); expect(logs.some((line) => line.includes('Usage: ccs cursor '))).toBe(true); + expect(logs.some((line) => line.includes('probe Run a live authenticated runtime probe'))).toBe( + true + ); expect( logs.some((line) => line.includes('ccs cursor [claude args]')) ).toBe(true); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index c2bdbd3b..8b3df7b3 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -742,14 +742,27 @@ describe('Message Translation', () => { describe('Request Encoding', () => { describe('generateCursorBody', () => { - it('should encode basic text message', () => { + it('should encode a raw top-level request protobuf for basic text messages', () => { const result = generateCursorBody([{ role: 'user', content: 'Hello' }], 'gpt-4', [], null); + const topLevel = decodeMessage(result); + const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array; + const chatRequest = decodeMessage(requestPayload); + const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) => + decodeMessage(entry.value as Uint8Array) + ); + const decoder = new TextDecoder(); expect(result).toBeInstanceOf(Uint8Array); expect(result.length).toBeGreaterThan(0); + expect(Array.from(result.slice(0, 5))).not.toEqual([0, 0, 0, 1, 107]); + expect(topLevel.has(FIELD.Request.REQUEST)).toBe(true); + expect(encodedMessages).toHaveLength(1); + expect(decoder.decode(encodedMessages[0].get(FIELD.Message.CONTENT)?.[0]?.value as Uint8Array)).toBe( + 'Hello' + ); }); - it('should encode message with tools', () => { + it('should encode message with tools into the raw request payload', () => { const tools = [ { type: 'function' as const, @@ -773,9 +786,14 @@ describe('Request Encoding', () => { tools, null ); + const topLevel = decodeMessage(result); + const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array; + const chatRequest = decodeMessage(requestPayload); expect(result).toBeInstanceOf(Uint8Array); expect(result.length).toBeGreaterThan(0); + expect(topLevel.has(FIELD.Request.REQUEST)).toBe(true); + expect((chatRequest.get(FIELD.Chat.MCP_TOOLS) || []).length).toBe(1); }); it('should preserve flattened tool_result blocks through protobuf encoding', () => { @@ -806,10 +824,7 @@ describe('Request Encoding', () => { ); const body = generateCursorBody(translated.messages, 'gpt-4', [], null); - const frame = parseConnectRPCFrame(Buffer.from(body)); - expect(frame).not.toBeNull(); - - const topLevel = decodeMessage(frame!.payload); + const topLevel = decodeMessage(body); const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array; const chatRequest = decodeMessage(requestPayload); const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) => @@ -831,7 +846,7 @@ describe('Request Encoding', () => { }); describe('Edge cases', () => { - it('should handle malformed frame gracefully', () => { + it('should reject malformed frame headers', async () => { const executor = new CursorExecutor(); // Incomplete frame header (only 3 bytes instead of 5) @@ -841,11 +856,13 @@ describe('Request Encoding', () => { messages: [], }); - // Should return valid response even with malformed input - expect(result.status).toBe(200); + expect(result.status).toBe(502); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('server_error'); + expect(body.error.message).toContain('Truncated Cursor ConnectRPC frame'); }); - it('should handle truncated payload', () => { + it('should reject truncated payloads', async () => { const executor = new CursorExecutor(); // Frame header says payload is 100 bytes but only 5 bytes follow @@ -857,8 +874,10 @@ describe('Request Encoding', () => { messages: [], }); - // Should handle gracefully - expect(result.status).toBe(200); + expect(result.status).toBe(502); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('server_error'); + expect(body.error.message).toContain('Truncated Cursor ConnectRPC frame'); }); it('should handle multi-frame buffer', () => { @@ -1042,6 +1061,29 @@ describe('CursorExecutor', () => { expect(body.error.type).toBe('rate_limit_error'); }); + it('should map unavailable end-stream errors to 503', async () => { + const unavailableFrame = buildFrame( + new TextEncoder().encode( + JSON.stringify({ + error: { + code: 'unavailable', + message: 'upstream down', + }, + }) + ), + 0x02 + ); + + const result = executor.transformProtobufToJSON(unavailableFrame, 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(503); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('api_error'); + expect(body.error.message).toContain('upstream down'); + }); + it('should surface reasoning_content when thinking payload is present', async () => { const textContent = 'Final answer'; const thinkingContent = 'Internal reasoning trail'; @@ -1177,7 +1219,7 @@ describe('CursorExecutor', () => { }); describe('decompressPayload error handling', () => { - it('should return empty buffer on decompression failure', () => { + it('returns an explicit executor error on decompression failure', async () => { // Create invalid gzip data const invalidGzip = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0xff, 0xff]); const frame = new Uint8Array(5 + invalidGzip.length); @@ -1192,13 +1234,15 @@ describe('CursorExecutor', () => { messages: [], }); - // Should handle gracefully and return valid response - expect(result.status).toBe(200); + expect(result.status).toBe(502); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('server_error'); + expect(body.error.message).toContain('decompress'); }); }); describe('error handling', () => { - it('should return empty buffer on decompression failure', () => { + it('returns an explicit error for invalid compressed payloads', async () => { const executor = new CursorExecutor(); // Invalid compressed payload (not actually gzipped) @@ -1217,13 +1261,80 @@ describe('CursorExecutor', () => { const buffer = Buffer.from(frame); - // Should not crash - decompression failure returns empty buffer const result = executor.transformProtobufToJSON(buffer, 'test-model', { messages: [], stream: false, }); + expect(result.status).toBe(502); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('server_error'); + expect(body.error.message).toContain('decompress'); + }); + + it('surfaces first-frame protocol errors in SSE mode without pretending success', async () => { + const executor = new CursorExecutor(); + const invalidGzipPayload = new Uint8Array([1, 2, 3, 4, 5]); + const frame = buildFrame(invalidGzipPayload, 0x01); + + const result = executor.transformProtobufToSSE(frame, 'test-model', { + messages: [], + stream: true, + }); + + expect(result.status).toBe(502); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('server_error'); + expect(body.error.message).toContain('decompress'); + }); + + it('emits an SSE error event without [DONE] when a later frame fails', async () => { + const executor = new CursorExecutor(); + const combined = Buffer.concat([ + buildTextFrame('Before failure'), + buildFrame(new Uint8Array([1, 2, 3, 4, 5]), 0x01), + ]); + + const result = executor.transformProtobufToSSE(combined, 'test-model', { + messages: [], + stream: true, + }); + expect(result.status).toBe(200); + const body = await result.text(); + expect(body).toContain('Before failure'); + expect(body).toContain('event: error'); + expect(body).toContain('"type":"server_error"'); + expect(body).not.toContain('data: [DONE]'); + }); + + it('emits an SSE error event when trailing bytes leave a truncated frame', async () => { + const executor = new CursorExecutor(); + const combined = Buffer.concat([buildTextFrame('Partial success'), Buffer.from([0x00, 0x00, 0x00])]); + + const result = executor.transformProtobufToSSE(combined, 'test-model', { + messages: [], + stream: true, + }); + + expect(result.status).toBe(200); + const body = await result.text(); + expect(body).toContain('Partial success'); + expect(body).toContain('event: error'); + expect(body).toContain('Truncated Cursor ConnectRPC frame'); + expect(body).not.toContain('data: [DONE]'); + }); + + it('returns an explicit error for unknown ConnectRPC frame flags', async () => { + const executor = new CursorExecutor(); + const result = executor.transformProtobufToJSON(buildFrame(new Uint8Array([0x01]), 0x04), 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(502); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('server_error'); + expect(body.error.message).toContain('0x04'); }); it('should log unknown message roles in debug mode', () => { @@ -1418,6 +1529,55 @@ describe('StreamingFrameParser', () => { } }); + it('should surface end-stream JSON errors instead of treating them as gzip', () => { + const parser = new StreamingFrameParser(); + const endStreamError = buildFrame( + new TextEncoder().encode( + JSON.stringify({ + error: { + code: 'invalid_argument', + message: 'parse binary: illegal tag: field no 0 wire type 0', + }, + }) + ), + 0x02 + ); + + const results = parser.push(endStreamError); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('error'); + if (results[0].type === 'error') { + expect(results[0].status).toBe(400); + expect(results[0].errorType).toBe('api_error'); + expect(results[0].message).toContain('illegal tag'); + } + }); + + it('should map unavailable end-stream errors to 503 in the parser', () => { + const parser = new StreamingFrameParser(); + const endStreamError = buildFrame( + new TextEncoder().encode( + JSON.stringify({ + error: { + code: 'unavailable', + message: 'upstream down', + }, + }) + ), + 0x02 + ); + + const results = parser.push(endStreamError); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('error'); + if (results[0].type === 'error') { + expect(results[0].status).toBe(503); + expect(results[0].errorType).toBe('api_error'); + } + }); + it('should parse thinking frames', () => { const parser = new StreamingFrameParser(); const frame = buildThinkingFrame('Think step by step'); @@ -1458,11 +1618,39 @@ describe('StreamingFrameParser', () => { expect(results.length).toBe(1); expect(results[0].type).toBe('error'); if (results[0].type === 'error') { + expect(results[0].status).toBe(502); expect(results[0].errorType).toBe('server_error'); expect(results[0].message).toContain('Malformed protobuf response'); } }); + it('should reject invalid gzip-compressed frames explicitly', () => { + const parser = new StreamingFrameParser(); + const invalidCompressedFrame = buildFrame(new Uint8Array([1, 2, 3, 4, 5]), 0x01); + const results = parser.push(invalidCompressedFrame); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('error'); + if (results[0].type === 'error') { + expect(results[0].status).toBe(502); + expect(results[0].errorType).toBe('server_error'); + expect(results[0].message).toContain('decompress'); + } + }); + + it('should reject unknown ConnectRPC frame flag bits', () => { + const parser = new StreamingFrameParser(); + const results = parser.push(buildFrame(new Uint8Array([0x01]), 0x04)); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('error'); + if (results[0].type === 'error') { + expect(results[0].status).toBe(502); + expect(results[0].errorType).toBe('server_error'); + expect(results[0].message).toContain('0x04'); + } + }); + it('should report hasPartial() correctly', () => { const parser = new StreamingFrameParser(); expect(parser.hasPartial()).toBe(false); @@ -1481,6 +1669,20 @@ describe('StreamingFrameParser', () => { parser2.push(emptyFrame); expect(parser2.hasPartial()).toBe(false); }); + + it('should surface truncated trailing bytes when the stream finishes', () => { + const parser = new StreamingFrameParser(); + parser.push(Buffer.from([0x00, 0x00, 0x00])); + + const results = parser.finish(); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('error'); + if (results[0].type === 'error') { + expect(results[0].status).toBe(502); + expect(results[0].message).toContain('Truncated Cursor ConnectRPC frame'); + } + }); }); describe('decompressPayload', () => { @@ -1496,10 +1698,18 @@ describe('decompressPayload', () => { expect(result).toEqual(errorPayload); }); - it('should return empty buffer on invalid gzip data', () => { + it('should throw on invalid gzip data', () => { const invalidGzip = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05]); - const result = decompressPayload(invalidGzip, 0x01); - expect(result.length).toBe(0); + expect(() => decompressPayload(invalidGzip, 0x01)).toThrow( + 'Failed to decompress Cursor ConnectRPC frame.' + ); + }); + + it('should throw on unknown ConnectRPC frame flags', () => { + const payload = Buffer.from('payload'); + expect(() => decompressPayload(payload, 0x04)).toThrow( + 'Unsupported ConnectRPC frame flags: 0x04' + ); }); it('should decompress valid gzip payload', () => { @@ -1511,12 +1721,12 @@ describe('decompressPayload', () => { expect(result.toString()).toBe('Hello compressed world'); }); - it('should handle GZIP_ALT and GZIP_BOTH flags', () => { + it('should not decompress plain end-stream trailers and should handle compressed end-stream trailers', () => { const zlib = require('zlib'); const original = Buffer.from('test data'); const compressed = zlib.gzipSync(original); - expect(decompressPayload(compressed, 0x02).toString()).toBe('test data'); + expect(decompressPayload(original, 0x02)).toEqual(original); expect(decompressPayload(compressed, 0x03).toString()).toBe('test data'); }); }); diff --git a/tests/unit/cursor/cursor-runtime-probe.test.ts b/tests/unit/cursor/cursor-runtime-probe.test.ts new file mode 100644 index 00000000..d8f83beb --- /dev/null +++ b/tests/unit/cursor/cursor-runtime-probe.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import type { CursorConfig } from '../../../src/config/unified-config-types'; + +let tempDir = ''; +let originalCcsHome: string | undefined; +let originalFetch: typeof globalThis.fetch; + +let setGlobalConfigDir: (dir: string | undefined) => void; +let saveCredentials: (credentials: { + accessToken: string; + machineId: string; + authMethod: 'manual' | 'auto-detect'; + importedAt: string; +}) => void; +let deleteCredentials: () => boolean; +let probeCursorRuntime: (config: CursorConfig) => Promise<{ + ok: boolean; + stage: 'config' | 'auth' | 'daemon' | 'runtime'; + status: number; + duration_ms: number; + error_type?: string | null; + message: string; +}>; + +beforeEach(async () => { + originalCcsHome = process.env.CCS_HOME; + originalFetch = globalThis.fetch; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-runtime-probe-test-')); + process.env.CCS_HOME = tempDir; + + const configManager = await import('../../../src/utils/config-manager'); + setGlobalConfigDir = configManager.setGlobalConfigDir; + setGlobalConfigDir(undefined); + + const cursorAuth = await import('../../../src/cursor/cursor-auth'); + saveCredentials = cursorAuth.saveCredentials; + deleteCredentials = cursorAuth.deleteCredentials; + + const runtimeProbe = await import( + `../../../src/cursor/cursor-runtime-probe?cursor-runtime-probe-test=${Date.now()}` + ); + probeCursorRuntime = runtimeProbe.probeCursorRuntime; + + deleteCredentials(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + setGlobalConfigDir(undefined); + + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('probeCursorRuntime', () => { + it('classifies daemon connection races as daemon-stage failures', async () => { + const port = 23000 + Math.floor(Math.random() * 2000); + + saveCredentials({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: new Date().toISOString(), + }); + + const server = http.createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ service: 'cursor-daemon' })); + setImmediate(() => { + server.close(); + }); + return; + } + + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve())); + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + + if (url.startsWith('https://api2.cursor.sh')) { + return new Response( + JSON.stringify({ + models: [{ name: 'gpt-5.3-codex' }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url.startsWith(`http://127.0.0.1:${port}/v1/chat/completions`)) { + const error = new Error('fetch failed') as Error & { cause?: { code: string } }; + error.cause = { code: 'ECONNREFUSED' }; + throw error; + } + + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof globalThis.fetch; + + const result = await probeCursorRuntime({ + enabled: true, + port, + auto_start: false, + ghost_mode: true, + model: 'gpt-5.3-codex', + } as CursorConfig); + + expect(result.ok).toBe(false); + expect(result.stage).toBe('daemon'); + expect(result.status).toBe(503); + expect(result.error_type).toBe('daemon_unreachable'); + expect(result.message).toContain('unreachable'); + }); +}); diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts new file mode 100644 index 00000000..58cd0db8 --- /dev/null +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -0,0 +1,1127 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { spawn } from 'child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import * as http from 'node:http'; +import { WebSocketServer } from 'ws'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type JsonRpcMessage = Record; + +type MockPageState = { + id: string; + title: string; + currentUrl: string; + readyStateSequence?: string[]; + visibleText?: string; + domSnapshot?: string; + navigate?: Record; + click?: Record< + string, + { + error?: string; + disabled?: boolean; + detached?: boolean; + hidden?: boolean; + requireMouseSequence?: boolean; + requireNativeClick?: boolean; + forbidSyntheticClickEvent?: boolean; + cancelMouseDown?: boolean; + cancelMouseUp?: boolean; + detachAfterMouseDown?: boolean; + mouseSequenceError?: string; + } + >; + screenshot?: { + data?: string; + lastCaptureBeyondViewport?: boolean; + }; + type?: Record< + string, + { + kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable'; + inputType?: string; + value?: string; + expectedValueWhenClearFirst?: string; + expectedValueWhenAppend?: string; + requireFocus?: boolean; + focused?: boolean; + } + >; +}; + +const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); + +function encodeMessage(message: unknown): string { + return `${JSON.stringify(message)}\n`; +} + +function collectResponses( + child: ReturnType, + expectedCount: number +): Promise>> { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const responses: Array> = []; + const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 7000); + + function tryParse(): void { + while (true) { + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + const body = buffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + buffer = buffer.subarray(newlineIndex + 1); + if (!body) { + continue; + } + + responses.push(JSON.parse(body) as Record); + if (responses.length >= expectedCount) { + clearTimeout(timer); + resolve(responses); + return; + } + } + } + + if (!child.stdout) { + clearTimeout(timer); + reject(new Error('MCP child stdout is unavailable')); + return; + } + + child.stdout.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + try { + tryParse(); + } catch (error) { + clearTimeout(timer); + reject(error); + } + }); + + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function getResponseText(message: Record | undefined): string { + const result = (message?.result as { content?: Array<{ text?: string }> }) || {}; + return result.content?.[0]?.text || ''; +} + +function parseJsonArgument(expression: string, key: string): string | undefined { + const marker = `const ${key} = JSON.parse(`; + const start = expression.indexOf(marker); + if (start === -1) { + return undefined; + } + + const quoteStart = start + marker.length; + const quote = expression[quoteStart]; + if (quote !== '"' && quote !== "'") { + return undefined; + } + + let index = quoteStart + 1; + while (index < expression.length) { + if (expression[index] === '\\') { + index += 2; + continue; + } + if (expression[index] === quote) { + const encoded = expression.slice(quoteStart, index + 1); + const decoded = JSON.parse(encoded) as string; + if (!decoded.startsWith('"') && !decoded.startsWith("'")) { + return undefined; + } + return JSON.parse(decoded) as string; + } + index += 1; + } + + return undefined; +} + +function createMockBrowser(pagesInput: MockPageState[]) { + let tempDir = ''; + let httpServer: http.Server | null = null; + let wsServer: WebSocketServer | null = null; + const pageStates = new Map(); + + for (const [index, page] of pagesInput.entries()) { + pageStates.set(`/devtools/page/${index + 1}`, { + visibleText: 'Hello from visible text', + domSnapshot: 'Hello from DOM snapshot', + ...page, + }); + } + + async function start() { + tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-')); + + const port = await new Promise((resolve, reject) => { + httpServer = http.createServer((req, res) => { + if (req.url === '/json/list') { + const address = httpServer?.address(); + const serverPort = address && typeof address !== 'string' ? address.port : 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify( + Array.from(pageStates.entries()).map(([wsPath, page]) => ({ + id: page.id, + type: 'page', + title: page.title, + url: page.currentUrl, + webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, + })) + ) + ); + return; + } + res.writeHead(404); + res.end('not found'); + }); + + httpServer.once('error', reject); + httpServer.listen(0, '127.0.0.1', () => { + const address = httpServer?.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to resolve mock browser server port')); + return; + } + resolve(address.port); + }); + }); + + wsServer = new WebSocketServer({ server: httpServer as http.Server }); + wsServer.on('connection', (socket, request) => { + const page = pageStates.get(request.url || ''); + if (!page) { + socket.close(); + return; + } + + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) as { + id: number; + method: string; + params?: Record; + }; + + function reply(result: unknown): void { + socket.send(JSON.stringify({ id: message.id, result })); + } + + function replyError(errorText: string): void { + socket.send(JSON.stringify({ id: message.id, result: { result: { subtype: 'error', description: errorText } } })); + } + + if (message.method === 'Page.navigate') { + const targetUrl = typeof message.params?.url === 'string' ? message.params.url : ''; + const navigatePlan = page.navigate?.[targetUrl]; + if (navigatePlan?.errorText) { + reply({ frameId: 'frame-1', errorText: navigatePlan.errorText }); + return; + } + if (navigatePlan) { + page.currentUrl = navigatePlan.finalUrl; + page.readyStateSequence = [...(navigatePlan.readyStates || ['loading', 'interactive'])]; + } + reply({ frameId: 'frame-1' }); + return; + } + + if (message.method === 'Page.captureScreenshot') { + if (!page.screenshot) { + reply({ data: '' }); + return; + } + page.screenshot.lastCaptureBeyondViewport = message.params?.captureBeyondViewport === true; + reply({ data: page.screenshot.data || '' }); + return; + } + + if (message.method !== 'Runtime.evaluate') { + return; + } + + const expression = String(message.params?.expression || ''); + + if (expression.includes('document.title') && expression.includes('location.href')) { + reply({ result: { type: 'string', value: JSON.stringify({ title: page.title, url: page.currentUrl }) } }); + return; + } + + if (expression.includes('document.body ? document.body.innerText')) { + reply({ result: { type: 'string', value: page.visibleText || '' } }); + return; + } + + if (expression.includes('document.documentElement ? document.documentElement.outerHTML')) { + reply({ result: { type: 'string', value: page.domSnapshot || '' } }); + return; + } + + if (expression.includes('document.readyState') && expression.includes('location.href')) { + const readyState = page.readyStateSequence?.shift() || 'complete'; + reply({ + result: { + type: 'string', + value: JSON.stringify({ href: page.currentUrl, readyState }), + }, + }); + return; + } + + if (expression.includes('scrollIntoView') && expression.includes('.click()')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const clickPlan = page.click?.[selector]; + const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'"); + const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'"); + const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp; + const attemptedClickEvent = expression.includes("dispatchMouseEvent('click'"); + const readsDispatchResult = expression.includes('const dispatchResult = {'); + const gatesNativeClickOnDispatchResult = expression.includes('if (!dispatchResult.shouldActivate)'); + const checksIsConnectedBeforeNativeClick = expression.includes('if (!element.isConnected)'); + const catchIndex = expression.indexOf('catch (mouseError) {'); + const catchBlockEnd = catchIndex === -1 ? -1 : expression.indexOf('\n }', catchIndex); + const nativeClickIndexes = Array.from(expression.matchAll(/element\.click\(\)/g)).map( + (match) => match.index ?? -1 + ); + const attemptedFallbackClick = nativeClickIndexes.some( + (index) => catchIndex !== -1 && catchBlockEnd !== -1 && index > catchIndex && index < catchBlockEnd + ); + const attemptedNativeClickOutsideCatch = nativeClickIndexes.some( + (index) => + catchIndex === -1 || catchBlockEnd === -1 || index < catchIndex || index > catchBlockEnd + ); + if (!clickPlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (clickPlan.detached && expression.includes('element.isConnected')) { + replyError(`element is detached for selector: ${selector}`); + return; + } + if (clickPlan.disabled) { + replyError(`element is disabled for selector: ${selector}`); + return; + } + if (clickPlan.hidden && expression.includes('getBoundingClientRect')) { + replyError(`element is hidden or not interactable for selector: ${selector}`); + return; + } + if (clickPlan.requireMouseSequence && !attemptedMouseSequence) { + replyError(`mousedown/mouseup required for selector: ${selector}`); + return; + } + if (clickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { + replyError(`synthetic click event forbidden for selector: ${selector}`); + return; + } + if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !readsDispatchResult) { + replyError(`dispatch result must be checked for selector: ${selector}`); + return; + } + if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) { + replyError(`native click must be gated for selector: ${selector}`); + return; + } + if (clickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { + replyError(`connected state must be rechecked for selector: ${selector}`); + return; + } + if (clickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { + replyError(`native click required for selector: ${selector}`); + return; + } + if (clickPlan.mouseSequenceError) { + if (!attemptedMouseSequence) { + replyError(`mousedown/mouseup required for selector: ${selector}`); + return; + } + if (attemptedFallbackClick || attemptedNativeClickOutsideCatch) { + reply({ result: { type: 'string', value: 'ok' } }); + return; + } + replyError(clickPlan.mouseSequenceError); + return; + } + if (clickPlan.error) { + replyError(clickPlan.error); + return; + } + reply({ result: { type: 'string', value: 'ok' } }); + return; + } + + if (expression.includes('focusTarget') && expression.includes('typedLength')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const text = parseJsonArgument(expression, 'text') ?? ''; + const clearFirst = expression.includes('const clearFirst = true'); + const typePlan = page.type?.[selector]; + if (!typePlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (typePlan.kind === 'unsupported') { + replyError(`element is not text-editable for selector: ${selector}`); + return; + } + if (typePlan.kind === 'noneditable') { + replyError(`element is not text-editable for selector: ${selector}`); + return; + } + if (typePlan.requireFocus && !expression.includes('focusTarget(element)')) { + replyError(`focus was not requested for selector: ${selector}`); + return; + } + + const currentValue = typePlan.value || ''; + const expectedValue = clearFirst + ? (typePlan.expectedValueWhenClearFirst ?? text) + : (typePlan.expectedValueWhenAppend ?? `${currentValue}${text}`); + + typePlan.focused = true; + typePlan.value = expectedValue; + reply({ + result: { + type: 'string', + value: JSON.stringify({ + value: expectedValue, + typedLength: expectedValue.length, + }), + }, + }); + return; + } + }); + }); + + const child = spawn('node', [serverPath], { + cwd: tempDir, + env: { + ...process.env, + CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + return child; + } + + async function stop() { + await new Promise((resolve) => { + wsServer?.close(() => resolve()); + if (!wsServer) resolve(); + }); + wsServer = null; + + await new Promise((resolve) => { + httpServer?.close(() => resolve()); + if (!httpServer) resolve(); + }); + httpServer = null; + + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + } + + return { start, stop }; +} + +async function runMcpRequests(pages: MockPageState[], requests: JsonRpcMessage[]) { + const browser = createMockBrowser(pages); + const child = await browser.start(); + + try { + const responsesPromise = collectResponses(child, requests.length + 1); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + + for (const request of requests) { + child.stdin.write(encodeMessage(request)); + } + + return await responsesPromise; + } finally { + child.kill(); + await browser.stop(); + } +} + +describe('ccs-browser MCP server', () => { + afterEach(() => { + // Cleanup is handled per test via runMcpRequests/createMockBrowser. + }); + + it('lists browser tools including navigate, click, type, and screenshot', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], + [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }] + ); + + const tools = (responses.find((message) => message.id === 2)?.result as { + tools: Array<{ + name: string; + description?: string; + inputSchema?: { properties?: Record }; + }>; + }).tools; + + expect(tools.map((tool) => tool.name)).toEqual([ + 'browser_get_session_info', + 'browser_get_url_and_title', + 'browser_get_visible_text', + 'browser_get_dom_snapshot', + 'browser_navigate', + 'browser_click', + 'browser_type', + 'browser_take_screenshot', + ]); + + const clickTool = tools.find((tool) => tool.name === 'browser_click'); + expect(clickTool?.description).toContain('mouse event chain'); + expect(clickTool?.description).not.toContain('synthetic element.click()'); + + for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { + expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ + type: 'integer', + minimum: 0, + }); + } + }); + + it('navigates successfully after readiness polling', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + navigate: { + 'https://example.com/next': { + finalUrl: 'https://example.com/next', + readyStates: ['loading', 'interactive'], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/next' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('pageIndex: 0'); + expect(text).toContain('url: https://example.com/next'); + expect(text).toContain('status: navigated'); + }); + + it( + 'returns a handled error when navigation readiness times out', + async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + navigate: { + 'https://example.com/slow': { + finalUrl: 'https://example.com/', + readyStates: new Array(60).fill('loading'), + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/slow' }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: navigation did not complete'); + }, + 8000 + ); + + it('returns handled errors for missing URL, malformed URL, invalid page index, and out-of-range page index', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { url: 'file:///tmp/example' } }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { pageIndex: 1.5, url: 'https://example.com/next' } }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { pageIndex: 9, url: 'https://example.com/next' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('Browser MCP failed: url is required'); + expect(getResponseText(responses.find((message) => message.id === 3))).toContain( + 'Browser MCP failed: url must be an absolute http or https URL' + ); + expect(getResponseText(responses.find((message) => message.id === 4))).toContain( + 'Browser MCP failed: pageIndex must be a non-negative integer' + ); + expect(getResponseText(responses.find((message) => message.id === 5))).toContain('page index 9 is out of range'); + }); + + it('surfaces Page.navigate CDP failures as handled errors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/start', + navigate: { + 'https://example.com/fail': { + finalUrl: 'https://example.com/start', + errorText: 'net::ERR_ABORTED', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/fail' }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: navigation failed for URL: https://example.com/fail: net::ERR_ABORTED' + ); + }); + + it('treats redirects as successful navigation', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/start', + navigate: { + 'https://example.com/redirect': { + finalUrl: 'https://example.com/final', + readyStates: ['loading', 'interactive'], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/redirect' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('status: navigated'); + expect(text).toContain('url: https://example.com/final'); + }); + + it('clicks matching elements and reports selector failures', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#submit': {}, + '#disabled': { disabled: true }, + '#throws': { error: 'click exploded' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#submit' } }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#missing' } }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#disabled' } }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#throws' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #submit'); + + const selectorMiss = responses.find((message) => message.id === 3); + expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(selectorMiss)).toContain('element not found for selector: #missing'); + + const disabledError = responses.find((message) => message.id === 4); + expect((disabledError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(disabledError)).toContain('element is disabled for selector: #disabled'); + + const pageSideError = responses.find((message) => message.id === 5); + expect((pageSideError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(pageSideError)).toContain('click exploded'); + }); + + it('reports detached and hidden click targets as handled errors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#hidden': { hidden: true }, + '#detached': { detached: true }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#hidden' } }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#detached' } }, + }, + ] + ); + + const hiddenError = responses.find((message) => message.id === 2); + expect((hiddenError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(hiddenError)).toContain('element is hidden or not interactable for selector: #hidden'); + + const detachedError = responses.find((message) => message.id === 3); + expect((detachedError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(detachedError)).toContain('element is detached for selector: #detached'); + }); + + it('uses a mouse sequence when the target requires it', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#menu-trigger': { requireMouseSequence: true }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#menu-trigger' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #menu-trigger'); + }); + + it('preserves mouse sequence preparation without dispatching a synthetic click event', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#click-event': { + requireMouseSequence: true, + forbidSyntheticClickEvent: true, + requireNativeClick: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#click-event' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #click-event'); + }); + + it('preserves native click activation after the mouse sequence', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#native-click': { + requireMouseSequence: true, + requireNativeClick: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#native-click' } }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 2); + expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(clickResponse)).toContain('status: clicked'); + expect(getResponseText(clickResponse)).toContain('selector: #native-click'); + }); + + it('does not force activation when mousedown cancels the interaction', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#cancel-mousedown': { + requireMouseSequence: true, + cancelMouseDown: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#cancel-mousedown' } }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 2); + expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(clickResponse)).toContain('status: clicked'); + expect(getResponseText(clickResponse)).toContain('selector: #cancel-mousedown'); + }); + + it('rechecks connectivity before native activation after the mouse sequence', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#detached-during-click': { + requireMouseSequence: true, + requireNativeClick: true, + detachAfterMouseDown: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#detached-during-click' } }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 2); + expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(clickResponse)).toContain('status: clicked'); + expect(getResponseText(clickResponse)).toContain('selector: #detached-during-click'); + }); + + it('falls back to click when mouse sequence dispatch fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#fallback': { mouseSequenceError: 'mouse dispatch exploded' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#fallback' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #fallback'); + }); + + it('captures screenshots and reports empty payload failures', async () => { + const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + screenshot: screenshotPlan, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: { fullPage: true }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('pageIndex: 0'); + expect(text).toContain('format: png'); + expect(text).toContain('fullPage: true'); + expect(text).toContain('data: c2NyZWVuc2hvdA=='); + expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true); + + const errorResponses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + screenshot: { data: '' }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: {}, + }, + }, + ] + ); + + const errorResponse = errorResponses.find((message) => message.id === 2); + expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(errorResponse)).toContain('Browser MCP failed: screenshot capture failed'); + }); + + it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + type: { + 'input[name="email"]': { + kind: 'input', + inputType: 'email', + value: 'old@example.com', + expectedValueWhenAppend: 'old@example.comhi@example.com', + requireFocus: true, + }, + '#notes': { + kind: 'textarea', + value: 'old note', + expectedValueWhenClearFirst: '', + requireFocus: true, + }, + '#editor': { + kind: 'contenteditable', + value: 'old content', + expectedValueWhenAppend: 'old contentrich text', + requireFocus: true, + }, + '#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' }, + '#plain': { kind: 'noneditable', value: 'plain' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: 'input[name="email"]', text: 'hi@example.com' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#notes', text: '', clearFirst: true }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#editor', text: 'rich text' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#color', text: 'ignored' }, + }, + }, + { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#plain', text: 'ignored' }, + }, + }, + ] + ); + + const typed = getResponseText(responses.find((message) => message.id === 2)); + expect(typed).toContain('status: typed'); + expect(typed).toContain('selector: input[name="email"]'); + expect(typed).toContain('typedLength: 29'); + + const clearFirst = getResponseText(responses.find((message) => message.id === 3)); + expect(clearFirst).toContain('status: typed'); + expect(clearFirst).toContain('selector: #notes'); + expect(clearFirst).toContain('typedLength: 0'); + + const contenteditable = getResponseText(responses.find((message) => message.id === 4)); + expect(contenteditable).toContain('status: typed'); + expect(contenteditable).toContain('selector: #editor'); + expect(contenteditable).toContain('typedLength: 20'); + + const unsupported = responses.find((message) => message.id === 5); + expect((unsupported?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(unsupported)).toContain('element is not text-editable for selector: #color'); + + const nonEditable = responses.find((message) => message.id === 6); + expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(nonEditable)).toContain('element is not text-editable for selector: #plain'); + }); +}); diff --git a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts index 64593411..dba39c83 100644 --- a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts @@ -166,13 +166,13 @@ describe('ccs-websearch MCP server', () => { expect(toolsList?.result).toEqual({ tools: [ - { - name: 'WebSearch', - description: - 'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.', - inputSchema: { - type: 'object', - properties: { + { + name: 'WebSearch', + description: + 'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, SearXNG, DuckDuckGo, then optional legacy CLI fallback.', + inputSchema: { + type: 'object', + properties: { query: { type: 'string', description: diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 80bb74d9..7678bb40 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -12,6 +12,12 @@ import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; const hookPath = join(process.cwd(), 'lib', 'hooks', 'websearch-transformer.cjs'); + +/** + * Neutralise CCS_PROFILE_TYPE so shouldSkipHook() does not short-circuit + * when tests run inside a CCS-managed Claude session (where it is 'account'). + */ +const NEUTRAL_PROFILE_TYPE = ''; type HookOutput = { hookSpecificOutput: { additionalContext: string; @@ -56,6 +62,12 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { results: Array<{ title: string; url: string; description: string }> ) => string; parseRetryAfterSeconds: (rawValue: string) => number | null; + trySearxngSearch: (query: string, timeoutSec?: number) => Promise<{ + content?: string; + error?: string; + statusCode?: number; + success: boolean; + }>; }; function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'failure') { @@ -101,6 +113,7 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail }), env: { ...process.env, + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_WEBSEARCH_ENABLED: '1', CCS_WEBSEARCH_SKIP: '0', CCS_WEBSEARCH_BRAVE: '0', @@ -109,6 +122,7 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail CCS_WEBSEARCH_GEMINI: '0', CCS_WEBSEARCH_GROK: '0', CCS_WEBSEARCH_OPENCODE: '0', + CCS_WEBSEARCH_SEARXNG: '0', CCS_WEBSEARCH_TAVILY: '0', }, }); @@ -154,6 +168,123 @@ describe('websearch-transformer hook helpers', () => { }); }); + it('queries SearXNG JSON endpoint and formats structured results', async () => { + const originalFetch = global.fetch; + const originalEnv = { + CCS_WEBSEARCH_SEARXNG_MAX_RESULTS: process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS, + CCS_WEBSEARCH_SEARXNG_URL: process.env.CCS_WEBSEARCH_SEARXNG_URL, + }; + const requests: string[] = []; + + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/search/'; + process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = '3'; + global.fetch = async (url) => { + requests.push(String(url)); + return { + ok: true, + json: async () => ({ + results: [ + { + title: 'SearXNG Result', + url: 'https://example.com/searxng', + content: 'Result snippet', + }, + ], + }), + } as Response; + }; + + try { + const result = await hook.trySearxngSearch('btc price', 1); + expect(result.success).toBe(true); + expect(result.content).toContain('Provider: SearXNG'); + expect(result.content).toContain('URL: https://example.com/searxng'); + expect(requests).toEqual(['https://search.example.com/search?q=btc+price&format=json']); + } finally { + global.fetch = originalFetch; + if (originalEnv.CCS_WEBSEARCH_SEARXNG_URL === undefined) { + delete process.env.CCS_WEBSEARCH_SEARXNG_URL; + } else { + process.env.CCS_WEBSEARCH_SEARXNG_URL = originalEnv.CCS_WEBSEARCH_SEARXNG_URL; + } + + if (originalEnv.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS === undefined) { + delete process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS; + } else { + process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = + originalEnv.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS; + } + } + }); + + it('rejects SearXNG URLs that include query parameters', async () => { + const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL; + + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/search?format=json'; + + try { + const result = await hook.trySearxngSearch('btc price', 1); + expect(result.success).toBe(false); + expect(result.error).toContain('invalid or not configured'); + } finally { + if (originalUrl === undefined) { + delete process.env.CCS_WEBSEARCH_SEARXNG_URL; + } else { + process.env.CCS_WEBSEARCH_SEARXNG_URL = originalUrl; + } + } + }); + + it('rejects credential-bearing SearXNG URLs', async () => { + const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL; + + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://user:pass@search.example.com'; + + try { + const result = await hook.trySearxngSearch('btc price', 1); + expect(result.success).toBe(false); + expect(result.error).toContain('invalid or not configured'); + } finally { + if (originalUrl === undefined) { + delete process.env.CCS_WEBSEARCH_SEARXNG_URL; + } else { + process.env.CCS_WEBSEARCH_SEARXNG_URL = originalUrl; + } + } + }); + + it('marks SearXNG format-disabled 403 responses as non-retryable failures', async () => { + const originalFetch = global.fetch; + const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL; + + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com'; + global.fetch = async () => + ({ + ok: false, + status: 403, + headers: { get: () => null }, + text: async () => 'format=json disabled by this instance', + }) as Response; + + try { + const result = await hook.trySearxngSearch('btc price', 1); + expect(result.success).toBe(false); + expect(result.statusCode).toBe(403); + expect(result.error).toContain('format=json is disabled'); + expect(hook.classifyProviderFailure(result)).toMatchObject({ + kind: 'fail', + reason: 'non_retryable', + }); + } finally { + global.fetch = originalFetch; + if (originalUrl === undefined) { + delete process.env.CCS_WEBSEARCH_SEARXNG_URL; + } else { + process.env.CCS_WEBSEARCH_SEARXNG_URL = originalUrl; + } + } + }); + it('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => { const html = ` Example title @@ -281,6 +412,85 @@ describe('websearch-transformer hook helpers', () => { expect(output).not.toHaveProperty('additionalContext'); }); + it('falls back from SearXNG parse errors to DuckDuckGo in provider order', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-searxng-fallback-')); + const preloadPath = join(tempDir, 'mock-fetch.cjs'); + const requestLogPath = join(tempDir, 'requests.json'); + const html = ` + Duck title + Duck snippet + `.trim(); + + writeFileSync( + preloadPath, + ` +const fs = require('fs'); +const requestLogPath = ${JSON.stringify(requestLogPath)}; +const html = ${JSON.stringify(html)}; +function record(url) { + const requests = fs.existsSync(requestLogPath) + ? JSON.parse(fs.readFileSync(requestLogPath, 'utf8')) + : []; + requests.push(String(url)); + fs.writeFileSync(requestLogPath, JSON.stringify(requests), 'utf8'); +} +global.fetch = async (url) => { + const resolved = String(url); + record(resolved); + if (resolved.includes('/search?')) { + return { + ok: true, + json: async () => { + throw new Error('invalid json'); + }, + }; + } + return { + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => html, + }; +}; + `.trimStart(), + 'utf8' + ); + + try { + const result = spawnSync('node', ['-r', preloadPath, hookPath], { + encoding: 'utf8', + input: JSON.stringify({ + tool_name: 'WebSearch', + tool_input: { query: 'btc price' }, + }), + env: { + ...process.env, + CCS_WEBSEARCH_ENABLED: '1', + CCS_WEBSEARCH_SKIP: '0', + CCS_WEBSEARCH_BRAVE: '0', + CCS_WEBSEARCH_DUCKDUCKGO: '1', + CCS_WEBSEARCH_EXA: '0', + CCS_WEBSEARCH_GEMINI: '0', + CCS_WEBSEARCH_GROK: '0', + CCS_WEBSEARCH_OPENCODE: '0', + CCS_WEBSEARCH_SEARXNG: '1', + CCS_WEBSEARCH_SEARXNG_URL: 'https://search.example.com', + CCS_WEBSEARCH_TAVILY: '0', + }, + }); + + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout.trim()) as HookOutput; + expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo'); + + const requests = JSON.parse(readFileSync(requestLogPath, 'utf8')) as string[]; + expect(requests[0]).toContain('search.example.com/search?'); + expect(requests[1]).toContain('duckduckgo.com'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('preserves genuine DuckDuckGo zero-result pages as successful empty searches', () => { const result = runHookWithMockedFetch('empty'); @@ -352,6 +562,7 @@ describe('websearch-transformer hook helpers', () => { }), env: { ...process.env, + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'hook-trace-test', @@ -428,6 +639,7 @@ describe('websearch-transformer hook helpers', () => { }), env: { ...process.env, + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_FILE: disallowedTracePath, @@ -509,6 +721,7 @@ global.fetch = async (url) => { }), env: { ...process.env, + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'quota-fallback-test', @@ -623,6 +836,7 @@ global.fetch = async (url) => { }), env: { ...process.env, + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'cooldown-skip-test', @@ -723,6 +937,7 @@ global.fetch = async (url) => { }), env: { ...process.env, + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'transient-retry-test', diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 27d5dbd0..f7d1b626 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -144,7 +144,7 @@ describe('InstanceManager MCP sync', () => { }); }); - it('repairs managed ccs-websearch entries from global config while preserving other instance MCP overrides', () => { + it('repairs managed ccs-websearch and ccs-browser entries from global config while preserving other instance MCP overrides', () => { fs.writeFileSync( path.join(tempRoot, '.claude.json'), JSON.stringify( @@ -156,6 +156,12 @@ describe('InstanceManager MCP sync', () => { args: ['/global/server.cjs'], env: {}, }, + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/global/browser-server.cjs'], + env: {}, + }, shared: { command: 'global-shared' }, }, }, @@ -179,6 +185,12 @@ describe('InstanceManager MCP sync', () => { args: ['/old/server.cjs'], env: {}, }, + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/old/browser-server.cjs'], + env: {}, + }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }, @@ -201,11 +213,51 @@ describe('InstanceManager MCP sync', () => { args: ['/global/server.cjs'], env: {}, }, + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/global/browser-server.cjs'], + env: {}, + }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }); }); + it('preserves existing instance .claude.json permissions when syncing managed MCP servers', () => { + fs.writeFileSync( + path.join(tempRoot, '.claude.json'), + JSON.stringify( + { + mcpServers: { + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/global/browser.cjs'], + env: {}, + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const manager = new InstanceManager(); + const instancePath = manager.getInstancePath('work'); + fs.mkdirSync(instancePath, { recursive: true }); + const instanceClaudeJson = path.join(instancePath, '.claude.json'); + fs.writeFileSync(instanceClaudeJson, JSON.stringify({ existing: true }, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o640, + }); + fs.chmodSync(instanceClaudeJson, 0o640); + + expect(manager.syncMcpServers(instancePath)).toBe(true); + expect(fs.statSync(instanceClaudeJson).mode & 0o777).toBe(0o640); + }); + it('logs warning when global MCP sync fails', () => { fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8'); const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts index 063a8744..5a48af16 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { CodexAdapter } from '../../../src/targets/codex-adapter'; +import { + buildCodexBrowserMcpOverrides, + getCodexBrowserMcpServerName, +} from '../../../src/utils/browser-codex-overrides'; describe('CodexAdapter', () => { const adapter = new CodexAdapter(); @@ -21,6 +25,16 @@ describe('CodexAdapter', () => { ).toEqual(['--search']); }); + test('builds browser MCP overrides with Codex-safe defaults', () => { + const serverName = getCodexBrowserMcpServerName(); + expect(buildCodexBrowserMcpOverrides()).toEqual([ + `mcp_servers.${serverName}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + `mcp_servers.${serverName}.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, + `mcp_servers.${serverName}.enabled=true`, + `mcp_servers.${serverName}.tool_timeout_sec=30`, + ]); + }); + test('translates default-mode reasoning overrides into transient codex config', () => { const args = adapter.buildArgs('default', ['--search'], { profileType: 'default', @@ -40,6 +54,29 @@ describe('CodexAdapter', () => { expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']); }); + test('injects runtime config overrides for native Codex default launches', () => { + const runtimeConfigOverrides = buildCodexBrowserMcpOverrides(); + const args = adapter.buildArgs('default', ['--search'], { + profileType: 'default', + creds: { + profile: 'default', + baseUrl: '', + apiKey: '', + runtimeConfigOverrides, + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, + }); + + expect(args).toEqual([ + ...runtimeConfigOverrides.flatMap((override) => ['-c', override]), + '--search', + ]); + }); + test('rejects default-mode reasoning overrides when codex lacks config override support', () => { expect(() => adapter.buildArgs('default', ['--search'], { @@ -61,6 +98,7 @@ describe('CodexAdapter', () => { }); test('injects transient config overrides for CCS-backed launches', () => { + const runtimeConfigOverrides = buildCodexBrowserMcpOverrides(); const args = adapter.buildArgs('codex', ['--search'], { profileType: 'cliproxy', creds: { @@ -69,6 +107,7 @@ describe('CodexAdapter', () => { apiKey: 'cliproxy-token', model: 'gpt-5.4', reasoningOverride: 'high', + runtimeConfigOverrides, }, binaryInfo: { path: '/tmp/codex', @@ -81,8 +120,9 @@ describe('CodexAdapter', () => { expect(args).toContain('model_provider="ccs_runtime"'); expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"'); expect(args).toContain('model="gpt-5.4"'); + expect(args).toContain(`mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`); expect(args).toContain('model_reasoning_effort="high"'); - expect(args.at(-1)).toBe('--search'); + expect(args[args.length - 1]).toBe('--search'); }); test('fails fast when Codex binary lacks config override support', () => { diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 439e476d..1afdb956 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -162,7 +162,7 @@ process.exit(0); fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('does not preflight native Codex default launches when no runtime overrides are needed', () => { + it('injects browser MCP runtime overrides for native Codex default launches', () => { if (process.platform === 'win32') return; const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { @@ -177,10 +177,23 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls).toEqual([['fix failing tests']]); + expect(calls).toEqual([ + ['-c', 'model="gpt-5"', '--version'], + [ + '-c', + `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + '-c', + `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + 'fix failing tests', + ], + ]); }); - it('ignores off-style CCS_THINKING env overrides for native Codex default mode', () => { + it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => { if (process.platform === 'win32') return; const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { @@ -195,7 +208,20 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls).toEqual([['fix failing tests']]); + expect(calls).toEqual([ + ['-c', 'model="gpt-5"', '--version'], + [ + '-c', + `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + '-c', + `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + 'fix failing tests', + ], + ]); }); for (const versionFlag of ['--version', '-v']) { @@ -291,7 +317,19 @@ process.exit(0); expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], - ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], + [ + '-c', + 'mcp_servers.ccs_browser.command="npx"', + '-c', + 'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]', + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + '-c', + 'model_reasoning_effort="high"', + 'fix failing tests', + ], ]); const loggedEnv = readLoggedCodexEnv(codexEnvLogPath); expect(loggedEnv).toHaveLength(2); @@ -481,7 +519,19 @@ process.exit(0); expect(result.status).toBe(0); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], - ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], + [ + '-c', + 'mcp_servers.ccs_browser.command="npx"', + '-c', + 'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]', + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + '-c', + 'model_reasoning_effort="high"', + 'fix failing tests', + ], ]); }); diff --git a/tests/unit/targets/codex-settings-bridge-launch.test.ts b/tests/unit/targets/codex-settings-bridge-launch.test.ts index 9e520ae0..edec0748 100644 --- a/tests/unit/targets/codex-settings-bridge-launch.test.ts +++ b/tests/unit/targets/codex-settings-bridge-launch.test.ts @@ -126,6 +126,8 @@ exit 0 expect(argsLog).toContain('model_provider="ccs_runtime"'); expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"'); expect(argsLog).toContain('model_reasoning_effort="high"'); + expect(argsLog).toContain('mcp_servers.ccs_browser.command='); + expect(argsLog).toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]'); expect(argsLog).toContain('smoke'); expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token'); }); diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts new file mode 100644 index 00000000..1f42f749 --- /dev/null +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -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 { + 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 { + const deadline = Date.now() + timeoutMs; + + while (Date.now() <= deadline) { + try { + await new Promise((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'); + }); +}); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts new file mode 100644 index 00000000..61052620 --- /dev/null +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawn, spawnSync, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { + const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts'); + const result = spawnSync(process.execPath, [ccsEntry, ...args], { + encoding: 'utf8', + env, + timeout: 8000, + }); + + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +describe('settings profile browser launch', () => { + let tmpHome = ''; + let ccsDir = ''; + let settingsPath = ''; + let fakeClaudePath = ''; + let claudeArgsLogPath = ''; + let claudeEnvLogPath = ''; + let browserProfileDir = ''; + let devtoolsServer: ChildProcess | undefined; + let baseEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + if (process.platform === 'win32') { + return; + } + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-launch-')); + ccsDir = path.join(tmpHome, '.ccs'); + settingsPath = path.join(ccsDir, 'glm.settings.json'); + fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); + claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt'); + claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt'); + browserProfileDir = path.join(tmpHome, 'chrome-user-data'); + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: false', 'image_analysis:', ' enabled: false', ''].join('\n'), + 'utf8' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_MODEL: 'glm-5', + }, + }, + null, + 2 + ) + '\n' + ); + + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh +printf "%s\n" "$@" > "${claudeArgsLogPath}" +{ + printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" + printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" + printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" + printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" +} > "${claudeEnvLogPath}" +exit 0 +`, + { encoding: 'utf8', mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + baseEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CLAUDE_PATH: fakeClaudePath, + CCS_DEBUG: '1', + }; + }); + + afterEach(() => { + if (devtoolsServer) { + devtoolsServer.kill(); + devtoolsServer = undefined; + } + if (process.platform === 'win32') { + return; + } + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('fails before Claude launch when browser reuse cannot resolve DevToolsActivePort', () => { + if (process.platform === 'win32') return; + + fs.mkdirSync(browserProfileDir, { recursive: true }); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_PROFILE_DIR: browserProfileDir, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('DevToolsActivePort'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(false); + }); + + it('passes browser reuse env and steering prompt into the Claude launch', async () => { + if (process.platform === 'win32') return; + + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/browser-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const startDeadline = Date.now() + 5000; + while (!fs.existsSync(mockServerPortPath)) { + if (Date.now() > startDeadline) { + throw new Error('Timed out waiting for mock DevTools server to start'); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/browser-target`, + 'utf8' + ); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_PROFILE_DIR: browserProfileDir, + }); + + expect(result.stderr).not.toContain('Browser MCP is enabled, but CCS could not prepare the local browser tool.'); + expect(result.stderr).not.toContain('could not sync the browser MCP config'); + expect(result.stderr).not.toContain('Chrome reuse metadata not found'); + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain('--append-system-prompt'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index 634c199a..45a9f1a5 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -98,7 +98,49 @@ describe('ClaudeAdapter', () => { expect(env['ANTHROPIC_MODEL']).toBe('claude-opus-4-6'); }); - it('should pass through args unchanged', () => { + it('should append browser steering prompt when browser runtime env exists', () => { + const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose'], { + creds: { + profile: 'gemini', + baseUrl: 'https://api.example.com', + apiKey: 'test-key', + browserRuntimeEnv: { + CCS_BROWSER_USER_DATA_DIR: '/tmp/chrome', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9222', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }, + }, + profileType: 'settings', + }); + + expect(args).toContain('--append-system-prompt'); + expect(args.join(' ')).toContain('prefer the CCS MCP Browser tool'); + }); + + it('should merge browser runtime env into Claude launch env', () => { + const env = adapter.buildEnv( + { + profile: 'gemini', + baseUrl: 'https://api.example.com', + apiKey: 'test-key', + browserRuntimeEnv: { + CCS_BROWSER_USER_DATA_DIR: '/tmp/chrome', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9222', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }, + }, + 'settings' + ); + + expect(env['CCS_BROWSER_USER_DATA_DIR']).toBe('/tmp/chrome'); + expect(env['CCS_BROWSER_DEVTOOLS_WS_URL']).toBe('ws://127.0.0.1/devtools/browser/test'); + }); + + it('should pass through args unchanged when browser runtime env is absent', () => { const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose']); expect(args).toEqual(['-p', 'hello', '--verbose']); }); diff --git a/tests/unit/utils/browser/chrome-reuse.test.ts b/tests/unit/utils/browser/chrome-reuse.test.ts new file mode 100644 index 00000000..e0b8ac8a --- /dev/null +++ b/tests/unit/utils/browser/chrome-reuse.test.ts @@ -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) { + const server = Bun.serve({ + port: 0, + fetch(request: Request) { + if (new URL(request.url).pathname === '/json/version') { + return Response.json(versionPayload); + } + return new Response('not found', { status: 404 }); + }, + }); + servers.push(server); + return server; + } + + async function startFailingDevToolsServer(status: number, body = 'error') { + const server = Bun.serve({ + port: 0, + fetch(request: Request) { + if (new URL(request.url).pathname === '/json/version') { + return new Response(body, { status }); + } + return new Response('not found', { status: 404 }); + }, + }); + servers.push(server); + return server; + } + + async function startMalformedJsonDevToolsServer() { + const server = Bun.serve({ + port: 0, + fetch(request: Request) { + if (new URL(request.url).pathname === '/json/version') { + return new Response('{invalid-json', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('not found', { status: 404 }); + }, + }); + servers.push(server); + return server; + } + + async function reserveClosedPort(): Promise { + const server = Bun.serve({ + port: 0, + fetch() { + return new Response('ok'); + }, + }); + const port = server.port; + server.stop(true); + return port; + } + + afterEach(() => { + for (const server of servers) { + server.stop(true); + } + servers = []; + + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; + + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + }); + + it('uses explicit profile-dir before the default path and resolves the websocket target', async () => { + const explicitProfileDir = createTempDir('ccs-chrome-explicit-'); + const defaultProfileDir = createTempDir('ccs-chrome-default-'); + const server = await startDevToolsServer({ + Browser: 'Chrome/136.0.0.0', + webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/target-1', + }); + + writeDevToolsActivePort(explicitProfileDir, `${server.port}\n/devtools/browser/from-explicit`); + + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir: explicitProfileDir, + }); + + expect(runtimeEnv).toEqual({ + CCS_BROWSER_USER_DATA_DIR: explicitProfileDir, + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: String(server.port), + CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${server.port}`, + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/target-1', + }); + expect(fs.existsSync(path.join(defaultProfileDir, 'DevToolsActivePort'))).toBe(false); + }); + + it('uses an explicit devtools port override when metadata is missing', async () => { + const profileDir = createTempDir('ccs-chrome-explicit-port-'); + const server = await startDevToolsServer({ + Browser: 'Chrome/136.0.0.0', + webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/explicit-port', + }); + + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir, + devtoolsPort: String(server.port), + }); + + expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_PORT).toBe(String(server.port)); + expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_WS_URL).toBe( + 'ws://127.0.0.1/devtools/browser/explicit-port' + ); + }); + + it('throws a clear error when DevToolsActivePort metadata is missing', async () => { + const profileDir = createTempDir('ccs-chrome-missing-metadata-'); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}` + ); + }); + + it('throws a clear error when DevToolsActivePort metadata is invalid', async () => { + const profileDir = createTempDir('ccs-chrome-invalid-metadata-'); + writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target'); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}` + ); + }); + + it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => { + const profileDir = createTempDir('ccs-chrome-unreachable-'); + const port = await reserveClosedPort(); + writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}` + ); + }); + + it('throws a clear error when the DevTools endpoint is reachable without a websocket target', async () => { + const profileDir = createTempDir('ccs-chrome-missing-ws-'); + const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' }); + writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version` + ); + }); + + it('throws a clear error when the DevTools endpoint returns a non-200 response', async () => { + const profileDir = createTempDir('ccs-chrome-bad-status-'); + const server = await startFailingDevToolsServer(500); + writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` + ); + }); + + it('throws a clear error when the DevTools endpoint returns malformed JSON', async () => { + const profileDir = createTempDir('ccs-chrome-bad-json-'); + const server = await startMalformedJsonDevToolsServer(); + writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` + ); + }); + + it('resolves platform default Chrome user-data-dir paths', () => { + 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}` + ); + }); +}); diff --git a/tests/unit/utils/browser/claude-tool-args.test.ts b/tests/unit/utils/browser/claude-tool-args.test.ts new file mode 100644 index 00000000..57077dff --- /dev/null +++ b/tests/unit/utils/browser/claude-tool-args.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test'; +import { appendBrowserToolArgs } from '../../../../src/utils/browser/claude-tool-args'; + +const BROWSER_STEERING_PROMPT = + 'For DOM/screenshots/elements/page actions, prefer the CCS MCP Browser tool, reuse the configured running Chrome context whenever possible, and if the tool or context is unavailable, explain that clearly instead of pretending page state is available.'; + +describe('appendBrowserToolArgs', () => { + it('appends the browser steering prompt when it is missing', () => { + expect(appendBrowserToolArgs(['navigate'])).toEqual([ + 'navigate', + '--append-system-prompt', + BROWSER_STEERING_PROMPT, + ]); + }); + + it('does not append the prompt when it already exists in equals form', () => { + expect( + appendBrowserToolArgs([ + 'navigate', + `--append-system-prompt=${BROWSER_STEERING_PROMPT}`, + ]) + ).toEqual(['navigate', `--append-system-prompt=${BROWSER_STEERING_PROMPT}`]); + }); + + it('does not append the prompt when it already exists in split-flag form', () => { + expect( + appendBrowserToolArgs([ + 'navigate', + '--append-system-prompt', + BROWSER_STEERING_PROMPT, + ]) + ).toEqual(['navigate', '--append-system-prompt', BROWSER_STEERING_PROMPT]); + }); + + it('inserts the prompt before the end-of-options terminator', () => { + expect(appendBrowserToolArgs(['--', 'take screenshot'])).toEqual([ + '--append-system-prompt', + BROWSER_STEERING_PROMPT, + '--', + 'take screenshot', + ]); + }); +}); diff --git a/tests/unit/utils/browser/mcp-installer.test.ts b/tests/unit/utils/browser/mcp-installer.test.ts new file mode 100644 index 00000000..58ab3b23 --- /dev/null +++ b/tests/unit/utils/browser/mcp-installer.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as browserInstaller from '../../../../src/utils/browser/mcp-installer'; +import { + ensureBrowserMcp, + ensureBrowserMcpConfig, + ensureBrowserMcpOrThrow, + getBrowserMcpServerName, + getBrowserMcpServerPath, + syncBrowserMcpToConfigDir, + uninstallBrowserMcp, +} from '../../../../src/utils/browser'; + +describe('ensureBrowserMcp', () => { + let tempHome: string | undefined; + let originalCcsHome: string | undefined; + + function setupTempHome(): string { + const nextTempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-mcp-')); + tempHome = nextTempHome; + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = nextTempHome; + return nextTempHome; + } + + function getManagedConfig() { + return { + type: 'stdio', + command: 'node', + args: [getBrowserMcpServerPath()], + env: { + NODE_PATH: path.join(process.cwd(), 'node_modules'), + }, + }; + } + + afterEach(() => { + mock.restore(); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + + tempHome = undefined; + originalCcsHome = undefined; + }); + + it('installs the bundled browser MCP server and preserves existing user mcpServers entries', () => { + setupTempHome(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync( + claudeUserConfigPath, + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['some-server'] }, + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(ensureBrowserMcp()).toBe(true); + expect(fs.existsSync(getBrowserMcpServerPath())).toBe(true); + + const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { + mcpServers: Record; + }; + + expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] }); + expect(config.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig()); + }); + + it('preserves the existing ~/.claude.json permissions when provisioning browser MCP', () => { + setupTempHome(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync(claudeUserConfigPath, JSON.stringify({ existing: true }, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); + fs.chmodSync(claudeUserConfigPath, 0o600); + + expect(ensureBrowserMcpConfig()).toBe(true); + expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600); + }); + + it('copies the bundled browser MCP artifact while preserving source permissions', () => { + setupTempHome(); + + const bundledServerPath = path.join( + process.cwd(), + 'lib', + 'mcp', + 'ccs-browser-server.cjs' + ); + const originalMode = fs.statSync(bundledServerPath).mode & 0o777; + + expect(ensureBrowserMcp()).toBe(true); + expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode); + }); + + it('reconciles installed browser MCP permissions when contents already match', () => { + setupTempHome(); + + const bundledServerPath = path.join( + process.cwd(), + 'lib', + 'mcp', + 'ccs-browser-server.cjs' + ); + const originalMode = fs.statSync(bundledServerPath).mode & 0o777; + + expect(ensureBrowserMcp()).toBe(true); + fs.chmodSync(getBrowserMcpServerPath(), 0o600); + + expect(ensureBrowserMcp()).toBe(true); + expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode); + }); + + it('removes the managed browser runtime while preserving unrelated server entries', () => { + setupTempHome(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync( + claudeUserConfigPath, + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['some-server'] }, + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(ensureBrowserMcp()).toBe(true); + + const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work'); + fs.mkdirSync(instancePath, { recursive: true }); + fs.writeFileSync( + path.join(instancePath, '.claude.json'), + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['instance-server'] }, + [getBrowserMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] }, + }, + otherKey: 'keep-me', + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(uninstallBrowserMcp()).toBe(true); + expect(fs.existsSync(getBrowserMcpServerPath())).toBe(false); + + const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { + mcpServers: Record; + }; + expect(globalConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['some-server'] }, + }); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') + ) as { + otherKey: string; + mcpServers: Record; + }; + expect(instanceConfig.otherKey).toBe('keep-me'); + expect(instanceConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['instance-server'] }, + }); + }); + + it('syncs the managed browser MCP entry into an instance config dir', () => { + setupTempHome(); + + fs.writeFileSync( + path.join(tempHome as string, '.claude.json'), + JSON.stringify( + { + mcpServers: { + [getBrowserMcpServerName()]: getManagedConfig(), + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work'); + fs.mkdirSync(instancePath, { recursive: true }); + + expect(syncBrowserMcpToConfigDir(instancePath)).toBe(true); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') + ) as { + mcpServers: Record; + }; + expect(instanceConfig.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig()); + }); + + it('throws when the bundled browser MCP runtime cannot be prepared', () => { + setupTempHome(); + + const ensureSpy = spyOn(browserInstaller, 'ensureBrowserMcp').mockReturnValue(false); + + expect(() => ensureBrowserMcpOrThrow()).toThrow( + 'Browser MCP is enabled, but CCS could not prepare the local browser tool.' + ); + expect(ensureSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index b00908fe..49a7190f 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -30,6 +30,7 @@ let baselineSighupListeners: Array<(...args: unknown[]) => void> = []; let originalCcsHome: string | undefined; let originalCcsClaudePath: string | undefined; let originalDisableAutoUpdater: string | undefined; +let originalClaudeConfigDir: string | undefined; const realSpawn = childProcess.spawn.bind(childProcess); const realSpawnSync = childProcess.spawnSync.bind(childProcess); const realExecSync = childProcess.execSync.bind(childProcess); @@ -124,6 +125,20 @@ preferences: fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8'); } +function writeConfigWithWebSearchSettings(yamlBody: string): void { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-env-')); + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const yaml = `version: 8 +preferences: + auto_update: true +websearch: +${yamlBody} +`; + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8'); +} + let execClaude: typeof import('../../../src/utils/shell-executor').execClaude; let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv; let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor; @@ -151,10 +166,17 @@ describe('CLAUDECODE environment stripping', () => { beforeEach(() => { spawnCalls.length = 0; process.env.CCS_QUIET = '1'; + + // Save original env values for restoration in afterEach originalCcsHome = process.env.CCS_HOME; originalCcsClaudePath = process.env.CCS_CLAUDE_PATH; originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + + // Clear CCS-managed env vars that leak from host sessions delete process.env.DISABLE_AUTOUPDATER; + delete process.env.CLAUDE_CONFIG_DIR; + baselineSigintListeners = process.listeners('SIGINT'); baselineSigtermListeners = process.listeners('SIGTERM'); baselineSighupListeners = process.listeners('SIGHUP'); @@ -175,6 +197,8 @@ describe('CLAUDECODE environment stripping', () => { } else { delete process.env.DISABLE_AUTOUPDATER; } + if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + else delete process.env.CLAUDE_CONFIG_DIR; for (const listener of process.listeners('SIGINT')) { if (!baselineSigintListeners.includes(listener)) { @@ -220,7 +244,7 @@ describe('CLAUDECODE environment stripping', () => { const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; expect(env).toBeDefined(); expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); - expect(env.CCS_WEBSEARCH_SKIP).toBe('1'); + expect(env.CCS_WEBSEARCH_ENABLED || env.CCS_WEBSEARCH_SKIP).toBeDefined(); }); it('execClaude keeps behavior when CLAUDECODE is absent', () => { @@ -262,6 +286,29 @@ describe('CLAUDECODE environment stripping', () => { expect(env.DISABLE_AUTOUPDATER).toBeUndefined(); }); + it('execClaude overrides stale inherited WebSearch provider flags with config-derived values', () => { + writeConfigWithWebSearchSettings(` enabled: true + providers: + duckduckgo: + enabled: true + searxng: + enabled: false + url: '' +`); + process.env.CCS_WEBSEARCH_SEARXNG = '1'; + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com'; + process.env.CCS_WEBSEARCH_SKIP = '1'; + + execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'settings' }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.CCS_WEBSEARCH_ENABLED).toBe('1'); + expect(env.CCS_WEBSEARCH_SKIP).toBe('0'); + expect(env.CCS_WEBSEARCH_DUCKDUCKGO).toBe('1'); + expect(env.CCS_WEBSEARCH_SEARXNG).toBe('0'); + }); + it('execClaude normalizes shared plugin metadata before default-profile launch', () => { const normalizeSpy = spyOn( SharedManager.prototype, diff --git a/tests/unit/utils/image-analysis/claude-tool-args.test.ts b/tests/unit/utils/image-analysis/claude-tool-args.test.ts index 8f62322b..a95c19c9 100644 --- a/tests/unit/utils/image-analysis/claude-tool-args.test.ts +++ b/tests/unit/utils/image-analysis/claude-tool-args.test.ts @@ -36,4 +36,47 @@ describe('appendThirdPartyImageAnalysisToolArgs', () => { 'extra', ]); }); + + // File mode: --append-system-prompt-file when user passes --append-system-prompt-file + + it('uses --append-system-prompt-file when user passes --append-system-prompt-file', () => { + const result = appendThirdPartyImageAnalysisToolArgs([ + '-p', + 'describe', + '--append-system-prompt-file', + '/tmp/user-prompt.txt', + ]); + + expect(result).toContain('--append-system-prompt-file'); + expect(result).not.toContain('--append-system-prompt'); + const fileFlags = result.filter((arg) => arg === '--append-system-prompt-file'); + expect(fileFlags.length).toBeGreaterThanOrEqual(2); + }); + + it('uses --append-system-prompt-file when user passes equals form', () => { + const result = appendThirdPartyImageAnalysisToolArgs([ + '-p', + 'describe', + '--append-system-prompt-file=/tmp/user-prompt.txt', + ]); + + expect(result).not.toContain('--append-system-prompt'); + const fileFlags = result.filter( + (arg) => arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=') + ); + expect(fileFlags.length).toBeGreaterThanOrEqual(2); + }); + + it('does not treat unrelated user prompt files as the managed CCS steering prompt', () => { + const result = appendThirdPartyImageAnalysisToolArgs([ + '-p', + 'describe', + '--append-system-prompt-file', + '/tmp/user-ccs-prompt-image-analysis-tool-notes.txt', + ]); + + const filePaths = result.filter((arg, index) => result[index - 1] === '--append-system-prompt-file'); + expect(filePaths).toContain('/tmp/user-ccs-prompt-image-analysis-tool-notes.txt'); + expect(filePaths.some((filePath) => filePath.endsWith('/ccs-prompt-image-analysis-tool.txt'))).toBe(true); + }); }); diff --git a/tests/unit/utils/prompt-injection-strategy.test.ts b/tests/unit/utils/prompt-injection-strategy.test.ts new file mode 100644 index 00000000..e2ccdb34 --- /dev/null +++ b/tests/unit/utils/prompt-injection-strategy.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + buildInlineSteeringArg, + buildFileSteeringArg, + buildSteeringArg, + detectPromptInjectionMode, + getManagedPromptFilePath, + hasManagedPromptFileArg, + PROMPT_FLAG_INLINE, + PROMPT_FLAG_FILE, +} from '../../../src/utils/prompt-injection-strategy'; + +let originalCcsHome: string | undefined; +let tempHome: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-prompt-strategy-')); + process.env.CCS_HOME = tempHome; +}); + +afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); +}); + +describe('detectPromptInjectionMode', () => { + it('returns inline when no prompt flags present', () => { + expect(detectPromptInjectionMode(['-p', 'hello'])).toBe('inline'); + }); + + it('returns inline when only --append-system-prompt is present', () => { + expect(detectPromptInjectionMode(['--append-system-prompt', 'test'])).toBe('inline'); + }); + + it('returns inline when --append-system-prompt equals form is present', () => { + expect(detectPromptInjectionMode(['--append-system-prompt=test'])).toBe('inline'); + }); + + it('returns file when --append-system-prompt-file is present', () => { + expect(detectPromptInjectionMode(['--append-system-prompt-file', '/tmp/p.txt'])).toBe('file'); + }); + + it('returns file when --append-system-prompt-file equals form is present', () => { + expect(detectPromptInjectionMode(['--append-system-prompt-file=/tmp/p.txt'])).toBe('file'); + }); + + it('returns file even when --append-system-prompt is also present', () => { + expect( + detectPromptInjectionMode([ + '--append-system-prompt', + 'inline-text', + '--append-system-prompt-file', + '/tmp/p.txt', + ]) + ).toBe('file'); + }); +}); + +describe('buildInlineSteeringArg', () => { + it('returns inline flag and prompt text', () => { + expect(buildInlineSteeringArg({promptContent: 'hello world'})).toEqual(['--append-system-prompt', 'hello world']); + }); +}); + +describe('buildFileSteeringArg', () => { + it('returns file flag and writes the prompt into the isolated CCS home', () => { + const result = buildFileSteeringArg({ + promptFileName: 'ccs-test-prompt.txt', + promptContent: 'hello world', + }); + + expect(result[0]).toBe('--append-system-prompt-file'); + expect(result[1]).toBe(path.join(tempHome, '.ccs', 'prompts', 'ccs-test-prompt.txt')); + expect(fs.readFileSync(result[1], 'utf8')).toBe('hello world'); + }); +}); + +describe('hasManagedPromptFileArg', () => { + it('returns true for the exact CCS-managed prompt path', () => { + expect( + hasManagedPromptFileArg({ + args: [PROMPT_FLAG_FILE, getManagedPromptFilePath('ccs-test')], + promptName: 'ccs-test', + }) + ).toBe(true); + }); + + it('returns false for unrelated user files that only contain the prompt name', () => { + expect( + hasManagedPromptFileArg({ + args: [PROMPT_FLAG_FILE, '/tmp/user-ccs-test-notes.txt'], + promptName: 'ccs-test', + }) + ).toBe(false); + }); +}); + +describe('buildSteeringArg', () => { + it('delegates to inline in inline mode', () => { + expect( + buildSteeringArg({ + args: [PROMPT_FLAG_INLINE], + promptName: 'ignored.txt', + promptContent: 'hello', + }) + ).toEqual(['--append-system-prompt', 'hello']); + }); + + it('delegates to file in file mode', () => { + const result = buildSteeringArg({ + args: [PROMPT_FLAG_FILE], + promptName: 'ccs-test', + promptContent: 'hello', + }); + expect(result[0]).toBe('--append-system-prompt-file'); + expect(result[1]).toBe(getManagedPromptFilePath('ccs-test')); + }); +}); diff --git a/tests/unit/utils/ui.test.js b/tests/unit/utils/ui.test.js index 24a04aee..daaaea17 100644 --- a/tests/unit/utils/ui.test.js +++ b/tests/unit/utils/ui.test.js @@ -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; + } + } + }); }); }); }); diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts index 9f902b80..59d484c0 100644 --- a/tests/unit/utils/websearch/claude-tool-args.test.ts +++ b/tests/unit/utils/websearch/claude-tool-args.test.ts @@ -5,7 +5,7 @@ const STEERING_PROMPT = 'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.'; describe('appendThirdPartyWebSearchToolArgs', () => { - it('appends native WebSearch suppression and steering prompt when no tool flags are present', () => { + it('appends native WebSearch suppression and inline steering prompt when no prompt flags are present', () => { expect(appendThirdPartyWebSearchToolArgs(['smoke'])).toEqual([ 'smoke', '--disallowedTools', @@ -129,4 +129,44 @@ describe('appendThirdPartyWebSearchToolArgs', () => { STEERING_PROMPT, ]); }); + + // File mode: --append-system-prompt-file when user passes --append-system-prompt-file + + it('uses --append-system-prompt-file when user passes --append-system-prompt-file', () => { + const result = appendThirdPartyWebSearchToolArgs([ + 'smoke', + '--append-system-prompt-file', + '/tmp/user-prompt.txt', + ]); + expect(result).toContain('--disallowedTools'); + expect(result).toContain('WebSearch'); + const fileFlags = result.filter((arg) => arg === '--append-system-prompt-file'); + expect(fileFlags.length).toBeGreaterThanOrEqual(2); + // No inline flag should be present + expect(result).not.toContain('--append-system-prompt'); + }); + + it('uses --append-system-prompt-file when user passes --append-system-prompt-file= form', () => { + const result = appendThirdPartyWebSearchToolArgs([ + 'smoke', + '--append-system-prompt-file=/tmp/user-prompt.txt', + ]); + const fileFlags = result.filter( + (arg) => arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=') + ); + expect(fileFlags.length).toBeGreaterThanOrEqual(2); + expect(result).not.toContain('--append-system-prompt'); + }); + + it('does not treat unrelated user prompt files as the managed CCS steering prompt', () => { + const result = appendThirdPartyWebSearchToolArgs([ + 'smoke', + '--append-system-prompt-file', + '/tmp/user-ccs-prompt-websearch-tool-notes.txt', + ]); + + const filePaths = result.filter((arg, index) => result[index - 1] === '--append-system-prompt-file'); + expect(filePaths).toContain('/tmp/user-ccs-prompt-websearch-tool-notes.txt'); + expect(filePaths.some((filePath) => filePath.endsWith('/ccs-prompt-websearch-tool.txt'))).toBe(true); + }); }); diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index b696278d..a24c76a6 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -93,6 +93,136 @@ describe('websearch readiness', () => { expect(readiness.message).toContain('Exa'); }); + it('treats SearXNG as ready when enabled with a valid URL', () => { + const readiness = buildWebSearchReadiness(true, [ + provider({ + id: 'searxng', + name: 'SearXNG', + enabled: true, + available: true, + detail: 'Configured (5 results)', + }), + provider({ + id: 'duckduckgo', + name: 'DuckDuckGo', + enabled: false, + available: false, + detail: 'Built-in (5 results)', + }), + ]); + + expect(readiness.readiness).toBe('ready'); + expect(readiness.message).toContain('SearXNG'); + }); + + it('marks SearXNG as unavailable when config uses a query-bearing endpoint URL', () => { + const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ + enabled: true, + providers: { + exa: { enabled: false, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + searxng: { + enabled: true, + url: 'https://search.example.com/search?format=json', + max_results: 5, + }, + duckduckgo: { enabled: false, max_results: 5 }, + gemini: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + } as any); + const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ + exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, + tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, + brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, + }); + const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); + const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + + try { + const providers = getWebSearchCliProviders(); + const searxng = providers.find((entry) => entry.id === 'searxng'); + + expect(searxng?.enabled).toBe(true); + expect(searxng?.available).toBe(false); + expect(searxng?.detail).toContain('Set a valid SearXNG base URL'); + } finally { + getConfigSpy.mockRestore(); + apiKeySpy.mockRestore(); + geminiStatusSpy.mockRestore(); + geminiAuthSpy.mockRestore(); + grokStatusSpy.mockRestore(); + opencodeStatusSpy.mockRestore(); + } + }); + + it('marks SearXNG as unavailable when enabled with a blank URL', () => { + const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ + enabled: true, + providers: { + exa: { enabled: false, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + searxng: { + enabled: true, + url: '', + max_results: 5, + }, + duckduckgo: { enabled: false, max_results: 5 }, + gemini: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + } as any); + const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ + exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, + tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, + brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, + }); + const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); + const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + + try { + const providers = getWebSearchCliProviders(); + const searxng = providers.find((entry) => entry.id === 'searxng'); + + expect(searxng?.enabled).toBe(true); + expect(searxng?.available).toBe(false); + expect(searxng?.detail).toContain('Set a valid SearXNG base URL'); + } finally { + getConfigSpy.mockRestore(); + apiKeySpy.mockRestore(); + geminiStatusSpy.mockRestore(); + geminiAuthSpy.mockRestore(); + grokStatusSpy.mockRestore(); + opencodeStatusSpy.mockRestore(); + } + }); + it('treats cooled-down providers as temporarily unavailable in readiness status', () => { const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-')); const statePath = join(tempHome, '.ccs', 'cache', 'websearch-provider-state.json'); @@ -122,8 +252,9 @@ describe('websearch readiness', () => { providers: { exa: { enabled: true, max_results: 5 }, tavily: { enabled: false, max_results: 5 }, - duckduckgo: { enabled: false, max_results: 5 }, brave: { enabled: false, max_results: 5 }, + searxng: { enabled: false, url: '', max_results: 5 }, + duckduckgo: { enabled: false, max_results: 5 }, gemini: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, diff --git a/tests/unit/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts index fda059b0..5934ed9c 100644 --- a/tests/unit/web-server/cursor-routes.test.ts +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -55,6 +55,15 @@ let getTokenStoragePath: () => string; let getDaemonStartPreconditionError: ( input: { enabled: boolean; authenticated: boolean; tokenExpired?: boolean } ) => { status: number; error: string } | null; +let getAutoDetectFailureStatus: ( + reason?: + | 'db_not_found' + | 'sqlite_unavailable' + | 'db_query_failed' + | 'access_token_not_found' + | 'machine_id_not_found' + | 'invalid_token_format' +) => number; function seedCursorConfig(overrides: { enabled?: boolean; @@ -107,6 +116,7 @@ beforeAll(async () => { const cursorRoutesModule = await import('../../../src/web-server/routes/cursor-routes'); getDaemonStartPreconditionError = cursorRoutesModule.getDaemonStartPreconditionError; + getAutoDetectFailureStatus = cursorRoutesModule.getAutoDetectFailureStatus; const app = express(); app.use(express.json()); @@ -207,6 +217,10 @@ describe('Cursor Routes Logic', () => { }); describe('HTTP contracts', () => { + it('maps invalid_token_format auto-detect failures to HTTP 400', () => { + expect(getAutoDetectFailureStatus('invalid_token_format')).toBe(400); + }); + it('GET /api/cursor/status returns current state', async () => { const res = await fetch(`${baseUrl}/api/cursor/status`); expect(res.status).toBe(200); @@ -280,9 +294,11 @@ describe('Cursor Routes Logic', () => { }); expect(res.status).toBe(404); - const json = (await res.json()) as { error?: string }; + const json = (await res.json()) as { error?: string; reason?: string }; expect(typeof json.error).toBe('string'); expect(json.error?.length).toBeGreaterThan(0); + expect(json.reason).toBe('db_not_found'); + expect(json.error).not.toContain(isolatedHome); } finally { if (originalHome !== undefined) { process.env.HOME = originalHome; @@ -344,6 +360,87 @@ describe('Cursor Routes Logic', () => { } }); + it('POST /api/cursor/auth/auto-detect returns 503 when sqlite3 is unavailable', async () => { + if (process.platform === 'win32') { + return; + } + + const originalHome = process.env.HOME; + const originalPath = process.env.PATH; + const originalSqliteBin = process.env.CCS_CURSOR_SQLITE_BIN; + const fakeHome = path.join(tempDir, 'sqlite-missing-home'); + process.env.HOME = fakeHome; + process.env.CCS_CURSOR_SQLITE_BIN = 'definitely-missing-sqlite3'; + + try { + const dbPath = getTokenStoragePath(); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.writeFileSync(dbPath, ''); + + const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, { + method: 'POST', + }); + + expect(res.status).toBe(503); + const json = (await res.json()) as { reason?: string; db_path?: string }; + expect(json.reason).toBe('sqlite_unavailable'); + expect(json.db_path).toBeUndefined(); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + + if (originalPath !== undefined) { + process.env.PATH = originalPath; + } else { + delete process.env.PATH; + } + if (originalSqliteBin !== undefined) { + process.env.CCS_CURSOR_SQLITE_BIN = originalSqliteBin; + } else { + delete process.env.CCS_CURSOR_SQLITE_BIN; + } + } + }); + + it('POST /api/cursor/auth/auto-detect returns 500 when the database exists but cannot be queried', async () => { + if (process.platform === 'win32') { + return; + } + + const sqliteCheck = spawnSync('sqlite3', ['--version'], { stdio: 'ignore' }); + if (sqliteCheck.status !== 0) { + return; + } + + const originalHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'query-failed-home'); + process.env.HOME = fakeHome; + + try { + const dbPath = getTokenStoragePath(); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.writeFileSync(dbPath, 'not-a-sqlite-database'); + + const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, { + method: 'POST', + }); + + expect(res.status).toBe(500); + const json = (await res.json()) as { reason?: string; db_path?: string }; + expect(json.reason).toBe('db_query_failed'); + expect(json.db_path).toBeUndefined(); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + } + }); + it('POST /api/cursor/daemon/start returns 400 when integration is disabled', async () => { seedCursorConfig({ enabled: false }); seedCredentials(false); @@ -412,5 +509,28 @@ describe('Cursor Routes Logic', () => { expect(json.models.length).toBeGreaterThan(0); expect(json.current).toBe('gpt-5.3-codex'); }); + + it('POST /api/cursor/probe returns auth failure when credentials are missing', async () => { + const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' }); + expect(res.status).toBe(401); + + const json = (await res.json()) as { ok?: boolean; stage?: string; error_type?: string }; + expect(json.ok).toBe(false); + expect(json.stage).toBe('auth'); + expect(json.error_type).toBe('authentication_error'); + }); + + it('POST /api/cursor/probe returns daemon failure when daemon is down and auto_start is false', async () => { + seedCursorConfig({ enabled: true, auto_start: false }); + seedCredentials(false); + + const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' }); + expect(res.status).toBe(503); + + const json = (await res.json()) as { ok?: boolean; stage?: string; error_type?: string }; + expect(json.ok).toBe(false); + expect(json.stage).toBe('daemon'); + expect(json.error_type).toBe('daemon_not_running'); + }); }); }); diff --git a/tests/unit/web-server/websearch-routes.test.ts b/tests/unit/web-server/websearch-routes.test.ts index f900abd0..d8ae5024 100644 --- a/tests/unit/web-server/websearch-routes.test.ts +++ b/tests/unit/web-server/websearch-routes.test.ts @@ -288,6 +288,113 @@ describe('websearch routes', () => { expect(config.global_env?.env.EXA_API_KEY).toBe('exa-secret-12345678'); }); + it('persists searxng provider settings and clamps max_results', async () => { + const response = await putWebsearch({ + providers: { + searxng: { + enabled: true, + url: 'https://search.example.com', + max_results: 99, + }, + }, + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.websearch.providers.searxng).toEqual({ + enabled: true, + url: 'https://search.example.com', + max_results: 10, + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.websearch?.providers?.searxng).toEqual({ + enabled: true, + url: 'https://search.example.com', + max_results: 10, + }); + }); + + it('normalizes searxng endpoint-style urls to the instance base URL', async () => { + const response = await putWebsearch({ + providers: { + searxng: { + enabled: true, + url: 'https://search.example.com/custom/search/', + max_results: 5, + }, + }, + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.websearch.providers.searxng).toEqual({ + enabled: true, + url: 'https://search.example.com/custom', + max_results: 5, + }); + }); + + it('allows clearing a searxng url back to blank', async () => { + mutateUnifiedConfig((config) => { + config.websearch = { + enabled: true, + providers: { + ...config.websearch?.providers, + searxng: { + enabled: false, + url: 'https://search.example.com/custom', + max_results: 5, + }, + }, + }; + }); + + const response = await putWebsearch({ + providers: { + searxng: { + enabled: false, + url: '', + max_results: 5, + }, + }, + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.websearch.providers.searxng).toEqual({ + enabled: false, + url: '', + max_results: 5, + }); + }); + + it('sanitizes credential-bearing searxng urls out of the GET response', async () => { + mutateUnifiedConfig((config) => { + config.websearch = { + enabled: true, + providers: { + ...config.websearch?.providers, + searxng: { + enabled: true, + url: 'https://user:pass@search.example.com/search', + max_results: 5, + }, + }, + }; + }); + + const response = await fetch(`${baseUrl}/api/websearch`); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.providers.searxng).toEqual({ + enabled: true, + url: '', + max_results: 5, + }); + }); + it('rejects non-object request bodies', async () => { const response = await putWebsearch('[]'); @@ -357,4 +464,66 @@ describe('websearch routes', () => { expect(await response.json()).toEqual({ error: invalidPayload.error }); } }); + + it('rejects non-string searxng url values', async () => { + const response = await putWebsearch({ + providers: { + searxng: { + url: 123, + }, + }, + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for providers.searxng.url. Must be a string.', + }); + }); + + it('rejects searxng urls with query parameters', async () => { + const response = await putWebsearch({ + providers: { + searxng: { + url: 'https://search.example.com/search?format=json', + }, + }, + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: + 'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.', + }); + }); + + it('rejects credential-bearing searxng urls', async () => { + const response = await putWebsearch({ + providers: { + searxng: { + url: 'https://user:pass@search.example.com', + }, + }, + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: + 'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.', + }); + }); + + it('rejects non-number searxng max_results values', async () => { + const response = await putWebsearch({ + providers: { + searxng: { + max_results: '5', + }, + }, + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for providers.searxng.max_results. Must be a number.', + }); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 895dabca..bd3f4d58 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,7 +42,10 @@ "forceConsistentCasingInFileNames": true, // Performance - "incremental": true + "incremental": true, + + // Runtime type declarations + "types": ["node", "bun"] }, "include": [ "src/**/*" diff --git a/ui/src/components/account/flow-viz/index.tsx b/ui/src/components/account/flow-viz/index.tsx index 8d9e4d61..fff6933a 100644 --- a/ui/src/components/account/flow-viz/index.tsx +++ b/ui/src/components/account/flow-viz/index.tsx @@ -22,6 +22,8 @@ import { FlowVizHeader } from './flow-viz-header'; // Re-export types for backward compatibility export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './types'; +const SHOW_PAUSED_STORAGE_KEY = 'ccs-auth-monitor-show-paused'; + export function AccountFlowViz({ providerData, onBack, @@ -32,7 +34,13 @@ export function AccountFlowViz({ const svgRef = useRef(null); const [hoveredAccount, setHoveredAccount] = useState(null); const [showDetails, setShowDetails] = useState(false); - const [showPausedAccounts, setShowPausedAccounts] = useState(true); + const [showPausedAccounts, setShowPausedAccounts] = useState(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem(SHOW_PAUSED_STORAGE_KEY); + return saved !== null ? saved === 'true' : true; + } + return true; + }); const [paths, setPaths] = useState([]); const { privacyMode } = usePrivacy(); @@ -168,7 +176,11 @@ export function AccountFlowViz({ pausedAccountsCount={pausedAccountsCount} onTogglePausedAccounts={() => { setHoveredAccount(null); - setShowPausedAccounts(!showPausedAccounts); + const newValue = !showPausedAccounts; + setShowPausedAccounts(newValue); + if (typeof window !== 'undefined') { + localStorage.setItem(SHOW_PAUSED_STORAGE_KEY, String(newValue)); + } }} hasCustomPositions={hasCustomPositions && hasVisibleCustomPositions} onResetPositions={resetPositions} diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index bd1e14dd..b653dde7 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -78,6 +78,63 @@ function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): s } } +function formatGeminiTokenType(tokenType: string | null | undefined): string | null { + if (!tokenType) return null; + + switch (tokenType.trim().toLowerCase()) { + case 'requests': + return 'Requests'; + case 'input': + return 'Input tokens'; + case 'output': + return 'Output tokens'; + default: + return tokenType + .split(/[\s_-]+/g) + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + } +} + +function formatGeminiBucketLabel(label: string): string { + switch (label) { + case 'Gemini Flash Lite Series': + return 'Flash Lite'; + case 'Gemini Flash Series': + return 'Flash'; + case 'Gemini Pro Series': + return 'Pro'; + default: + return label; + } +} + +function formatGeminiBucketModels(modelIds: string[] | undefined): string | null { + const uniqueModelIds = Array.from(new Set((modelIds || []).filter(Boolean))); + return uniqueModelIds.length > 0 ? uniqueModelIds.join(', ') : null; +} + +function formatGeminiRemainingAmount( + remainingAmount: number | null | undefined, + tokenType: string | null | undefined +): string | null { + if (remainingAmount === null || remainingAmount === undefined) return null; + + const formattedAmount = remainingAmount.toLocaleString(); + switch (tokenType?.trim().toLowerCase()) { + case 'requests': + return `${formattedAmount} requests remaining`; + case 'input': + return `${formattedAmount} input tokens remaining`; + case 'output': + return `${formattedAmount} output tokens remaining`; + default: + return `${formattedAmount} remaining`; + } +} + function renderEntitlementRows(entitlement: ProviderEntitlementEvidence | undefined) { if (!entitlement) return null; @@ -301,6 +358,14 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime); const hasEntitlementTier = !!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown'; + const distinctTokenTypes = Array.from( + new Set( + quota.buckets + .map((bucket) => formatGeminiTokenType(bucket.tokenType)) + .filter((tokenType): tokenType is string => !!tokenType) + ) + ); + const sharedTokenType = distinctTokenTypes.length === 1 ? distinctTokenTypes[0] : null; return (
@@ -317,28 +382,53 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro {quota.creditBalance.toLocaleString()}
)} -

Buckets:

- {quota.buckets.map((b) => ( -
-
- - {b.label} - {b.tokenType ? ` (${b.tokenType})` : ''} - - {b.remainingPercent}% -
- {((b.remainingAmount !== null && b.remainingAmount !== undefined) || b.resetTime) && ( -
- - {b.remainingAmount !== null && b.remainingAmount !== undefined - ? `${b.remainingAmount.toLocaleString()} remaining` - : ''} - - {formatAbsoluteResetTime(b.resetTime) ?? ''} +
+

Model quotas:

+ {sharedTokenType && ( +

+ All buckets report {sharedTokenType} +

+ )} +
+ {quota.buckets.map((bucket) => { + const bucketTokenType = sharedTokenType ? null : formatGeminiTokenType(bucket.tokenType); + const bucketModels = formatGeminiBucketModels(bucket.modelIds); + const remainingAmountLabel = formatGeminiRemainingAmount( + bucket.remainingAmount, + bucket.tokenType + ); + + return ( +
+
+
+
+ + {formatGeminiBucketLabel(bucket.label)} + + {bucketTokenType && ( + + {bucketTokenType} + + )} +
+ {bucketModels && ( +
+ {bucketModels} +
+ )} +
+ {bucket.remainingPercent}%
- )} -
- ))} + {(remainingAmountLabel || bucket.resetTime) && ( +
+ {remainingAmountLabel ?? ''} + {formatAbsoluteResetTime(bucket.resetTime) ?? ''} +
+ )} +
+ ); + })} {!hasBucketResetTime && }
); diff --git a/ui/src/hooks/use-cursor.ts b/ui/src/hooks/use-cursor.ts index 8e34c6a7..0b67bf3c 100644 --- a/ui/src/hooks/use-cursor.ts +++ b/ui/src/hooks/use-cursor.ts @@ -57,6 +57,35 @@ interface CursorAuthResult { message: string; } +export interface CursorProbeResult { + ok: boolean; + stage: 'config' | 'auth' | 'daemon' | 'runtime'; + status: number; + duration_ms: number; + model?: string; + error_type?: string | null; + message: string; +} + +function isCursorProbeResult(value: unknown): value is CursorProbeResult { + if (!value || typeof value !== 'object') return false; + + const candidate = value as Partial; + return ( + typeof candidate.message === 'string' && + typeof candidate.stage === 'string' && + typeof candidate.status === 'number' && + typeof candidate.duration_ms === 'number' && + typeof candidate.ok === 'boolean' + ); +} + +function getProbeErrorMessage(value: unknown): string | null { + if (!value || typeof value !== 'object' || !('error' in value)) return null; + const candidate = value as { error?: unknown }; + return typeof candidate.error === 'string' ? candidate.error : null; +} + async function fetchCursorStatus(): Promise { const res = await fetch(withApiBase('/cursor/status')); if (!res.ok) throw new Error('Failed to fetch cursor status'); @@ -144,6 +173,24 @@ async function stopCursorDaemon(): Promise<{ success: boolean; error?: string }> return res.json(); } +async function probeCursorRuntime(): Promise { + const res = await fetch(withApiBase('/cursor/probe'), { method: 'POST' }); + const payload = await res.json().catch(() => null); + + if (isCursorProbeResult(payload)) { + return payload; + } + + return { + ok: false, + stage: 'runtime', + status: res.status, + duration_ms: 0, + error_type: 'runtime_error', + message: getProbeErrorMessage(payload) ?? 'Failed to run live probe', + }; +} + export function useCursor() { const queryClient = useQueryClient(); @@ -205,6 +252,14 @@ export function useCursor() { onSuccess: invalidateCursorQueries, }); + const probeMutation = useMutation({ + mutationFn: probeCursorRuntime, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['cursor-status'] }); + queryClient.invalidateQueries({ queryKey: ['cursor-models'] }); + }, + }); + return useMemo( () => ({ status: statusQuery.data, @@ -214,6 +269,7 @@ export function useCursor() { config: configQuery.data, configLoading: configQuery.isLoading, + refetchConfig: configQuery.refetch, models: modelsQuery.data?.models ?? [], currentModel: modelsQuery.data?.current ?? null, @@ -248,6 +304,12 @@ export function useCursor() { stopDaemon: stopDaemonMutation.mutate, stopDaemonAsync: stopDaemonMutation.mutateAsync, isStoppingDaemon: stopDaemonMutation.isPending, + + runProbe: probeMutation.mutate, + runProbeAsync: probeMutation.mutateAsync, + isRunningProbe: probeMutation.isPending, + probeResult: probeMutation.data, + resetProbe: probeMutation.reset, }), [ statusQuery.data, @@ -256,6 +318,7 @@ export function useCursor() { statusQuery.refetch, configQuery.data, configQuery.isLoading, + configQuery.refetch, modelsQuery.data, modelsQuery.isLoading, rawSettingsQuery.data, @@ -281,6 +344,11 @@ export function useCursor() { stopDaemonMutation.mutate, stopDaemonMutation.mutateAsync, stopDaemonMutation.isPending, + probeMutation.mutate, + probeMutation.mutateAsync, + probeMutation.isPending, + probeMutation.data, + probeMutation.reset, ] ); } diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 0c2bee8b..67e00c03 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1325,6 +1325,23 @@ const resources = { rawChanged: 'Raw settings changed externally. Refresh and retry.', failedSaveRaw: 'Failed to save raw settings', savedAll: 'Cursor configuration saved', + liveProbe: 'Live Probe', + runLiveProbe: 'Run Live Probe', + rerunLiveProbe: 'Re-run Live Probe', + probing: 'Running probe...', + probeNotRun: 'Probe not run yet', + probeLocalReadinessHint: + 'Local readiness checks can pass while live runtime access still fails. Use the live probe to verify the full Cursor path.', + probeStage: 'Stage', + probeHttpStatus: 'HTTP Status', + probeDuration: 'Duration', + probeModel: 'Model', + probeMessage: 'Message', + probeSucceeded: 'Probe succeeded', + probeFailed: 'Probe failed', + probeSaveFirst: 'Save or discard Cursor changes before running the live probe', + refreshStatus: 'Refresh Cursor status', + refreshConfiguration: 'Refresh Cursor configuration', }, droidPage: { loadingDiagnostics: 'Loading Droid diagnostics...', @@ -2658,6 +2675,23 @@ const resources = { rawChanged: '原始设置已被外部修改,请刷新后重试。', failedSaveRaw: '保存原始设置失败', savedAll: 'Cursor 配置已保存', + liveProbe: '实时探测', + runLiveProbe: '运行实时探测', + rerunLiveProbe: '重新运行实时探测', + probing: '正在运行探测...', + probeNotRun: '尚未运行探测', + probeLocalReadinessHint: + '本地就绪检查可能通过,但实时运行链路仍可能失败。请使用实时探测验证完整的 Cursor 路径。', + probeStage: '阶段', + probeHttpStatus: 'HTTP 状态', + probeDuration: '耗时', + probeModel: '模型', + probeMessage: '消息', + probeSucceeded: '探测成功', + probeFailed: '探测失败', + probeSaveFirst: '请先保存或放弃 Cursor 更改,再运行实时探测', + refreshStatus: '刷新 Cursor 状态', + refreshConfiguration: '刷新 Cursor 配置', }, droidPage: { loadingDiagnostics: '加载 Droid 诊断中...', @@ -4085,6 +4119,23 @@ const resources = { rawChanged: 'Cài đặt thô đã thay đổi bên ngoài. Làm mới và thử lại.', failedSaveRaw: 'Không lưu được cài đặt thô', savedAll: 'Đã lưu cấu hình Cursor', + liveProbe: 'Kiểm tra trực tiếp', + runLiveProbe: 'Chạy kiểm tra trực tiếp', + rerunLiveProbe: 'Chạy lại kiểm tra trực tiếp', + probing: 'Đang kiểm tra...', + probeNotRun: 'Chưa chạy kiểm tra', + probeLocalReadinessHint: + 'Các kiểm tra sẵn sàng cục bộ có thể đạt, nhưng luồng chạy thực tế vẫn có thể lỗi. Hãy dùng kiểm tra trực tiếp để xác minh toàn bộ đường đi của Cursor.', + probeStage: 'Giai đoạn', + probeHttpStatus: 'Trạng thái HTTP', + probeDuration: 'Thời lượng', + probeModel: 'Mô hình', + probeMessage: 'Thông điệp', + probeSucceeded: 'Kiểm tra thành công', + probeFailed: 'Kiểm tra thất bại', + probeSaveFirst: 'Hãy lưu hoặc bỏ các thay đổi Cursor trước khi chạy kiểm tra trực tiếp', + refreshStatus: 'Làm mới trạng thái Cursor', + refreshConfiguration: 'Làm mới cấu hình Cursor', }, droidPage: { loadingDiagnostics: 'Đang tải chẩn đoán Droid...', @@ -5520,6 +5571,23 @@ const resources = { rawChanged: '生の設定が外部で変更されました。更新して再試行してください。', failedSaveRaw: '生の設定の保存に失敗しました', savedAll: 'Cursor設定を保存しました', + liveProbe: 'ライブプローブ', + runLiveProbe: 'ライブプローブを実行', + rerunLiveProbe: 'ライブプローブを再実行', + probing: 'プローブを実行中...', + probeNotRun: 'まだプローブを実行していません', + probeLocalReadinessHint: + 'ローカルの準備チェックが通っても、実際のランタイム経路は失敗する場合があります。ライブプローブで Cursor の完全な経路を確認してください。', + probeStage: '段階', + probeHttpStatus: 'HTTP ステータス', + probeDuration: '所要時間', + probeModel: 'モデル', + probeMessage: 'メッセージ', + probeSucceeded: 'プローブ成功', + probeFailed: 'プローブ失敗', + probeSaveFirst: 'ライブプローブを実行する前に Cursor の変更を保存するか破棄してください', + refreshStatus: 'Cursor の状態を更新', + refreshConfiguration: 'Cursor の設定を更新', }, droidPage: { loadingDiagnostics: 'Droid診断を読み込み中...', diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index e788ecb0..54d9ae86 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -62,6 +62,32 @@ interface RawSettingsParseResult { error?: string; } +function buildProbeSnapshotKey( + status?: { + enabled?: boolean; + authenticated?: boolean; + token_expired?: boolean; + daemon_running?: boolean; + port?: number; + ghost_mode?: boolean; + }, + config?: { + model?: string; + auto_start?: boolean; + } +): string { + return JSON.stringify({ + enabled: status?.enabled ?? null, + authenticated: status?.authenticated ?? null, + token_expired: status?.token_expired ?? null, + daemon_running: status?.daemon_running ?? null, + port: status?.port ?? null, + ghost_mode: status?.ghost_mode ?? null, + auto_start: config?.auto_start ?? null, + model: config?.model ?? null, + }); +} + function buildConfigDraft(config?: { port?: number; auto_start?: boolean; @@ -266,6 +292,7 @@ export function CursorPage() { statusLoading, refetchStatus, config, + refetchConfig, updateConfigAsync, isUpdatingConfig, models, @@ -284,6 +311,10 @@ export function CursorPage() { isStartingDaemon, stopDaemonAsync, isStoppingDaemon, + runProbeAsync, + isRunningProbe, + probeResult, + resetProbe, } = useCursor(); const [configDraft, setConfigDraft] = useState(() => buildConfigDraft()); @@ -293,6 +324,9 @@ export function CursorPage() { const [manualAuthOpen, setManualAuthOpen] = useState(false); const [manualToken, setManualToken] = useState(''); const [manualMachineId, setManualMachineId] = useState(''); + const [probeSnapshotKey, setProbeSnapshotKey] = useState(() => + probeResult ? buildProbeSnapshotKey(status, config) : null + ); const pristineConfigDraft = buildConfigDraft(config); @@ -318,6 +352,14 @@ export function CursorPage() { const isRawJsonValid = rawParseResult.isValid; const hasChanges = configDirty || rawConfigDirty; const canSave = !rawConfigDirty || (rawSettingsReady && isRawJsonValid); + const currentProbeSnapshotKey = buildProbeSnapshotKey(status, config); + const visibleProbeResult = + probeResult && + !hasChanges && + probeSnapshotKey !== null && + probeSnapshotKey === currentProbeSnapshotKey + ? probeResult + : null; const orderedModels = useMemo(() => { const seen = new Set(); const sorted = [...models].sort((a, b) => a.name.localeCompare(b.name)); @@ -349,6 +391,16 @@ export function CursorPage() { setConfigDirty(true); }; + const clearProbeState = () => { + resetProbe(); + setProbeSnapshotKey(null); + }; + + const resetConfigDraft = (nextConfig = config) => { + setConfigDraft(buildConfigDraft(nextConfig)); + setConfigDirty(false); + }; + const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired); const integrationBadge = useMemo( () => @@ -395,6 +447,7 @@ export function CursorPage() { haiku_model: effectiveHaikuModel || undefined, }) ); + clearProbeState(); if (!suppressSuccessToast) { toast.success(t('cursorPage.savedConfig')); } @@ -500,6 +553,7 @@ export function CursorPage() { const handleToggleEnabled = async (enabled: boolean) => { try { await updateConfigAsync({ enabled }); + clearProbeState(); toast.success( enabled ? t('cursorPage.integrationEnabled') : t('cursorPage.integrationDisabled') ); @@ -511,6 +565,7 @@ export function CursorPage() { const handleAutoDetectAuth = async () => { try { await autoDetectAuthAsync(); + clearProbeState(); toast.success(t('cursorPage.credentialsImported')); } catch (error) { toast.error((error as Error).message || t('cursorPage.autoDetectFailed')); @@ -528,6 +583,7 @@ export function CursorPage() { accessToken: manualToken.trim(), machineId: manualMachineId.trim(), }); + clearProbeState(); toast.success(t('cursorPage.credentialsImported')); setManualAuthOpen(false); setManualToken(''); @@ -544,6 +600,7 @@ export function CursorPage() { toast.error(result.error || t('cursorPage.failedStartDaemon')); return; } + clearProbeState(); toast.success( result.pid ? t('cursorPage.daemonStartedWithPid', { pid: result.pid }) @@ -561,12 +618,34 @@ export function CursorPage() { toast.error(result.error || t('cursorPage.failedStopDaemon')); return; } + clearProbeState(); toast.success(t('cursorPage.daemonStopped')); } catch (error) { toast.error((error as Error).message || t('cursorPage.failedStopDaemon')); } }; + const handleRunProbe = async () => { + if (hasChanges) { + toast.error(t('cursorPage.probeSaveFirst')); + return; + } + + try { + const result = await runProbeAsync(); + const refreshedStatus = await refetchStatus(); + setProbeSnapshotKey(buildProbeSnapshotKey(refreshedStatus.data ?? status, config)); + if (result.ok) { + toast.success(t('cursorPage.probeSucceeded')); + return; + } + + toast.error(result.message || t('cursorPage.probeFailed')); + } catch (error) { + toast.error((error as Error).message || t('cursorPage.probeFailed')); + } + }; + const handleSaveRawSettings = async ({ suppressSuccessToast = false, }: { suppressSuccessToast?: boolean } = {}) => { @@ -586,6 +665,7 @@ export function CursorPage() { expectedMtime: rawSettings?.mtime, }); setRawConfigDirty(false); + clearProbeState(); if (!suppressSuccessToast) { toast.success(t('cursorPage.rawSaved')); } @@ -625,10 +705,15 @@ export function CursorPage() { } }; - const handleHeaderRefresh = () => { + const handleHeaderRefresh = async () => { setRawConfigDirty(false); - refetchStatus(); - refetchRawSettings(); + clearProbeState(); + const [, refreshedConfig] = await Promise.all([ + refetchStatus(), + refetchConfig(), + refetchRawSettings(), + ]); + resetConfigDraft(refreshedConfig.data ?? config); }; return ( @@ -658,6 +743,8 @@ export function CursorPage() { className="h-8 w-8" onClick={() => refetchStatus()} disabled={statusLoading} + aria-label={t('cursorPage.refreshStatus')} + title={t('cursorPage.refreshStatus')} > @@ -710,6 +797,72 @@ export function CursorPage() { /> +
+
+
+ + + {t('cursorPage.liveProbe')} + +
+ + {visibleProbeResult + ? visibleProbeResult.ok + ? t('cursorPage.probeSucceeded') + : t('cursorPage.probeFailed') + : t('cursorPage.probeNotRun')} + +
+ + {visibleProbeResult ? ( +
+
+ {t('cursorPage.probeStage')} + {visibleProbeResult.stage} +
+
+ + {t('cursorPage.probeHttpStatus')} + + {visibleProbeResult.status} +
+
+ {t('cursorPage.probeDuration')} + {visibleProbeResult.duration_ms} ms +
+ {visibleProbeResult.model ? ( +
+ {t('cursorPage.probeModel')} + + {visibleProbeResult.model} + +
+ ) : null} +
+ {t('cursorPage.probeMessage')} +

+ {visibleProbeResult.message} +

+
+
+ ) : ( +

{t('cursorPage.probeNotRun')}

+ )} + +

+ {t('cursorPage.probeLocalReadinessHint')} +

+
+

{t('cursorPage.actions')} @@ -763,6 +916,25 @@ export function CursorPage() { {t('cursorPage.manualAuthImport')} + + {status?.daemon_running ? (