From 2cc92e8bb80ba7239bb66b8cd24e0cb4c6ff0627 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 07:16:50 +0200 Subject: [PATCH 01/42] test(ci): isolate CCS-managed env vars from host session When tests run inside a CCS-managed Claude session, host env vars (CLAUDE_CONFIG_DIR, CCS_PROFILE_TYPE) leak into subprocess spawns and fixture setup, causing 9 test failures: - persist-command-handler: reads real symlinked settings.json instead of temp fixture because CLAUDE_CONFIG_DIR overrides scoped CCS_HOME - websearch-transformer: hook silently exits via shouldSkipHook() because CCS_PROFILE_TYPE=account triggers native_account_profile skip - claudecode-env-stripping: normalizeSharedPluginMetadataPaths receives leaked CLAUDE_CONFIG_DIR instead of undefined for default profiles Fix: clear CCS-managed env vars in beforeEach and neutralize CCS_PROFILE_TYPE in subprocess env blocks. Built [OnSteroids](https://onsteroids.ai) --- tests/unit/commands/persist-command-handler.test.ts | 1 + tests/unit/hooks/websearch-transformer.test.ts | 6 ++++++ tests/unit/utils/claudecode-env-stripping.test.ts | 5 +++++ 3 files changed, 12 insertions(+) diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index 6235be55..8f235714 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -73,6 +73,7 @@ function stubProcessExit(): void { beforeEach(async () => { tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-')); originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; originalProcessExit = process.exit; originalFsOpen = fs.promises.open; originalFsRename = fs.promises.rename; diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 80bb74d9..42e7e3be 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -101,6 +101,7 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail }), env: { ...process.env, + CCS_PROFILE_TYPE: '', CCS_WEBSEARCH_ENABLED: '1', CCS_WEBSEARCH_SKIP: '0', CCS_WEBSEARCH_BRAVE: '0', @@ -352,6 +353,7 @@ describe('websearch-transformer hook helpers', () => { }), env: { ...process.env, + CCS_PROFILE_TYPE: '', CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'hook-trace-test', @@ -428,6 +430,7 @@ describe('websearch-transformer hook helpers', () => { }), env: { ...process.env, + CCS_PROFILE_TYPE: '', CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_FILE: disallowedTracePath, @@ -509,6 +512,7 @@ global.fetch = async (url) => { }), env: { ...process.env, + CCS_PROFILE_TYPE: '', CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'quota-fallback-test', @@ -623,6 +627,7 @@ global.fetch = async (url) => { }), env: { ...process.env, + CCS_PROFILE_TYPE: '', CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'cooldown-skip-test', @@ -723,6 +728,7 @@ global.fetch = async (url) => { }), env: { ...process.env, + CCS_PROFILE_TYPE: '', CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'transient-retry-test', diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index b00908fe..4c3e3dd0 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); @@ -154,7 +155,9 @@ describe('CLAUDECODE environment stripping', () => { originalCcsHome = process.env.CCS_HOME; originalCcsClaudePath = process.env.CCS_CLAUDE_PATH; originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; 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 +178,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)) { From eec8c8b4a3288ec29e246aac58785f642618a48f Mon Sep 17 00:00:00 2001 From: buiducnhat Date: Sat, 11 Apr 2026 16:43:59 +0700 Subject: [PATCH 02/42] feat(websearch): add SearXNG provider support and configuration - Updated websearch.md to include SearXNG in the configuration guide. - Implemented SearXNG JSON API integration in websearch-transformer.cjs. - Enhanced unified-config-loader.ts to support SearXNG settings. - Defined SearXNG configuration types in unified-config-types.ts. - Updated hook-env.ts to manage SearXNG environment variables. - Modified status.ts to include SearXNG in web search provider checks. - Added SearXNG routes and validation in websearch-routes.ts. - Implemented SearXNG provider tests in websearch-transformer.test.ts. - Updated readiness tests to account for SearXNG in status.test.ts. - Enhanced websearch routes tests to validate SearXNG settings. - Added SearXNG configuration fields in the UI settings. --- docs/websearch.md | 73 ++++---- lib/hooks/websearch-transformer.cjs | 120 +++++++++++++ src/config/unified-config-loader.ts | 30 +++- src/config/unified-config-types.ts | 31 +++- src/utils/websearch/hook-env.ts | 6 + src/utils/websearch/status.ts | 55 ++++-- src/utils/websearch/types.ts | 6 +- src/web-server/health/websearch-checks.ts | 2 +- src/web-server/routes/websearch-routes.ts | 35 ++++ .../unit/hooks/websearch-transformer.test.ts | 162 ++++++++++++++++++ tests/unit/utils/websearch/status.test.ts | 25 ++- .../unit/web-server/websearch-routes.test.ts | 57 ++++++ .../settings/sections/websearch/index.tsx | 43 ++++- ui/src/pages/settings/types.ts | 6 +- 14 files changed, 588 insertions(+), 63 deletions(-) diff --git a/docs/websearch.md b/docs/websearch.md index 054c75d3..ded2e1b9 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,8 @@ 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 SearXNG base URL (for example `https://search.example.com`) when SearXNG is enabled - 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 +100,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 +132,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 +165,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..d1fae51e 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,20 @@ 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); + return parsed.toString().replace(/\/+$/, ''); + } catch { + return ''; + } +} + function buildPrompt(providerId, query) { const config = PROVIDER_CONFIG[providerId]; const parts = [ @@ -569,6 +584,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 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 +1005,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 +1399,5 @@ module.exports = { tryTavilySearch, tryDuckDuckGoSearch, tryBraveSearch, + trySearxngSearch, }; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index b0c3ecfd..e7a6a0bc 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -407,14 +407,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: 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 +1098,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 +1138,12 @@ export function getWebSearchConfig(): { max_results: config.websearch?.providers?.brave?.max_results ?? 5, }; + const searxngConfig = { + enabled: config.websearch?.providers?.searxng?.enabled ?? false, + url: 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 +1167,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 +1180,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/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index d5e69018..d2b5f85c 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -61,6 +61,12 @@ export function getWebSearchHookEnv(): Record { env.CCS_WEBSEARCH_BRAVE_MAX_RESULTS = String(wsConfig.providers.brave.max_results || 5); } + if (wsConfig.providers?.searxng?.enabled && wsConfig.providers.searxng.url?.trim()) { + env.CCS_WEBSEARCH_SEARXNG = '1'; + env.CCS_WEBSEARCH_SEARXNG_URL = wsConfig.providers.searxng.url.trim(); + 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..e0b9c36d 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -28,6 +28,20 @@ function hasEnvValue(name: string): boolean { return (process.env[name] || '').trim().length > 0; } +function hasValidSearxngUrl(url: string | undefined): boolean { + const normalized = String(url || '').trim(); + if (!normalized) { + return false; + } + + try { + const parsed = new URL(normalized); + return Boolean(parsed.protocol && parsed.host); + } catch { + return false; + } +} + function getProviderStatePath(): string { return join(getCcsDir(), 'cache', PROVIDER_STATE_FILE); } @@ -209,18 +223,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 +240,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 +293,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/types.ts b/src/utils/websearch/types.ts index 8d737617..a6ca29df 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'; @@ -107,8 +108,9 @@ export interface WebSearchConfig { providers?: { exa?: WebSearchProviderConfig; tavily?: WebSearchProviderConfig; - duckduckgo?: WebSearchProviderConfig; brave?: WebSearchProviderConfig; + searxng?: WebSearchProviderConfig; + duckduckgo?: WebSearchProviderConfig; gemini?: WebSearchProviderConfig; opencode?: WebSearchProviderConfig; grok?: WebSearchProviderConfig; 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/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index fc550868..d5bc6432 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -17,6 +17,8 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar 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 +30,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 +90,22 @@ 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; + } + + 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') @@ -144,6 +168,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: providers.searxng?.url ?? config.websearch?.providers?.searxng?.url ?? '', + 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/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 80bb74d9..2f005e0c 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -56,6 +56,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') { @@ -154,6 +160,83 @@ 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, + }; + + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/'; + process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = '3'; + global.fetch = async () => + ({ + 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'); + } 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('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 +364,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'); diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index b696278d..a23ea719 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -93,6 +93,28 @@ 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('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 +144,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/websearch-routes.test.ts b/tests/unit/web-server/websearch-routes.test.ts index f900abd0..18908aff 100644 --- a/tests/unit/web-server/websearch-routes.test.ts +++ b/tests/unit/web-server/websearch-routes.test.ts @@ -288,6 +288,33 @@ 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('rejects non-object request bodies', async () => { const response = await putWebsearch('[]'); @@ -357,4 +384,34 @@ 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 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/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index b662d2e0..3f408749 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -28,8 +28,16 @@ import type { } from '../../types'; import { ProviderCard, type ProviderFieldConfig } from './provider-card'; -type ProviderId = 'exa' | 'tavily' | 'brave' | 'duckduckgo' | 'gemini' | 'opencode' | 'grok'; -type ProviderFieldKey = 'model' | 'timeout' | 'max_results'; +type ProviderId = + | 'exa' + | 'tavily' + | 'brave' + | 'searxng' + | 'duckduckgo' + | 'gemini' + | 'opencode' + | 'grok'; +type ProviderFieldKey = 'model' | 'timeout' | 'max_results' | 'url'; interface ProviderFieldDefinition { key: ProviderFieldKey; @@ -58,6 +66,7 @@ const CHAIN_STEPS = [ { id: 'exa', title: 'Exa', defaultEnabled: false }, { id: 'tavily', title: 'Tavily', defaultEnabled: false }, { id: 'brave', title: 'Brave', defaultEnabled: false }, + { id: 'searxng', title: 'SearXNG', defaultEnabled: false }, { id: 'duckduckgo', title: 'DuckDuckGo', defaultEnabled: true }, { id: 'legacy', title: 'Legacy CLI', defaultEnabled: false }, ] as const; @@ -130,6 +139,36 @@ const BACKEND_PROVIDERS: ProviderDefinition[] = [ }, ], }, + { + id: 'searxng', + title: 'SearXNG', + description: 'Configurable JSON backend for self-hosted or public SearXNG instances.', + badge: 'SELF-HOSTED', + badgeTone: 'cyan', + defaultEnabled: false, + fallbackDetail: 'Set a valid base URL', + footerNote: 'Runs after Brave and before DuckDuckGo when enabled and ready.', + fields: [ + { + key: 'url', + label: 'Base URL', + type: 'text', + placeholder: 'https://search.example.com', + helpText: 'Must expose /search with format=json enabled.', + defaultValue: '', + }, + { + key: 'max_results', + label: 'Max results', + type: 'number', + placeholder: '5', + helpText: 'Clamp between 1 and 10 results.', + defaultValue: 5, + min: 1, + max: 10, + }, + ], + }, { id: 'duckduckgo', title: 'DuckDuckGo', diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 70af1d16..1cff3d33 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -9,6 +9,7 @@ import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client'; export interface ProviderConfig { enabled?: boolean; + url?: string; model?: string; timeout?: number; max_results?: number; @@ -28,8 +29,9 @@ export interface WebSearchApiKeyState { export interface WebSearchProvidersConfig { exa?: ProviderConfig; tavily?: ProviderConfig; - duckduckgo?: ProviderConfig; brave?: ProviderConfig; + searxng?: ProviderConfig; + duckduckgo?: ProviderConfig; gemini?: ProviderConfig; grok?: ProviderConfig; opencode?: ProviderConfig; @@ -48,7 +50,7 @@ export interface WebSearchSavePayload { } export interface CliStatus { - id: 'exa' | 'tavily' | 'duckduckgo' | 'brave' | 'gemini' | 'grok' | 'opencode'; + id: 'exa' | 'tavily' | 'brave' | 'searxng' | 'duckduckgo' | 'gemini' | 'grok' | 'opencode'; kind: 'backend' | 'legacy-cli'; name?: string; enabled: boolean; From 36d828f99c4a8dae40286b72709487ae8cb49f2c Mon Sep 17 00:00:00 2001 From: buiducnhat Date: Sat, 11 Apr 2026 19:35:41 +0700 Subject: [PATCH 03/42] feat(websearch): add url property to WebSearchProviderConfig interface --- src/utils/websearch/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/websearch/types.ts b/src/utils/websearch/types.ts index a6ca29df..4b6b3bdf 100644 --- a/src/utils/websearch/types.ts +++ b/src/utils/websearch/types.ts @@ -98,6 +98,7 @@ export interface WebSearchProviderConfig { model?: string; timeout?: number; max_results?: number; + url?: string; } /** From 1a7fc4a3f280e647c07c4b1a4e9eeae2d3ae3d3c Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 17:18:51 +0200 Subject: [PATCH 04/42] test(ci): improve test isolation readability per review feedback - Extract NEUTRAL_PROFILE_TYPE constant with JSDoc explaining why CCS_PROFILE_TYPE must be neutralised in subprocess env blocks - Replace 6 bare '' occurrences with the named constant (DRY) - Group save/delete operations in beforeEach with section comments Built [OnSteroids](https://onsteroids.ai) --- tests/unit/hooks/websearch-transformer.test.ts | 18 ++++++++++++------ .../utils/claudecode-env-stripping.test.ts | 5 +++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 42e7e3be..6cc83de4 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; @@ -101,7 +107,7 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail }), env: { ...process.env, - CCS_PROFILE_TYPE: '', + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_WEBSEARCH_ENABLED: '1', CCS_WEBSEARCH_SKIP: '0', CCS_WEBSEARCH_BRAVE: '0', @@ -353,7 +359,7 @@ describe('websearch-transformer hook helpers', () => { }), env: { ...process.env, - CCS_PROFILE_TYPE: '', + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'hook-trace-test', @@ -430,7 +436,7 @@ describe('websearch-transformer hook helpers', () => { }), env: { ...process.env, - CCS_PROFILE_TYPE: '', + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_FILE: disallowedTracePath, @@ -512,7 +518,7 @@ global.fetch = async (url) => { }), env: { ...process.env, - CCS_PROFILE_TYPE: '', + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'quota-fallback-test', @@ -627,7 +633,7 @@ global.fetch = async (url) => { }), env: { ...process.env, - CCS_PROFILE_TYPE: '', + CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE, CCS_HOME: ccsHome, CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'cooldown-skip-test', @@ -728,7 +734,7 @@ global.fetch = async (url) => { }), env: { ...process.env, - CCS_PROFILE_TYPE: '', + 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/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 4c3e3dd0..117f2608 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -152,12 +152,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'); From 79251d7af8b22a3a7fdb11ac6a357dd245df4590 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 17:29:15 +0200 Subject: [PATCH 05/42] test(ci): add env isolation comment to persist-command-handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align with claudecode-env-stripping.test.ts style — explain why CLAUDE_CONFIG_DIR is cleared in beforeEach for consistency across test files. Built [OnSteroids](https://onsteroids.ai) --- tests/unit/commands/persist-command-handler.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index 8f235714..80a6dcd6 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -73,6 +73,7 @@ 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; From dceb6f6d37972322a881d32c4d8b0c420e2f52db Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 07:39:16 +0200 Subject: [PATCH 06/42] feat(cliproxy): add --json flag to catalog command Add machine-readable JSON output for `ccs cliproxy catalog --json` to enable programmatic access to available models per provider. Output format: { "provider": [{ "id": "...", "name": "..." }, ...] } Closes CCS-1 Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 15 +++++++++++++++ src/commands/cliproxy/index.ts | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 13374e65..a41cb56f 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -5,6 +5,7 @@ import { SYNCABLE_PROVIDERS, getResolvedCatalog, refreshCatalogFromProxy, + getAllResolvedCatalogs, } from '../../cliproxy/catalog-cache'; import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; @@ -173,6 +174,20 @@ export async function handleCatalogRefresh(verbose: boolean): Promise { console.log(''); } +/** + * Output catalog as JSON for programmatic consumption. + * Used by OnSteroids and other tools to get available models per provider. + * Format: { provider: [{ id, name }], ... } + */ +export function handleCatalogJson(): void { + const catalogs = getAllResolvedCatalogs(); + const result: Record> = {}; + for (const [provider, catalog] of Object.entries(catalogs)) { + result[provider] = catalog.models.map((m) => ({ id: m.id, name: m.name })); + } + console.log(JSON.stringify(result)); +} + /** Reset catalog cache */ export async function handleCatalogReset(): Promise { await initUI(); diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 50c9013a..bad626b4 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,10 @@ export async function handleCliproxyCommand(args: string[]): Promise { // Catalog commands if (command === 'catalog') { + if (hasAnyFlag(remainingArgs, ['--json'])) { + handleCatalogJson(); + return; + } const subcommand = remainingArgs[1]; if (subcommand === 'refresh') { await handleCatalogRefresh(verbose); From ac92688cc5f14054f4bd73c15723d521baa9afb1 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 18:02:32 +0200 Subject: [PATCH 07/42] docs(cliproxy): clarify catalog --json output format in JSDoc Use TypeScript-style index signature notation instead of ambiguous placeholder name in format description. Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index a41cb56f..183759d7 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -177,7 +177,7 @@ export async function handleCatalogRefresh(verbose: boolean): Promise { /** * Output catalog as JSON for programmatic consumption. * Used by OnSteroids and other tools to get available models per provider. - * Format: { provider: [{ id, name }], ... } + * Format: { [providerName: string]: Array<{ id: string, name: string }> } */ export function handleCatalogJson(): void { const catalogs = getAllResolvedCatalogs(); From 509d2e4dd83840775915c7297621da89a7b7fe9c Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 18:27:39 +0200 Subject: [PATCH 08/42] test(cliproxy): add catalog --json tests and clarify flag priority - Add unit tests verifying JSON output structure, field filtering, and minified format - Document that --json takes priority over subcommands (refresh/reset) Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/index.ts | 2 + .../commands/cliproxy-catalog-json.test.ts | 64 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/unit/commands/cliproxy-catalog-json.test.ts diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index bad626b4..52c4cb02 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -147,6 +147,8 @@ 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; 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..0e1928ff --- /dev/null +++ b/tests/unit/commands/cliproxy-catalog-json.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +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', async () => { + const { handleCatalogJson } = await import( + `../../../src/commands/cliproxy/catalog-subcommand?catalog-json-format=${Date.now()}` + ); + + handleCatalogJson(); + + expect(capturedOutput).toHaveLength(1); + const parsed = JSON.parse(capturedOutput[0]) as Record< + string, + Array<{ id: string; name: string }> + >; + + // 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 { id, name } objects + for (const [provider, models] of Object.entries(parsed)) { + expect(Array.isArray(models)).toBe(true); + expect(models.length).toBeGreaterThan(0); + for (const model of models) { + expect(typeof model.id).toBe('string'); + expect(typeof model.name).toBe('string'); + // Only id and name — no extra metadata leaks + expect(Object.keys(model).sort()).toEqual(['id', 'name']); + } + // Provider key should be a non-empty string + expect(provider.length).toBeGreaterThan(0); + } + }); + + it('outputs minified JSON (single line, no whitespace formatting)', async () => { + const { handleCatalogJson } = await import( + `../../../src/commands/cliproxy/catalog-subcommand?catalog-json-minified=${Date.now()}` + ); + + handleCatalogJson(); + + const output = capturedOutput[0]; + // Minified JSON has no newlines + expect(output.includes('\n')).toBe(false); + // Valid JSON roundtrip + expect(JSON.stringify(JSON.parse(output))).toBe(output); + }); +}); From fbfb130f4d6313f10a6c757ceccfcfe2d7e69241 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 18:53:33 +0200 Subject: [PATCH 09/42] feat(cliproxy): include model metadata in catalog --json output - Expose tier, description, deprecated, broken, extendedContext, nativeImageInput fields (omitted when undefined) - Simplify tests to use static imports instead of dynamic cache-busting - Add integration tests verifying --json routing through handleCliproxyCommand - Test --json priority over refresh/reset subcommands Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 29 +++++- .../commands/cliproxy-catalog-json.test.ts | 93 ++++++++++++++----- 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 183759d7..78fb6e1d 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -174,16 +174,39 @@ 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; + 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]: Array<{ id: string, name: string }> } + * Format: { [providerName: string]: CatalogJsonModel[] } */ export function handleCatalogJson(): void { const catalogs = getAllResolvedCatalogs(); - const result: Record> = {}; + const result: Record = {}; for (const [provider, catalog] of Object.entries(catalogs)) { - result[provider] = catalog.models.map((m) => ({ id: m.id, name: m.name })); + result[provider] = catalog.models.map((m) => { + const entry: CatalogJsonModel = { id: m.id, name: m.name }; + if (m.tier) entry.tier = m.tier; + if (m.description) entry.description = m.description; + if (m.deprecated) entry.deprecated = m.deprecated; + if (m.deprecationReason) entry.deprecationReason = m.deprecationReason; + if (m.broken) entry.broken = m.broken; + if (m.extendedContext) entry.extendedContext = m.extendedContext; + if (m.nativeImageInput) entry.nativeImageInput = m.nativeImageInput; + return entry; + }); } console.log(JSON.stringify(result)); } diff --git a/tests/unit/commands/cliproxy-catalog-json.test.ts b/tests/unit/commands/cliproxy-catalog-json.test.ts index 0e1928ff..cd3fe17f 100644 --- a/tests/unit/commands/cliproxy-catalog-json.test.ts +++ b/tests/unit/commands/cliproxy-catalog-json.test.ts @@ -1,4 +1,6 @@ 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[]; @@ -16,49 +18,96 @@ afterEach(() => { }); describe('cliproxy catalog --json output', () => { - it('outputs valid JSON mapping provider names to model arrays', async () => { - const { handleCatalogJson } = await import( - `../../../src/commands/cliproxy/catalog-subcommand?catalog-json-format=${Date.now()}` - ); - + it('outputs valid JSON mapping provider names to model arrays', () => { handleCatalogJson(); expect(capturedOutput).toHaveLength(1); - const parsed = JSON.parse(capturedOutput[0]) as Record< - string, - Array<{ id: string; name: string }> - >; + 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 { id, name } objects - for (const [provider, models] of Object.entries(parsed)) { + // 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) { + for (const model of models as Array>) { expect(typeof model.id).toBe('string'); expect(typeof model.name).toBe('string'); - // Only id and name — no extra metadata leaks - expect(Object.keys(model).sort()).toEqual(['id', 'name']); } - // Provider key should be a non-empty string - expect(provider.length).toBeGreaterThan(0); } }); - it('outputs minified JSON (single line, no whitespace formatting)', async () => { - const { handleCatalogJson } = await import( - `../../../src/commands/cliproxy/catalog-subcommand?catalog-json-minified=${Date.now()}` - ); + 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('outputs minified JSON (single line, no whitespace formatting)', () => { handleCatalogJson(); const output = capturedOutput[0]; - // Minified JSON has no newlines expect(output.includes('\n')).toBe(false); - // Valid JSON roundtrip 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'); + }); +}); From 9ac1ac98040503cd395d51e4cffff123a1ace44f Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 19:08:24 +0200 Subject: [PATCH 10/42] fix(cliproxy): preserve explicit false values in catalog --json Use !== undefined checks instead of truthy checks so that explicitly set false booleans (e.g. extendedContext: false) appear in JSON output, allowing consumers to distinguish "not set" from "explicitly disabled". Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 78fb6e1d..3090c28e 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -198,13 +198,13 @@ export function handleCatalogJson(): void { for (const [provider, catalog] of Object.entries(catalogs)) { result[provider] = catalog.models.map((m) => { const entry: CatalogJsonModel = { id: m.id, name: m.name }; - if (m.tier) entry.tier = m.tier; - if (m.description) entry.description = m.description; - if (m.deprecated) entry.deprecated = m.deprecated; - if (m.deprecationReason) entry.deprecationReason = m.deprecationReason; - if (m.broken) entry.broken = m.broken; - if (m.extendedContext) entry.extendedContext = m.extendedContext; - if (m.nativeImageInput) entry.nativeImageInput = m.nativeImageInput; + 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.extendedContext !== undefined) entry.extendedContext = m.extendedContext; + if (m.nativeImageInput !== undefined) entry.nativeImageInput = m.nativeImageInput; return entry; }); } From 22acd96ba40dda2c9ed596b2db2643863b3b7e4d Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 19:22:05 +0200 Subject: [PATCH 11/42] feat(cliproxy): add thinking config to catalog --json and test false values - Include thinking support configuration (type, min, max, levels) in JSON output for reasoning-capable models - Add test verifying explicit false booleans are preserved (not omitted) - Add test verifying thinking config appears for thinking models Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 3 ++ .../commands/cliproxy-catalog-json.test.ts | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 3090c28e..05c4f172 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -10,6 +10,7 @@ import { 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'; @@ -183,6 +184,7 @@ interface CatalogJsonModel { deprecated?: boolean; deprecationReason?: string; broken?: boolean; + thinking?: ThinkingSupport; extendedContext?: boolean; nativeImageInput?: boolean; } @@ -203,6 +205,7 @@ export function handleCatalogJson(): void { 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.thinking !== undefined) entry.thinking = m.thinking; if (m.extendedContext !== undefined) entry.extendedContext = m.extendedContext; if (m.nativeImageInput !== undefined) entry.nativeImageInput = m.nativeImageInput; return entry; diff --git a/tests/unit/commands/cliproxy-catalog-json.test.ts b/tests/unit/commands/cliproxy-catalog-json.test.ts index cd3fe17f..7bc3e5ea 100644 --- a/tests/unit/commands/cliproxy-catalog-json.test.ts +++ b/tests/unit/commands/cliproxy-catalog-json.test.ts @@ -75,6 +75,39 @@ describe('cliproxy catalog --json output', () => { } }); + 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(); From 7bf5b4b95e0bb12afc1d23d668fb43546608db58 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 19:33:00 +0200 Subject: [PATCH 12/42] fix(cliproxy): guard against undefined catalog and add issueUrl field - Add null check for catalog entries from Partial type - Include issueUrl in JSON output for broken model tracking Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 05c4f172..adcdb5e5 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -184,6 +184,7 @@ interface CatalogJsonModel { deprecated?: boolean; deprecationReason?: string; broken?: boolean; + issueUrl?: string; thinking?: ThinkingSupport; extendedContext?: boolean; nativeImageInput?: boolean; @@ -198,6 +199,9 @@ 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; @@ -205,6 +209,7 @@ export function handleCatalogJson(): void { 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; From ea2b16bb6622c7e4d668aa63b735093701313036 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 20:47:10 +0200 Subject: [PATCH 13/42] docs(cliproxy): add catalog --json to --help output Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/help-subcommand.ts | 1 + 1 file changed, 1 insertion(+) 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'], ], ], [ From ef564cafdf4213d07914f8e2cf9a348a6fb272b3 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 21:08:08 +0200 Subject: [PATCH 14/42] ci: retrigger validation From 7776715ebadc5821167c0bf721266f43a228b3cb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 11 Apr 2026 17:50:11 -0400 Subject: [PATCH 15/42] fix(docker): delegate restart/install to supervisorctl in Docker deployments The dashboard restart button and install service used local-mode process management (stopProxy + ensureCliproxyService) which conflicts with supervisord in Docker deployments. Killing the CLIProxy binary caused the bootstrap wrapper to exit, supervisord to attempt a restart, but ensureCliproxyService already spawned an orphaned process on :8317 resulting in EADDRINUSE and supervisord entering FATAL state. Detect supervisord via /var/run/supervisor.sock and delegate to supervisorctl restart, matching the pattern already used by ccs docker update. Closes #964 --- src/docker/supervisord-lifecycle.ts | 36 +++++++++++++++++++ .../routes/cliproxy-stats-routes.ts | 13 ++++++- .../cliproxy-dashboard-install-service.ts | 18 ++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/docker/supervisord-lifecycle.ts 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/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/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 { From ca8401e5fc18b4bd3d577704d7de8411253084cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Apr 2026 22:13:58 +0000 Subject: [PATCH 16/42] chore(release): 7.68.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a58575f5..02d4251f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.0", + "version": "7.68.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 5076b212af6a9318505e52536899af1060faf053 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 01:24:40 -0400 Subject: [PATCH 17/42] fix(ui): clarify Gemini quota tooltip labels --- .../shared/quota-tooltip-content.tsx | 132 +++++++++++++++--- .../shared/quota-tooltip-content.test.tsx | 24 ++-- 2 files changed, 127 insertions(+), 29 deletions(-) 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/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index 790ec8a1..bf600694 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -12,22 +12,22 @@ function createGeminiQuotaResult( { id: 'gemini-flash-lite-series::combined', label: 'Gemini Flash Lite Series', - tokenType: null, + tokenType: 'requests', remainingFraction: 1, remainingPercent: 100, remainingAmount: 100, resetTime: '2026-01-30T09:00:00Z', - modelIds: ['gemini-2.5-flash-lite'], + modelIds: ['gemini-2.5-flash-lite', 'gemini-3.1-flash-lite-preview'], }, { id: 'gemini-flash-series::combined', label: 'Gemini Flash Series', - tokenType: null, + tokenType: 'requests', remainingFraction: 0.82, remainingPercent: 82, remainingAmount: 82, resetTime: '2026-01-30T14:00:00Z', - modelIds: ['gemini-3-flash-preview'], + modelIds: ['gemini-3-flash-preview', 'gemini-3.1-flash-preview', 'gemini-2.5-flash'], }, ], projectId: 'cloudaicompanion-test-123', @@ -51,7 +51,7 @@ function createGeminiQuotaResult( } describe('QuotaTooltipContent', () => { - it('renders Gemini tier, credits, remaining amount, and bucket reset timestamps', () => { + it('renders Gemini tier, model coverage, and clearer bucket wording', () => { const quota = createGeminiQuotaResult(); const expectedReset = new Date('2026-01-30T14:00:00Z').toLocaleString(undefined, { month: '2-digit', @@ -69,9 +69,17 @@ describe('QuotaTooltipContent', () => { expect(screen.getByText('g1-pro-tier')).toBeInTheDocument(); expect(screen.getByText('Credits')).toBeInTheDocument(); expect(screen.getByText('12')).toBeInTheDocument(); - expect(screen.getByText('Gemini Flash Lite Series')).toBeInTheDocument(); - expect(screen.getByText('100 remaining')).toBeInTheDocument(); - expect(screen.getByText('82 remaining')).toBeInTheDocument(); + expect(screen.getByText('Model quotas:')).toBeInTheDocument(); + expect(screen.getByText('All buckets report Requests')).toBeInTheDocument(); + expect(screen.getByText('Flash Lite')).toBeInTheDocument(); + expect( + screen.getByText('gemini-2.5-flash-lite, gemini-3.1-flash-lite-preview') + ).toBeInTheDocument(); + expect(screen.getByText('100 requests remaining')).toBeInTheDocument(); + expect( + screen.getByText('gemini-3-flash-preview, gemini-3.1-flash-preview, gemini-2.5-flash') + ).toBeInTheDocument(); + expect(screen.getByText('82 requests remaining')).toBeInTheDocument(); expect(screen.getByText(expectedReset)).toBeInTheDocument(); }); From 898e7a632125476f269d4ff34280589404d7c3ae Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 01:22:15 -0400 Subject: [PATCH 18/42] hotfix: detect bun-owned installs through symlinked ccs path --- src/utils/package-manager-detector.ts | 4 ++- .../utils/package-manager-detector.test.ts | 35 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index 606c58ed..d5f4a051 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -29,7 +29,9 @@ export interface InstalledPackageState { const CCS_PACKAGE_NAME = '@kaitranntt/ccs'; function resolveScriptPath(scriptPath: string): string { - if (path.win32.isAbsolute(scriptPath)) { + // Keep Windows-style absolute test fixtures stable on non-Windows hosts, but + // still resolve real POSIX symlink paths such as ~/.bun/bin/ccs. + if (path.win32.isAbsolute(scriptPath) && !path.isAbsolute(scriptPath)) { return scriptPath; } diff --git a/tests/unit/utils/package-manager-detector.test.ts b/tests/unit/utils/package-manager-detector.test.ts index 693e0920..5bb1164a 100644 --- a/tests/unit/utils/package-manager-detector.test.ts +++ b/tests/unit/utils/package-manager-detector.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'bun:test'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { @@ -72,6 +72,39 @@ describe('package-manager-detector', () => { expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9'); }); + it.if(process.platform !== 'win32')( + 'detects bun installs from a POSIX symlinked ~/.bun/bin/ccs entrypoint', + () => { + const tempRoot = makeTempDir('ccs-install-detector-bun-symlink-'); + const packageRoot = join( + tempRoot, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + const scriptPath = join(packageRoot, 'dist', 'ccs.js'); + const symlinkPath = join(tempRoot, 'home', '.bun', 'bin', 'ccs'); + + writePackage(packageRoot, '7.67.0-dev.9'); + mkdirSync(join(packageRoot, 'dist'), { recursive: true }); + writeFileSync(scriptPath, '#!/usr/bin/env node\n'); + mkdirSync(join(tempRoot, 'home', '.bun', 'bin'), { recursive: true }); + symlinkSync(scriptPath, symlinkPath); + + const install = detectCurrentInstall(symlinkPath); + const resolvedTempRoot = realpathSync(tempRoot); + const resolvedScriptPath = realpathSync(scriptPath); + + expect(install.manager).toBe('bun'); + expect(install.prefix).toBe(join(resolvedTempRoot, '.bun')); + expect(install.resolvedScriptPath).toBe(resolvedScriptPath); + expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9'); + } + ); + it('detects custom bun install roots that still use install/global/node_modules', () => { const tempRoot = makeTempDir('ccs-install-detector-custom-bun-'); const packageRoot = join( From b2f584114a19fa3f3b0882af4a91b9c67316c603 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 05:37:37 +0000 Subject: [PATCH 19/42] chore(release): 7.68.1-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9fd19432..e604f9ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1", + "version": "7.68.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 2eaf760d20be35c958e31ae16b362d0c508cd0c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 05:47:44 +0000 Subject: [PATCH 20/42] chore(release): 7.68.1-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e604f9ed..7bb6ae59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1-dev.1", + "version": "7.68.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From e1049e38d409ad32bf0c1ae41eb713f59dbdfe02 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 01:50:30 -0400 Subject: [PATCH 21/42] feat(cursor): harden live probe and runtime contracts --- docs/cursor-integration.md | 39 +- src/commands/cursor-command-display.ts | 26 +- src/commands/cursor-command.ts | 47 +- src/cursor/cursor-auth.ts | 238 ++++++-- src/cursor/cursor-executor.ts | 548 +++++++++++------- src/cursor/cursor-protobuf-decoder.ts | 13 +- src/cursor/cursor-protobuf-schema.ts | 29 +- src/cursor/cursor-protobuf.ts | 6 +- src/cursor/cursor-runtime-probe.ts | 258 +++++++++ src/cursor/cursor-stream-parser.ts | 102 +++- src/cursor/index.ts | 1 + src/cursor/types.ts | 12 + src/web-server/routes/cursor-routes.ts | 25 +- tests/unit/cursor/cursor-auth.test.ts | 193 +++++- tests/unit/cursor/cursor-daemon.test.ts | 3 + tests/unit/cursor/cursor-protobuf.test.ts | 160 ++++- .../unit/cursor/cursor-runtime-probe.test.ts | 132 +++++ tests/unit/web-server/cursor-routes.test.ts | 102 +++- ui/src/hooks/use-cursor.ts | 66 +++ ui/src/lib/i18n.ts | 60 ++ ui/src/pages/cursor.tsx | 159 +++++ ui/tests/unit/hooks/use-cursor.test.tsx | 116 ++++ ui/tests/unit/ui/pages/cursor-page.test.tsx | 157 +++++ 23 files changed, 2186 insertions(+), 306 deletions(-) create mode 100644 src/cursor/cursor-runtime-probe.ts create mode 100644 tests/unit/cursor/cursor-runtime-probe.test.ts create mode 100644 ui/tests/unit/hooks/use-cursor.test.tsx create mode 100644 ui/tests/unit/ui/pages/cursor-page.test.tsx 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/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/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..a8ae63de 100644 --- a/src/cursor/cursor-executor.ts +++ b/src/cursor/cursor-executor.ts @@ -6,10 +6,19 @@ 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, +} from './cursor-stream-parser.js'; /** Executor parameters */ interface ExecutorParams { @@ -41,6 +50,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,13 +75,13 @@ 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 || @@ -74,19 +90,48 @@ function createErrorResponse(jsonError: { const isRateLimit = jsonError?.error?.code === 'resource_exhausted'; - 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: isRateLimit ? 429 : 400, + errorType: isRateLimit ? 'rate_limit_error' : 'api_error', + 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 +458,237 @@ 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; + 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 +706,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 +732,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 isRateLimit = json?.error?.code === 'resource_exhausted'; + yield { + type: 'error', + error: { + message: msg, + status: isRateLimit ? 429 : 400, + errorType: isRateLimit ? 'rate_limit_error' : 'api_error', + code: isRateLimit ? 'rate_limited' : '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 +799,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; } @@ -711,7 +847,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 +976,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..3ac4ca3c --- /dev/null +++ b/src/cursor/cursor-runtime-probe.ts @@ -0,0 +1,258 @@ +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) { + 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', + }; + } + + daemonRunning = true; + } + + 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..1c64d446 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,53 @@ export type FrameResult = }; }; +export class CursorConnectFrameError extends Error { + constructor( + message: string, + readonly status = 502, + readonly errorType = 'server_error' + ) { + super(message); + this.name = 'CursorConnectFrameError'; + } +} + +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', + }; +} + /** * 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 +82,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 +125,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 isRateLimit = json?.error?.code === 'resource_exhausted'; + results.push({ + type: 'error', + message: msg, + status: isRateLimit ? 429 : 400, + errorType: isRateLimit ? 'rate_limit_error' : 'api_error', + }); + return results; + } + } catch { + // Ignore successful end-stream metadata trailers. + } + continue; + } // Check for JSON error try { @@ -116,7 +200,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; 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/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts index b28c3705..73575352 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'; @@ -124,7 +125,16 @@ 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/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..337ae7ee 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) => @@ -1177,7 +1192,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 +1207,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 +1234,63 @@ 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('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 +1485,31 @@ 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 parse thinking frames', () => { const parser = new StreamingFrameParser(); const frame = buildThinkingFrame('Think step by step'); @@ -1458,11 +1550,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); @@ -1496,10 +1616,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 +1639,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/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts index fda059b0..65945b83 100644 --- a/tests/unit/web-server/cursor-routes.test.ts +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -280,9 +280,10 @@ 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'); } finally { if (originalHome !== undefined) { process.env.HOME = originalHome; @@ -344,6 +345,82 @@ 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 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 +489,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/ui/src/hooks/use-cursor.ts b/ui/src/hooks/use-cursor.ts index 8e34c6a7..747db82e 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, @@ -248,6 +303,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, @@ -281,6 +342,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..faef6c61 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1325,6 +1325,21 @@ 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', }, droidPage: { loadingDiagnostics: 'Loading Droid diagnostics...', @@ -2658,6 +2673,21 @@ const resources = { rawChanged: '原始设置已被外部修改,请刷新后重试。', failedSaveRaw: '保存原始设置失败', savedAll: 'Cursor 配置已保存', + liveProbe: '实时探测', + runLiveProbe: '运行实时探测', + rerunLiveProbe: '重新运行实时探测', + probing: '正在运行探测...', + probeNotRun: '尚未运行探测', + probeLocalReadinessHint: + '本地就绪检查可能通过,但实时运行链路仍可能失败。请使用实时探测验证完整的 Cursor 路径。', + probeStage: '阶段', + probeHttpStatus: 'HTTP 状态', + probeDuration: '耗时', + probeModel: '模型', + probeMessage: '消息', + probeSucceeded: '探测成功', + probeFailed: '探测失败', + probeSaveFirst: '请先保存或放弃 Cursor 更改,再运行实时探测', }, droidPage: { loadingDiagnostics: '加载 Droid 诊断中...', @@ -4085,6 +4115,21 @@ 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', }, droidPage: { loadingDiagnostics: 'Đang tải chẩn đoán Droid...', @@ -5520,6 +5565,21 @@ const resources = { rawChanged: '生の設定が外部で変更されました。更新して再試行してください。', failedSaveRaw: '生の設定の保存に失敗しました', savedAll: 'Cursor設定を保存しました', + liveProbe: 'ライブプローブ', + runLiveProbe: 'ライブプローブを実行', + rerunLiveProbe: 'ライブプローブを再実行', + probing: 'プローブを実行中...', + probeNotRun: 'まだプローブを実行していません', + probeLocalReadinessHint: + 'ローカルの準備チェックが通っても、実際のランタイム経路は失敗する場合があります。ライブプローブで Cursor の完全な経路を確認してください。', + probeStage: '段階', + probeHttpStatus: 'HTTP ステータス', + probeDuration: '所要時間', + probeModel: 'モデル', + probeMessage: 'メッセージ', + probeSucceeded: 'プローブ成功', + probeFailed: 'プローブ失敗', + probeSaveFirst: 'ライブプローブを実行する前に Cursor の変更を保存するか破棄してください', }, droidPage: { loadingDiagnostics: 'Droid診断を読み込み中...', diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index e788ecb0..d5bbec3f 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; @@ -284,6 +310,10 @@ export function CursorPage() { isStartingDaemon, stopDaemonAsync, isStoppingDaemon, + runProbeAsync, + isRunningProbe, + probeResult, + resetProbe, } = useCursor(); const [configDraft, setConfigDraft] = useState(() => buildConfigDraft()); @@ -293,6 +323,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 +351,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 +390,11 @@ export function CursorPage() { setConfigDirty(true); }; + const clearProbeState = () => { + resetProbe(); + setProbeSnapshotKey(null); + }; + const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired); const integrationBadge = useMemo( () => @@ -395,6 +441,7 @@ export function CursorPage() { haiku_model: effectiveHaikuModel || undefined, }) ); + clearProbeState(); if (!suppressSuccessToast) { toast.success(t('cursorPage.savedConfig')); } @@ -500,6 +547,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 +559,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 +577,7 @@ export function CursorPage() { accessToken: manualToken.trim(), machineId: manualMachineId.trim(), }); + clearProbeState(); toast.success(t('cursorPage.credentialsImported')); setManualAuthOpen(false); setManualToken(''); @@ -544,6 +594,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 +612,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 +659,7 @@ export function CursorPage() { expectedMtime: rawSettings?.mtime, }); setRawConfigDirty(false); + clearProbeState(); if (!suppressSuccessToast) { toast.success(t('cursorPage.rawSaved')); } @@ -627,6 +701,7 @@ export function CursorPage() { const handleHeaderRefresh = () => { setRawConfigDirty(false); + clearProbeState(); refetchStatus(); refetchRawSettings(); }; @@ -710,6 +785,71 @@ 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 +903,25 @@ export function CursorPage() { {t('cursorPage.manualAuthImport')} + + {status?.daemon_running ? ( @@ -794,11 +806,12 @@ export function CursorPage() {

@@ -993,6 +1006,8 @@ export function CursorPage() { size="sm" onClick={handleHeaderRefresh} disabled={statusLoading || rawSettingsLoading} + aria-label={t('cursorPage.refreshConfiguration')} + title={t('cursorPage.refreshConfiguration')} > ({ runProbeAsync: vi.fn(), resetProbe: vi.fn(), refetchStatus: vi.fn(), + refetchConfig: vi.fn(), refetchRawSettings: vi.fn(), updateConfigAsync: vi.fn(), saveRawSettingsAsync: vi.fn(), @@ -75,6 +76,7 @@ function buildUseCursorResult(overrides: Record = {}) { sonnet_model: 'gpt-5.3-codex', haiku_model: 'gpt-5-mini', }, + refetchConfig: mocks.refetchConfig, updateConfigAsync: mocks.updateConfigAsync, isUpdatingConfig: false, models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }], @@ -110,6 +112,13 @@ describe('CursorPage', () => { beforeEach(() => { mocks.runProbeAsync.mockReset(); mocks.resetProbe.mockReset(); + mocks.refetchConfig.mockReset(); + mocks.refetchConfig.mockResolvedValue({ + status: 'success', + isError: false, + error: null, + data: buildUseCursorResult().config, + }); mocks.runProbeAsync.mockResolvedValue(probeFailure); mocks.useCursor.mockReturnValue(buildUseCursorResult()); toastMocks.error.mockReset(); @@ -154,4 +163,22 @@ describe('CursorPage', () => { 'Save or discard Cursor changes before running the live probe' ); }); + + it('refreshes config state with accessible refresh controls', async () => { + render(); + + expect(screen.getByRole('button', { name: 'Refresh Cursor status' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Refresh Cursor configuration' }) + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('tab', { name: 'Settings' })); + await userEvent.clear(screen.getByLabelText('Port')); + await userEvent.type(screen.getByLabelText('Port'), '20130'); + + await userEvent.click(screen.getByRole('button', { name: 'Refresh Cursor configuration' })); + + await waitFor(() => expect(mocks.refetchConfig).toHaveBeenCalledTimes(1)); + expect(screen.getByLabelText('Port')).toHaveValue(20129); + }); }); From 2719d7a6f7a2589dd2b9f9478f9c86d8aaea8876 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 04:42:28 -0400 Subject: [PATCH 23/42] fix(cursor): align auto-detect status and CI coverage --- src/web-server/routes/cursor-routes.ts | 22 +++++++++++++++------ tests/unit/web-server/cursor-routes.test.ts | 19 ++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts index 5309c581..defcc96e 100644 --- a/src/web-server/routes/cursor-routes.ts +++ b/src/web-server/routes/cursor-routes.ts @@ -31,6 +31,21 @@ 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': @@ -138,12 +153,7 @@ router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise 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); @@ -396,6 +410,11 @@ describe('Cursor Routes Logic', () => { 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; From 8b553c35c150e6411e6af0ce0a0a27305b516e6c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 06:31:20 -0400 Subject: [PATCH 24/42] fix(websearch): harden searxng url handling --- docs/websearch.md | 3 +- lib/hooks/websearch-transformer.cjs | 18 ++- lib/mcp/ccs-websearch-server.cjs | 2 +- src/config/unified-config-loader.ts | 5 +- src/utils/websearch/hook-env.ts | 19 ++- src/utils/websearch/status.ts | 14 +-- src/utils/websearch/types.ts | 42 +++++++ src/web-server/routes/websearch-routes.ts | 19 ++- .../hooks/ccs-websearch-mcp-server.test.ts | 14 +-- .../unit/hooks/websearch-transformer.test.ts | 49 +++++++- .../utils/claudecode-env-stripping.test.ts | 39 +++++- tests/unit/utils/websearch/status.test.ts | 54 +++++++++ .../unit/web-server/websearch-routes.test.ts | 112 ++++++++++++++++++ .../settings/sections/websearch/index.tsx | 3 +- 14 files changed, 359 insertions(+), 34 deletions(-) diff --git a/docs/websearch.md b/docs/websearch.md index ded2e1b9..29dcf740 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -81,7 +81,8 @@ That shared launch helper applies to normal third-party settings profiles, CLIPr Open `ccs config` → `Settings` → `WebSearch`. - Enable Exa, Tavily, Brave, SearXNG, or DuckDuckGo in the backend chain -- Configure SearXNG base URL (for example `https://search.example.com`) when SearXNG is enabled +- 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 diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index d1fae51e..6da24c3e 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -378,6 +378,22 @@ function getSearxngBaseUrl() { 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 ''; @@ -587,7 +603,7 @@ 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 not configured' }; + return { success: false, error: 'SearXNG URL is invalid or not configured' }; } const params = new URLSearchParams({ 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/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index e7a6a0bc..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'; @@ -413,7 +414,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { }, searxng: { enabled: partial.websearch?.providers?.searxng?.enabled ?? false, - url: partial.websearch?.providers?.searxng?.url ?? '', + url: normalizeSearxngBaseUrl(partial.websearch?.providers?.searxng?.url) ?? '', max_results: partial.websearch?.providers?.searxng?.max_results ?? 5, }, duckduckgo: { @@ -1140,7 +1141,7 @@ export function getWebSearchConfig(): { const searxngConfig = { enabled: config.websearch?.providers?.searxng?.enabled ?? false, - url: config.websearch?.providers?.searxng?.url ?? '', + url: normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '', max_results: config.websearch?.providers?.searxng?.max_results ?? 5, }; diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index d2b5f85c..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,9 +73,10 @@ export function getWebSearchHookEnv(): Record { env.CCS_WEBSEARCH_BRAVE_MAX_RESULTS = String(wsConfig.providers.brave.max_results || 5); } - if (wsConfig.providers?.searxng?.enabled && wsConfig.providers.searxng.url?.trim()) { + const searxngBaseUrl = normalizeSearxngBaseUrl(wsConfig.providers?.searxng?.url); + if (wsConfig.providers?.searxng?.enabled && searxngBaseUrl) { env.CCS_WEBSEARCH_SEARXNG = '1'; - env.CCS_WEBSEARCH_SEARXNG_URL = wsConfig.providers.searxng.url.trim(); + env.CCS_WEBSEARCH_SEARXNG_URL = searxngBaseUrl; env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = String(wsConfig.providers.searxng.max_results || 5); } diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index e0b9c36d..e4efdf98 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'; @@ -29,17 +29,7 @@ function hasEnvValue(name: string): boolean { } function hasValidSearxngUrl(url: string | undefined): boolean { - const normalized = String(url || '').trim(); - if (!normalized) { - return false; - } - - try { - const parsed = new URL(normalized); - return Boolean(parsed.protocol && parsed.host); - } catch { - return false; - } + return normalizeSearxngBaseUrl(url) !== null; } function getProviderStatePath(): string { diff --git a/src/utils/websearch/types.ts b/src/utils/websearch/types.ts index 4b6b3bdf..38f94499 100644 --- a/src/utils/websearch/types.ts +++ b/src/utils/websearch/types.ts @@ -117,3 +117,45 @@ export interface WebSearchConfig { 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/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index d5bc6432..22f88100 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -12,6 +12,7 @@ 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(); @@ -95,6 +96,19 @@ router.put('/', (req: Request, res: Response): void => { 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' || @@ -130,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 @@ -173,7 +190,7 @@ router.put('/', (req: Request, res: Response): void => { providers.searxng?.enabled ?? config.websearch?.providers?.searxng?.enabled ?? false, - url: providers.searxng?.url ?? config.websearch?.providers?.searxng?.url ?? '', + url: normalizedSearxngUrl ?? existingSearxngUrl, max_results: clampWebSearchMaxResults( providers.searxng?.max_results, config.websearch?.providers?.searxng?.max_results ?? DEFAULT_WEBSEARCH_MAX_RESULTS 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 2f005e0c..775cb4f9 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -115,6 +115,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', }, }); @@ -166,11 +167,13 @@ describe('websearch-transformer hook helpers', () => { 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/'; + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/search/'; process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = '3'; - global.fetch = async () => - ({ + global.fetch = async (url) => { + requests.push(String(url)); + return { ok: true, json: async () => ({ results: [ @@ -181,13 +184,15 @@ describe('websearch-transformer hook helpers', () => { }, ], }), - }) as Response; + } 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) { @@ -205,6 +210,42 @@ describe('websearch-transformer hook helpers', () => { } }); + 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; diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index b00908fe..4152e09b 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -124,6 +124,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; @@ -220,7 +234,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 +276,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/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index a23ea719..3f15cf15 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -115,6 +115,60 @@ describe('websearch readiness', () => { 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('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'); diff --git a/tests/unit/web-server/websearch-routes.test.ts b/tests/unit/web-server/websearch-routes.test.ts index 18908aff..d8ae5024 100644 --- a/tests/unit/web-server/websearch-routes.test.ts +++ b/tests/unit/web-server/websearch-routes.test.ts @@ -315,6 +315,86 @@ describe('websearch routes', () => { }); }); + 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('[]'); @@ -400,6 +480,38 @@ describe('websearch routes', () => { }); }); + 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: { diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index 3f408749..b7a19a13 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -154,7 +154,8 @@ const BACKEND_PROVIDERS: ProviderDefinition[] = [ label: 'Base URL', type: 'text', placeholder: 'https://search.example.com', - helpText: 'Must expose /search with format=json enabled.', + helpText: + 'Paste the instance base URL only. CCS appends /search?format=json for you and rejects embedded credentials.', defaultValue: '', }, { From 8d8f4469b6537faf0918773c52ea2b71abef11de Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 14:46:56 -0400 Subject: [PATCH 25/42] fix(websearch): reject blank searxng status urls --- src/utils/websearch/status.ts | 3 +- tests/unit/utils/websearch/status.test.ts | 54 +++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index e4efdf98..5f496624 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -29,7 +29,8 @@ function hasEnvValue(name: string): boolean { } function hasValidSearxngUrl(url: string | undefined): boolean { - return normalizeSearxngBaseUrl(url) !== null; + const normalized = normalizeSearxngBaseUrl(url); + return normalized !== null && normalized !== ''; } function getProviderStatePath(): string { diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index 3f15cf15..a24c76a6 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -169,6 +169,60 @@ describe('websearch readiness', () => { } }); + 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'); From 66467f940916eef66a22c82c27434961ae7bdbf8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 19:30:42 +0000 Subject: [PATCH 26/42] chore(release): 7.68.1-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7bb6ae59..a06864d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1-dev.2", + "version": "7.68.1-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 4b526e1c01402421e46b238e845aa9b7eb7e9be7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 20:03:53 +0000 Subject: [PATCH 27/42] chore(release): 7.68.1-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a06864d0..278670ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1-dev.3", + "version": "7.68.1-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 52d51b1402c1ada1b0e806e227ce93c21ed5f17a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 20:44:31 +0000 Subject: [PATCH 28/42] chore(release): 7.68.1-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 278670ce..997f9d8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1-dev.4", + "version": "7.68.1-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 18e5cd5d94da8335cc01575d96693e197ea56eb0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 16:59:04 -0400 Subject: [PATCH 29/42] feat(ui): persist paused account toggle state in localStorage --- ui/src/components/account/flow-viz/index.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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} From efecd7c7d9d6b1722dc91e529dfffe4e338111dc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 21:03:50 +0000 Subject: [PATCH 30/42] chore(release): 7.68.1-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 997f9d8f..1a972940 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1-dev.5", + "version": "7.68.1-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From eb8149e8fa476f279583136be7286dbaf1ac4361 Mon Sep 17 00:00:00 2001 From: walker <13750528578@163.com> Date: Sun, 12 Apr 2026 00:02:22 +0800 Subject: [PATCH 31/42] =?UTF-8?q?feat(browser):=20=E6=8E=A5=E5=85=A5=20CCS?= =?UTF-8?q?=20browser=20MCP=20=E4=B8=8E=E6=9C=80=E5=B0=8F=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 browser MCP 安装、Chrome 复用与运行时接线, 实现 navigate、click、type、take_screenshot 四个 phase-1 工具并补齐相关单测。 Co-Authored-By: Claude Opus 4.6 --- lib/mcp/ccs-browser-server.cjs | 834 ++++++++++++++++++ src/ccs.ts | 77 +- src/cliproxy/executor/env-resolver.ts | 4 + src/cliproxy/executor/index.ts | 42 +- src/cliproxy/types.ts | 2 + src/config/migration-manager.ts | 3 +- src/management/instance-manager.ts | 7 +- src/targets/claude-adapter.ts | 16 +- src/targets/codex-adapter.ts | 21 +- src/targets/target-adapter.ts | 4 + src/utils/browser-codex-overrides.ts | 28 + src/utils/browser/chrome-reuse.ts | 238 +++++ src/utils/browser/claude-tool-args.ts | 22 + src/utils/browser/index.ts | 25 + src/utils/browser/mcp-installer.ts | 375 ++++++++ src/utils/claude-tool-args.ts | 46 + src/utils/image-analysis/claude-tool-args.ts | 55 +- src/utils/websearch/claude-tool-args.ts | 60 +- .../unit/hooks/ccs-browser-mcp-server.test.ts | 827 +++++++++++++++++ tests/unit/instance-manager-mcp-sync.test.ts | 54 +- tests/unit/targets/codex-adapter.test.ts | 42 +- .../targets/codex-runtime-integration.test.ts | 62 +- .../codex-settings-bridge-launch.test.ts | 2 + .../default-profile-browser-launch.test.ts | 154 ++++ .../settings-profile-browser-launch.test.ts | 199 +++++ tests/unit/targets/target-registry.test.ts | 44 +- tests/unit/utils/browser/chrome-reuse.test.ts | 227 +++++ .../utils/browser/claude-tool-args.test.ts | 43 + .../unit/utils/browser/mcp-installer.test.ts | 232 +++++ 29 files changed, 3627 insertions(+), 118 deletions(-) create mode 100755 lib/mcp/ccs-browser-server.cjs create mode 100644 src/utils/browser-codex-overrides.ts create mode 100644 src/utils/browser/chrome-reuse.ts create mode 100644 src/utils/browser/claude-tool-args.ts create mode 100644 src/utils/browser/index.ts create mode 100644 src/utils/browser/mcp-installer.ts create mode 100644 src/utils/claude-tool-args.ts create mode 100644 tests/unit/hooks/ccs-browser-mcp-server.test.ts create mode 100644 tests/unit/targets/default-profile-browser-launch.test.ts create mode 100644 tests/unit/targets/settings-profile-browser-launch.test.ts create mode 100644 tests/unit/utils/browser/chrome-reuse.test.ts create mode 100644 tests/unit/utils/browser/claude-tool-args.test.ts create mode 100644 tests/unit/utils/browser/mcp-installer.test.ts diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs new file mode 100755 index 00000000..515d367a --- /dev/null +++ b/lib/mcp/ccs-browser-server.cjs @@ -0,0 +1,834 @@ +#!/usr/bin/env node + +const { WebSocket } = require('ws'); + +const PROTOCOL_VERSION = '2024-11-05'; +const SERVER_NAME = 'ccs-browser'; +const SERVER_VERSION = '1.0.0'; +const TOOL_SESSION_INFO = 'browser_get_session_info'; +const TOOL_URL_TITLE = 'browser_get_url_and_title'; +const TOOL_VISIBLE_TEXT = 'browser_get_visible_text'; +const TOOL_DOM_SNAPSHOT = 'browser_get_dom_snapshot'; +const TOOL_NAVIGATE = 'browser_navigate'; +const TOOL_CLICK = 'browser_click'; +const TOOL_TYPE = 'browser_type'; +const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot'; +const TOOL_NAMES = [ + TOOL_SESSION_INFO, + TOOL_URL_TITLE, + TOOL_VISIBLE_TEXT, + TOOL_DOM_SNAPSHOT, + TOOL_NAVIGATE, + TOOL_CLICK, + TOOL_TYPE, + TOOL_TAKE_SCREENSHOT, +]; +const CDP_TIMEOUT_MS = 5000; +const NAVIGATION_POLL_INTERVAL_MS = 100; + +let inputBuffer = Buffer.alloc(0); +let requestCounter = 0; + +function shouldExposeTools() { + return Boolean(process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL); +} + +function writeMessage(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function writeResponse(id, result) { + writeMessage({ jsonrpc: '2.0', id, result }); +} + +function writeError(id, code, message) { + writeMessage({ jsonrpc: '2.0', id, error: { code, message } }); +} + +function getTools() { + if (!shouldExposeTools()) { + return []; + } + + return [ + { + name: TOOL_SESSION_INFO, + description: + 'List the current Chrome session pages available through the configured DevTools connection, including page ids, titles, URLs, and websocket endpoints.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_URL_TITLE, + description: + 'Read the current page URL and title from the configured Chrome session. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_VISIBLE_TEXT, + description: + 'Read visible text from the current page via DOM evaluation in the configured Chrome session. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_DOM_SNAPSHOT, + description: + 'Read a DOM snapshot from the current page by returning the document outerHTML. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_NAVIGATE, + description: + 'Navigate the selected page to an absolute http or https URL and wait until navigation is ready. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + url: { + type: 'string', + description: 'Required absolute http or https URL to navigate to.', + }, + }, + required: ['url'], + additionalProperties: false, + }, + }, + { + name: TOOL_CLICK, + description: + 'Click the first element matching a CSS selector in the selected page using synthetic element.click(). Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the element to click.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_TYPE, + description: + 'Type text into the first element matching a CSS selector when it is a supported text-editable target. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the target element.', + }, + text: { + type: 'string', + description: 'Required text to assign. May be an empty string.', + }, + clearFirst: { + type: 'boolean', + description: 'When true, clear the current value or content before assigning text.', + }, + }, + required: ['selector', 'text'], + additionalProperties: false, + }, + }, + { + name: TOOL_TAKE_SCREENSHOT, + description: + 'Capture a PNG screenshot from the selected page. Optionally choose a page by index or request fullPage capture.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + fullPage: { + type: 'boolean', + description: 'Optional full-page capture flag.', + }, + }, + additionalProperties: false, + }, + }, + ]; +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${url}`); + } + return await response.json(); +} + +function getHttpUrl() { + const value = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + if (!value) { + throw new Error('Browser MCP is unavailable because CCS_BROWSER_DEVTOOLS_HTTP_URL is missing.'); + } + return value.replace(/\/+$/, ''); +} + +async function listPageTargets() { + const targets = await fetchJson(`${getHttpUrl()}/json/list`); + if (!Array.isArray(targets)) { + throw new Error('Browser MCP received an invalid /json/list response.'); + } + + return targets + .filter((target) => target && typeof target === 'object' && target.type === 'page') + .map((target) => ({ + id: typeof target.id === 'string' ? target.id : '', + title: typeof target.title === 'string' ? target.title : '', + url: typeof target.url === 'string' ? target.url : '', + type: typeof target.type === 'string' ? target.type : 'page', + webSocketDebuggerUrl: + typeof target.webSocketDebuggerUrl === 'string' ? target.webSocketDebuggerUrl : '', + })); +} + +function parsePageIndex(toolArgs) { + if (!toolArgs || !Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex')) { + return 0; + } + + if (!Number.isInteger(toolArgs.pageIndex) || toolArgs.pageIndex < 0) { + throw new Error('pageIndex must be a non-negative integer'); + } + + return toolArgs.pageIndex; +} + +async function getSelectedPage(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const pageIndex = parsePageIndex(toolArgs); + + const page = pages[pageIndex]; + if (!page) { + throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`); + } + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + return { page, pageIndex, pages }; +} + +function formatSessionInfo(pages) { + return [ + '[CCS Browser Session]', + '', + ...pages.map((page, index) => `${index}. ${page.title || ''} | ${page.url || ''}`), + ].join('\n'); +} + +function createEvaluateExpression(kind) { + switch (kind) { + case 'url-title': + return `JSON.stringify({ title: document.title, url: location.href })`; + case 'visible-text': + return `document.body ? document.body.innerText : ''`; + case 'dom-snapshot': + return `document.documentElement ? document.documentElement.outerHTML : ''`; + default: + throw new Error(`Unknown browser evaluation kind: ${kind}`); + } +} + +async function sendCdpCommand(page, method, params = {}) { + const ws = new WebSocket(page.webSocketDebuggerUrl); + const requestId = ++requestCounter; + + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + settled = true; + ws.terminate(); + reject(new Error('Browser MCP timed out waiting for a DevTools response.')); + } + }, CDP_TIMEOUT_MS); + + function settleError(error) { + if (settled) { + return; + } + clearTimeout(timer); + settled = true; + reject(error); + } + + ws.on('open', () => { + ws.send( + JSON.stringify({ + id: requestId, + method, + params, + }) + ); + }); + + ws.on('message', (data) => { + if (settled) { + return; + } + + let message; + try { + message = JSON.parse(data.toString()); + } catch { + return; + } + + if (message.id !== requestId) { + return; + } + + clearTimeout(timer); + settled = true; + ws.close(); + + if (message.error) { + reject(new Error(message.error.message || 'DevTools request failed.')); + return; + } + + resolve(message.result || null); + }); + + ws.on('error', (error) => { + settleError(error); + }); + + ws.on('close', () => { + if (settled) { + return; + } + settleError(new Error('Browser MCP lost the DevTools websocket connection.')); + }); + }); +} + +async function evaluateInPage(page, kind) { + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: createEvaluateExpression(kind), + returnByValue: true, + }); + + const result = response && response.result ? response.result : null; + if (!result) { + throw new Error('Browser MCP received an invalid DevTools evaluation response.'); + } + + if (result.subtype === 'error') { + throw new Error(result.description || 'DevTools evaluation returned an error.'); + } + + return typeof result.value === 'string' ? result.value : result.value ?? ''; +} + +async function evaluateExpression(page, expression) { + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression, + returnByValue: true, + }); + + const result = response && response.result ? response.result : null; + if (!result) { + throw new Error('Browser MCP received an invalid DevTools evaluation response.'); + } + + if (result.subtype === 'error') { + throw new Error(result.description || 'DevTools evaluation returned an error.'); + } + + return typeof result.value === 'string' ? result.value : result.value ?? ''; +} + +function requireNonEmptyString(value, label) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} is required`); + } + return value.trim(); +} + +function requireString(value, label) { + if (typeof value !== 'string') { + throw new Error(`${label} is required`); + } + return value; +} + +function requireValidHttpUrl(value) { + const raw = requireNonEmptyString(value, 'url'); + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new Error('url must be an absolute http or https URL'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('url must be an absolute http or https URL'); + } + + return parsed.toString(); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getNavigationState(page) { + const raw = await evaluateExpression(page, `JSON.stringify({ href: location.href, readyState: document.readyState })`); + const parsed = JSON.parse(raw); + return { + href: typeof parsed.href === 'string' ? parsed.href : '', + readyState: typeof parsed.readyState === 'string' ? parsed.readyState : '', + }; +} + +function isSameDocumentHashNavigation(beforeHref, requestedUrl) { + try { + const before = new URL(beforeHref); + const requested = new URL(requestedUrl); + return ( + before.origin === requested.origin && + before.pathname === requested.pathname && + before.search === requested.search && + before.hash !== requested.hash + ); + } catch { + return false; + } +} + +function isNavigationReady(state, beforeHref, requestedUrl) { + if (state.readyState !== 'interactive' && state.readyState !== 'complete') { + return false; + } + + if (state.href === requestedUrl) { + return true; + } + + if (state.href && state.href !== beforeHref) { + return true; + } + + if (isSameDocumentHashNavigation(beforeHref, requestedUrl) && state.href === requestedUrl) { + return true; + } + + return false; +} + +async function waitForNavigationReady(page, beforeHref, requestedUrl) { + const deadline = Date.now() + CDP_TIMEOUT_MS; + + while (Date.now() <= deadline) { + const state = await getNavigationState(page); + if (isNavigationReady(state, beforeHref, requestedUrl)) { + return state.href; + } + if (Date.now() + NAVIGATION_POLL_INTERVAL_MS > deadline) { + break; + } + await sleep(NAVIGATION_POLL_INTERVAL_MS); + } + + throw new Error(`navigation did not complete for URL: ${requestedUrl}`); +} + +async function handleNavigate(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const url = requireValidHttpUrl(toolArgs.url); + const before = await getNavigationState(page); + const navigateResult = await sendCdpCommand(page, 'Page.navigate', { url }); + if (navigateResult && typeof navigateResult.errorText === 'string' && navigateResult.errorText) { + throw new Error(`navigation failed for URL: ${url}: ${navigateResult.errorText}`); + } + const finalUrl = await waitForNavigationReady(page, before.href, url); + return `pageIndex: ${pageIndex}\nurl: ${finalUrl}\nstatus: navigated`; +} + +async function handleClick(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const element = document.querySelector(selector); + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + if ('disabled' in element && element.disabled) { + throw new Error('element is disabled for selector: ' + selector); + } + element.scrollIntoView({ block: 'center', inline: 'center' }); + element.click(); + return 'ok'; + })()`; + + await evaluateExpression(page, expression); + return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: clicked`; +} + +async function handleType(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const text = requireString(toolArgs.text, 'text'); + const clearFirst = toolArgs.clearFirst === true; + + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const text = JSON.parse(${JSON.stringify(JSON.stringify(text))}); + const clearFirst = ${clearFirst ? 'true' : 'false'}; + const element = document.querySelector(selector); + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + + const dispatchEvents = (target) => { + target.dispatchEvent(new Event('input', { bubbles: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + }; + + const focusTarget = (target) => { + if (typeof target.focus === 'function') { + target.focus(); + } + }; + + let readback = ''; + let expectedValue = ''; + + if (element instanceof HTMLTextAreaElement) { + focusTarget(element); + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; + expectedValue = (clearFirst ? '' : element.value) + text; + if (setter) { + setter.call(element, expectedValue); + } else { + element.value = expectedValue; + } + dispatchEvents(element); + readback = element.value; + } else if (element instanceof HTMLInputElement) { + const supportedTypes = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'password']); + const normalizedType = (element.getAttribute('type') || '').toLowerCase(); + if (!supportedTypes.has(normalizedType)) { + throw new Error('element is not text-editable for selector: ' + selector); + } + focusTarget(element); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + expectedValue = (clearFirst ? '' : element.value) + text; + if (setter) { + setter.call(element, expectedValue); + } else { + element.value = expectedValue; + } + dispatchEvents(element); + readback = element.value; + } else if (element.isContentEditable === true) { + focusTarget(element); + expectedValue = (clearFirst ? '' : (element.textContent || '')) + text; + element.textContent = expectedValue; + dispatchEvents(element); + readback = element.textContent || ''; + } else { + throw new Error('element is not text-editable for selector: ' + selector); + } + + if (readback !== expectedValue) { + throw new Error('typed text verification failed for selector: ' + selector); + } + + return JSON.stringify({ value: readback, typedLength: readback.length }); + })()`; + + const raw = await evaluateExpression(page, expression); + const parsed = JSON.parse(raw); + const typedLength = typeof parsed.typedLength === 'number' ? parsed.typedLength : String(parsed.value || '').length; + return `pageIndex: ${pageIndex}\nselector: ${selector}\ntypedLength: ${typedLength}\nstatus: typed`; +} + +async function handleScreenshot(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const fullPage = toolArgs.fullPage === true; + const response = await sendCdpCommand(page, 'Page.captureScreenshot', { + format: 'png', + captureBeyondViewport: fullPage, + }); + + const data = response && typeof response.data === 'string' ? response.data : ''; + if (!data) { + throw new Error('screenshot capture failed'); + } + + return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; +} + +async function handleToolCall(message) { + const id = message.id; + const params = message.params || {}; + const toolName = params.name || ''; + const toolArgs = params.arguments || {}; + + if (!TOOL_NAMES.includes(toolName)) { + writeError(id, -32602, `Unknown tool: ${toolName}`); + return; + } + + if (!shouldExposeTools()) { + writeResponse(id, { + content: [ + { + type: 'text', + text: 'Browser MCP is unavailable because browser reuse is not configured for this Claude session.', + }, + ], + isError: true, + }); + return; + } + + try { + if (toolName === TOOL_SESSION_INFO) { + const pages = await listPageTargets(); + writeResponse(id, { + content: [{ type: 'text', text: formatSessionInfo(pages) }], + }); + return; + } + + if (toolName === TOOL_NAVIGATE) { + const text = await handleNavigate(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CLICK) { + const text = await handleClick(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_TYPE) { + const text = await handleType(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_TAKE_SCREENSHOT) { + const text = await handleScreenshot(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + const { page, pageIndex } = await getSelectedPage(toolArgs); + + if (toolName === TOOL_URL_TITLE) { + const raw = await evaluateInPage(page, 'url-title'); + const parsed = JSON.parse(raw); + writeResponse(id, { + content: [ + { + type: 'text', + text: `pageIndex: ${pageIndex}\ntitle: ${parsed.title || ''}\nurl: ${parsed.url || ''}`, + }, + ], + }); + return; + } + + if (toolName === TOOL_VISIBLE_TEXT) { + const text = await evaluateInPage(page, 'visible-text'); + writeResponse(id, { + content: [{ type: 'text', text: text || '' }], + }); + return; + } + + const html = await evaluateInPage(page, 'dom-snapshot'); + writeResponse(id, { + content: [{ type: 'text', text: html || '' }], + }); + } catch (error) { + writeResponse(id, { + content: [ + { + type: 'text', + text: `Browser MCP failed: ${(error && error.message) || String(error)}`, + }, + ], + isError: true, + }); + } +} + +async function handleMessage(message) { + if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { + return; + } + + switch (message.method) { + case 'initialize': + writeResponse(message.id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + }); + return; + case 'notifications/initialized': + return; + case 'ping': + writeResponse(message.id, {}); + return; + case 'tools/list': + writeResponse(message.id, { tools: getTools() }); + return; + case 'tools/call': + await handleToolCall(message); + return; + default: + if (message.id !== undefined) { + writeError(message.id, -32601, `Method not found: ${message.method}`); + } + } +} + +function parseMessages() { + while (true) { + let body; + const startsWithLegacyHeaders = inputBuffer + .subarray(0, Math.min(inputBuffer.length, 32)) + .toString('utf8') + .toLowerCase() + .startsWith('content-length:'); + + if (startsWithLegacyHeaders) { + const headerEnd = inputBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return; + } + + const headerText = inputBuffer.subarray(0, headerEnd).toString('utf8'); + const match = headerText.match(/content-length:\s*(\d+)/i); + if (!match) { + inputBuffer = Buffer.alloc(0); + return; + } + + const contentLength = Number.parseInt(match[1], 10); + const messageEnd = headerEnd + 4 + contentLength; + if (inputBuffer.length < messageEnd) { + return; + } + + body = inputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8'); + inputBuffer = inputBuffer.subarray(messageEnd); + } else { + const newlineIndex = inputBuffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + body = inputBuffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + inputBuffer = inputBuffer.subarray(newlineIndex + 1); + if (!body) { + continue; + } + } + + let message; + try { + message = JSON.parse(body); + } catch { + continue; + } + + Promise.resolve(handleMessage(message)).catch((error) => { + if (message && message.id !== undefined) { + writeError(message.id, -32603, (error && error.message) || 'Internal error'); + } + }); + } +} + +process.stdin.on('data', (chunk) => { + inputBuffer = Buffer.concat([inputBuffer, chunk]); + parseMessages(); +}); + +process.stdin.on('error', () => { + process.exit(0); +}); + +['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) => { + process.on(signal, () => process.exit(0)); +}); + +process.stdin.resume(); diff --git a/src/ccs.ts b/src/ccs.ts index a6edc978..e3fdfb9d 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -36,6 +36,13 @@ import { syncImageAnalysisMcpToConfigDir, appendThirdPartyImageAnalysisToolArgs, } from './utils/image-analysis'; +import { + appendBrowserToolArgs, + ensureBrowserMcpOrThrow, + resolveBrowserRuntimeEnv, + resolveConfiguredBrowserProfileDir, + syncBrowserMcpToConfigDir, +} from './utils/browser'; import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks, @@ -69,6 +76,7 @@ import { execClaude } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger } from './services/logging'; +import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides'; // Import target adapter system import { @@ -117,6 +125,15 @@ interface RuntimeReasoningResolution { const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']); +function resolveCodexRuntimeConfigOverrides( + target: ReturnType +): string[] { + if (target !== 'codex') { + return []; + } + return buildCodexBrowserMcpOverrides(); +} + /** * Smart profile detection */ @@ -624,6 +641,7 @@ async function main(): Promise { // For non-claude targets, verify target binary exists once and pass it through. const targetBinaryInfo = targetAdapter?.detectBinary() ?? null; + const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(resolvedTarget); if (resolvedTarget !== 'claude' && !targetBinaryInfo) { const displayName = targetAdapter?.displayName || resolvedTarget; console.error(fail(`${displayName} CLI not found.`)); @@ -867,6 +885,7 @@ async function main(): Promise { model: envVars['ANTHROPIC_MODEL'], }), reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, envVars, }; @@ -982,8 +1001,16 @@ async function main(): Promise { // Settings-based profiles (glm, glmt) are third-party providers const imageAnalysisMcpReady = resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; + let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; + const browserProfileDir = + resolvedTarget === 'claude' + ? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR) + : undefined; if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); + if (browserProfileDir) { + ensureBrowserMcpOrThrow(); + } } // Display WebSearch status (single line, equilibrium UX) @@ -1007,6 +1034,15 @@ async function main(): Promise { const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + if ( + browserProfileDir && + inheritedClaudeConfigDir && + !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) + ) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } const expandedSettingsPath = resolvedSettingsPath ?? (profileInfo.settingsPath @@ -1208,12 +1244,21 @@ async function main(): Promise { // Explicitly inject effective settings env vars so stale ANTHROPIC_* // values from prior sessions cannot leak into the active profile. + if (browserProfileDir) { + browserRuntimeEnv = { + ...(await resolveBrowserRuntimeEnv({ + profileDir: browserProfileDir, + })), + }; + } + const envVars: NodeJS.ProcessEnv = { ...globalEnv, ...settingsEnv, ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, + ...(browserRuntimeEnv || {}), CCS_PROFILE_TYPE: 'settings', }; @@ -1238,6 +1283,7 @@ async function main(): Promise { model: settingsEnv['ANTHROPIC_MODEL'], }), reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, envVars, }; await adapter.prepareCredentials(creds); @@ -1254,10 +1300,13 @@ async function main(): Promise { const imageAnalysisArgs = imageAnalysisMcpReady ? appendThirdPartyImageAnalysisToolArgs(remainingArgs) : remainingArgs; + const browserArgs = browserRuntimeEnv + ? appendBrowserToolArgs(imageAnalysisArgs) + : imageAnalysisArgs; const launchArgs = [ '--settings', expandedSettingsPath, - ...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs), + ...appendThirdPartyWebSearchToolArgs(browserArgs), ]; const traceEnv = createWebSearchTraceContext({ launcher: 'ccs.settings-profile', @@ -1314,8 +1363,22 @@ async function main(): Promise { CCS_WEBSEARCH_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', }; + let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; + const browserProfileDir = + resolvedTarget === 'claude' + ? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR) + : undefined; if (resolvedTarget === 'claude') { + if (browserProfileDir) { + ensureBrowserMcpOrThrow(); + browserRuntimeEnv = { + ...(await resolveBrowserRuntimeEnv({ + profileDir: browserProfileDir, + })), + }; + Object.assign(envVars, browserRuntimeEnv); + } const defaultContinuityInheritance = await resolveProfileContinuityInheritance({ profileName: profileInfo.name, profileType: profileInfo.type, @@ -1330,6 +1393,14 @@ async function main(): Promise { } if (defaultContinuityInheritance.claudeConfigDir) { envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; + if ( + browserProfileDir && + !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) + ) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } } } @@ -1355,6 +1426,8 @@ async function main(): Promise { model: process.env['ANTHROPIC_MODEL'], }), reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + browserRuntimeEnv, }; if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) { console.error( @@ -1377,7 +1450,7 @@ async function main(): Promise { } const launchArgs = resolveNativeClaudeLaunchArgs( - remainingArgs, + browserRuntimeEnv ? appendBrowserToolArgs(remainingArgs) : remainingArgs, 'default', envVars.CLAUDE_CONFIG_DIR ); diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index a6233879..bafcea63 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -74,6 +74,8 @@ export interface ProxyChainConfig { claudeConfigDir?: string; /** Execution-aware image analysis env prepared by the caller */ imageAnalysisEnv?: Record; + /** Optional browser runtime env for Claude browser MCP reuse. */ + browserRuntimeEnv?: Record; } interface CliproxyImageAnalysisDeps { @@ -240,6 +242,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record key.startsWith('CCS_BROWSER_')) + .sort() + .join(',')} ws=${env.CCS_BROWSER_DEVTOOLS_WS_URL || ''}` + ); + } logEnvironment(env, webSearchEnv, verbose); if (imageAnalysisWarning) { console.error(info(imageAnalysisWarning)); @@ -1222,10 +1259,13 @@ export async function execClaudeWithCLIProxy( const imageAnalysisArgs = imageAnalysisMcpReady ? appendThirdPartyImageAnalysisToolArgs(claudeArgs) : claudeArgs; + const browserArgs = browserRuntimeEnv + ? appendBrowserToolArgs(imageAnalysisArgs) + : imageAnalysisArgs; const launchArgs = [ '--settings', settingsPath, - ...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs), + ...appendThirdPartyWebSearchToolArgs(browserArgs), ]; const traceEnv = createWebSearchTraceContext({ launcher: 'cliproxy.executor', diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 77462b0e..aae1912f 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -229,6 +229,8 @@ export interface ExecutorConfig { profileName?: string; /** Optional inherited continuity directory from mapped account profile */ claudeConfigDir?: string; + /** Optional browser runtime env for Claude browser MCP reuse. */ + browserRuntimeEnv?: Record; } /** diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index db206d45..650cd07d 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -121,9 +121,10 @@ export function loadMigrationCheckData(): MigrationCheckData { if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) { const legacyProfiles = legacyConfig.profiles as Record; + const unifiedProfiles = unifiedConfig.profiles ?? {}; for (const profileName of Object.keys(legacyProfiles)) { const targetProfileName = resolveAliasToCanonical(profileName); - if (!unifiedConfig.profiles[targetProfileName]) { + if (!unifiedProfiles[targetProfileName]) { needsMigration = true; break; } diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index b9729459..8fa15198 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -14,7 +14,7 @@ import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; import type { AccountContextPolicy } from '../auth/account-context'; import { getCcsDir, getCcsHome } from '../utils/config-manager'; -const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis']); +const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis', 'ccs-browser']); /** Options for instance creation */ export interface InstanceOptions { @@ -252,9 +252,12 @@ class InstanceManager { } instanceContent.mcpServers = mergedMcpServers; + const fileMode = fs.existsSync(instanceClaudeJson) + ? fs.statSync(instanceClaudeJson).mode & 0o777 + : 0o600; fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), { encoding: 'utf8', - mode: 0o600, + mode: fileMode, }); return true; } catch (error) { diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index 4ba0c40c..9dc4f08a 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -12,6 +12,7 @@ import type { ProfileType } from '../types/profile'; import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from '../utils/shell-executor'; import { ErrorManager } from '../utils/error-manager'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; +import { appendBrowserToolArgs } from '../utils/browser'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { runCleanup } from '../errors'; @@ -32,8 +33,16 @@ export class ClaudeAdapter implements TargetAdapter { // No-op: Claude receives credentials via environment variables } - buildArgs(_profile: string, userArgs: string[]): string[] { - return userArgs; + buildArgs( + _profile: string, + userArgs: string[], + options?: { + creds?: TargetCredentials; + profileType?: ProfileType; + binaryInfo?: TargetBinaryInfo; + } + ): string[] { + return options?.creds?.browserRuntimeEnv ? appendBrowserToolArgs(userArgs) : userArgs; } buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv { @@ -51,6 +60,9 @@ export class ClaudeAdapter implements TargetAdapter { if (creds.envVars) { Object.assign(env, creds.envVars); } + if (creds.browserRuntimeEnv) { + Object.assign(env, creds.browserRuntimeEnv); + } if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl; if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey; diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 09a278d7..e7166fdd 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -188,20 +188,23 @@ export class CodexAdapter implements TargetAdapter { const profileType = options?.profileType || 'default'; const creds = options?.creds; const reasoningOverride = normalizeCodexReasoningOverride(creds?.reasoningOverride); + const runtimeConfigOverrides = creds?.runtimeConfigOverrides ?? []; if (profileType === 'default') { + const overrides = [...runtimeConfigOverrides]; if (reasoningOverride) { - if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { + overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`); + } + if (overrides.length === 0) { + return userArgs; + } + if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { + if (reasoningOverride) { throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo)); } - return [ - ...buildConfigOverrideArgs([ - `model_reasoning_effort=${formatTomlString(reasoningOverride)}`, - ]), - ...userArgs, - ]; + return userArgs; } - return userArgs; + return [...buildConfigOverrideArgs(overrides), ...userArgs]; } if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { @@ -233,6 +236,8 @@ export class CodexAdapter implements TargetAdapter { overrides.push(`model=${formatTomlString(creds.model)}`); } + overrides.push(...runtimeConfigOverrides); + if (reasoningOverride) { overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`); } diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index abf5c3ce..fcbe08b1 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -29,8 +29,12 @@ export interface TargetCredentials { * Targets may ignore this when unsupported. */ reasoningOverride?: string | number; + /** Target-native runtime config overrides (for example Codex -c key=value entries). */ + runtimeConfigOverrides?: string[]; /** Additional env vars from profile resolution (websearch, hooks, etc.) */ envVars?: NodeJS.ProcessEnv; + /** Runtime browser reuse env passed through to Claude launches when browser MCP is active. */ + browserRuntimeEnv?: NodeJS.ProcessEnv; } /** diff --git a/src/utils/browser-codex-overrides.ts b/src/utils/browser-codex-overrides.ts new file mode 100644 index 00000000..02cdf8cb --- /dev/null +++ b/src/utils/browser-codex-overrides.ts @@ -0,0 +1,28 @@ +const CODEX_BROWSER_MCP_SERVER_NAME = 'ccs_browser'; +const DEFAULT_BROWSER_TOOL_TIMEOUT_SEC = 30; +const PLAYWRIGHT_MCP_PACKAGE = '@playwright/mcp@0.0.70'; + +function formatTomlString(value: string): string { + return JSON.stringify(value); +} + +function formatTomlArray(values: string[]): string { + return JSON.stringify(values); +} + +function getNpxCommand(): string { + return process.platform === 'win32' ? 'npx.cmd' : 'npx'; +} + +export function getCodexBrowserMcpServerName(): string { + return CODEX_BROWSER_MCP_SERVER_NAME; +} + +export function buildCodexBrowserMcpOverrides(): string[] { + return [ + `mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.command=${formatTomlString(getNpxCommand())}`, + `mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.args=${formatTomlArray(['-y', PLAYWRIGHT_MCP_PACKAGE])}`, + `mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.enabled=true`, + `mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.tool_timeout_sec=${DEFAULT_BROWSER_TOOL_TIMEOUT_SEC}`, + ]; +} diff --git a/src/utils/browser/chrome-reuse.ts b/src/utils/browser/chrome-reuse.ts new file mode 100644 index 00000000..8fd77903 --- /dev/null +++ b/src/utils/browser/chrome-reuse.ts @@ -0,0 +1,238 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { expandPath } from '../helpers'; + +export interface BrowserReuseOptions { + profileDir?: string; + devtoolsPort?: string; +} + +export interface BrowserRuntimeEnv { + CCS_BROWSER_USER_DATA_DIR: string; + CCS_BROWSER_DEVTOOLS_HOST: string; + CCS_BROWSER_DEVTOOLS_PORT: string; + CCS_BROWSER_DEVTOOLS_HTTP_URL: string; + CCS_BROWSER_DEVTOOLS_WS_URL: string; +} + +const DEVTOOLS_HOST = '127.0.0.1'; +const DEVTOOLS_ACTIVE_PORT_FILE = 'DevToolsActivePort'; + +export function resolveDefaultChromeUserDataDir(platform = process.platform): string { + switch (platform) { + case 'darwin': + return path.join( + process.env.HOME || process.env.USERPROFILE || '', + 'Library', + 'Application Support', + 'Google', + 'Chrome' + ); + case 'linux': + return path.join( + process.env.HOME || process.env.USERPROFILE || '', + '.config', + 'google-chrome' + ); + case 'win32': { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + throw new Error( + 'LOCALAPPDATA is required to resolve the default Chrome user-data-dir on Windows' + ); + } + return path.join(localAppData, 'Google', 'Chrome', 'User Data'); + } + default: + return path.join( + process.env.HOME || process.env.USERPROFILE || '', + '.config', + 'google-chrome' + ); + } +} + +export function resolveConfiguredBrowserProfileDir(profileDir?: string): string | undefined { + if (profileDir?.trim()) { + return expandPath(profileDir); + } + + try { + const defaultUserDataDir = resolveDefaultChromeUserDataDir(); + return fs.existsSync(path.join(defaultUserDataDir, DEVTOOLS_ACTIVE_PORT_FILE)) + ? defaultUserDataDir + : undefined; + } catch { + return undefined; + } +} + +export async function resolveBrowserRuntimeEnv( + options: BrowserReuseOptions +): Promise { + const requestedProfileDir = options.profileDir || resolveDefaultChromeUserDataDir(); + const userDataDir = expandPath(requestedProfileDir); + + const directoryStat = await safeStat(userDataDir); + if (!directoryStat?.isDirectory()) { + throw new Error(`Chrome profile directory is invalid: ${userDataDir}`); + } + + const metadataPath = path.join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE); + const port = await resolveDevToolsPort({ + userDataDir, + metadataPath, + explicitPort: options.devtoolsPort || process.env.CCS_BROWSER_DEVTOOLS_PORT, + }); + const httpUrl = `http://${DEVTOOLS_HOST}:${port}`; + const versionUrl = `${httpUrl}/json/version`; + + let versionPayload: unknown; + try { + const response = await fetch(versionUrl); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + versionPayload = await response.json(); + } catch { + throw new Error(`Chrome DevTools endpoint is unreachable: ${httpUrl}`); + } + + const websocketUrl = + typeof versionPayload === 'object' && + versionPayload !== null && + typeof (versionPayload as Record).webSocketDebuggerUrl === 'string' + ? (versionPayload as Record).webSocketDebuggerUrl + : ''; + + if (!websocketUrl) { + throw new Error(`Chrome DevTools endpoint did not provide a websocket target: ${versionUrl}`); + } + + return { + CCS_BROWSER_USER_DATA_DIR: userDataDir, + CCS_BROWSER_DEVTOOLS_HOST: DEVTOOLS_HOST, + CCS_BROWSER_DEVTOOLS_PORT: port, + CCS_BROWSER_DEVTOOLS_HTTP_URL: httpUrl, + CCS_BROWSER_DEVTOOLS_WS_URL: websocketUrl, + }; +} + +function parseDevToolsPort(metadataPath: string, metadata: string): string { + const [rawPort] = metadata + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (!rawPort || !/^\d+$/.test(rawPort)) { + throw new Error(`Chrome reuse metadata is invalid: ${metadataPath}`); + } + + const port = Number.parseInt(rawPort, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Chrome reuse metadata is invalid: ${metadataPath}`); + } + + return String(port); +} + +interface ResolveDevToolsPortOptions { + userDataDir: string; + metadataPath: string; + explicitPort?: string; +} + +async function resolveDevToolsPort(options: ResolveDevToolsPortOptions): Promise { + if (options.explicitPort?.trim()) { + return parsePortValue(options.explicitPort.trim(), 'CCS_BROWSER_DEVTOOLS_PORT'); + } + + let metadata: string | undefined; + try { + metadata = await fs.promises.readFile(options.metadataPath, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code && code !== 'ENOENT') { + throw new Error(`Chrome reuse metadata is unreadable: ${options.metadataPath}`); + } + } + + if (metadata !== undefined) { + return parseDevToolsPort(options.metadataPath, metadata); + } + + const discoveredPort = discoverDevToolsPortFromChromeProcess(options.userDataDir); + if (discoveredPort) { + return discoveredPort; + } + + throw new Error(`Chrome reuse metadata not found: ${options.metadataPath}`); +} + +function parsePortValue(rawPort: string, sourceLabel: string): string { + if (!/^\d+$/.test(rawPort)) { + throw new Error(`Chrome reuse metadata is invalid: ${sourceLabel}`); + } + + const port = Number.parseInt(rawPort, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Chrome reuse metadata is invalid: ${sourceLabel}`); + } + + return String(port); +} + +function discoverDevToolsPortFromChromeProcess(userDataDir: string): string | undefined { + try { + const processList = execFileSync('ps', ['-axww', '-o', 'command='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const lines = processList + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const normalizedDir = normalizePathForComparison(userDataDir); + + for (const line of lines) { + if (!line.includes('Chrome') && !line.includes('chromium')) { + continue; + } + if (!matchesUserDataDir(line, normalizedDir)) { + continue; + } + + const match = line.match(/--remote-debugging-port=(\d+)/); + if (match?.[1]) { + return parsePortValue(match[1], 'process-list'); + } + } + } catch { + return undefined; + } + + return undefined; +} + +function matchesUserDataDir(commandLine: string, normalizedDir: string): boolean { + const candidates = [ + `--user-data-dir=${normalizedDir}`, + `--user-data-dir="${normalizedDir}"`, + `--user-data-dir='${normalizedDir}'`, + ]; + const normalizedCommand = normalizePathForComparison(commandLine); + return candidates.some((candidate) => normalizedCommand.includes(candidate)); +} + +function normalizePathForComparison(value: string): string { + return process.platform === 'win32' ? value.toLowerCase() : value; +} + +async function safeStat(targetPath: string): Promise { + try { + return await fs.promises.stat(targetPath); + } catch { + return null; + } +} diff --git a/src/utils/browser/claude-tool-args.ts b/src/utils/browser/claude-tool-args.ts new file mode 100644 index 00000000..d0e3338b --- /dev/null +++ b/src/utils/browser/claude-tool-args.ts @@ -0,0 +1,22 @@ +import { + hasExactFlagValue as hasExactClaudeFlagValue, + splitArgsAtTerminator as splitClaudeArgsAtTerminator, +} from '../claude-tool-args'; + +const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; +const BROWSER_STEERING_PROMPT = + 'For DOM/screenshots/elements/page actions, prefer the CCS MCP Browser tool, reuse the configured running Chrome context whenever possible, and if the tool or context is unavailable, explain that clearly instead of pretending page state is available.'; + +function ensureBrowserSteeringPrompt(args: string[]): string[] { + const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args); + + if (hasExactClaudeFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, BROWSER_STEERING_PROMPT)) { + return args; + } + + return [...optionArgs, APPEND_SYSTEM_PROMPT_FLAG, BROWSER_STEERING_PROMPT, ...trailingArgs]; +} + +export function appendBrowserToolArgs(args: string[]): string[] { + return ensureBrowserSteeringPrompt(args); +} diff --git a/src/utils/browser/index.ts b/src/utils/browser/index.ts new file mode 100644 index 00000000..be30da8e --- /dev/null +++ b/src/utils/browser/index.ts @@ -0,0 +1,25 @@ +/** + * Browser Utilities + */ + +export { + getBrowserMcpServerName, + getBrowserMcpServerPath, + installBrowserMcpServer, + ensureBrowserMcpConfig, + ensureBrowserMcp, + uninstallBrowserMcpServer, + removeBrowserMcpConfig, + uninstallBrowserMcp, + syncBrowserMcpToConfigDir, + ensureBrowserMcpOrThrow, +} from './mcp-installer'; + +export { appendBrowserToolArgs } from './claude-tool-args'; + +export { + resolveBrowserRuntimeEnv, + resolveDefaultChromeUserDataDir, + resolveConfiguredBrowserProfileDir, +} from './chrome-reuse'; +export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse'; diff --git a/src/utils/browser/mcp-installer.ts b/src/utils/browser/mcp-installer.ts new file mode 100644 index 00000000..e5e8552c --- /dev/null +++ b/src/utils/browser/mcp-installer.ts @@ -0,0 +1,375 @@ +/** + * Browser MCP installer and ~/.claude.json provisioning. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; +import { InstanceManager } from '../../management/instance-manager'; +import { getClaudeUserConfigPath } from '../claude-config-path'; +import { getCcsDir } from '../config-manager'; + +const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs'; +const BROWSER_MCP_SERVER_NAME = 'ccs-browser'; + +interface ClaudeUserConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +interface ManagedBrowserMcpConfig { + type: 'stdio'; + command: 'node'; + args: [string]; + env: Record; +} + +function resolvePackageRoot(fromPath: string): string | null { + let currentDir = path.dirname(fromPath); + while (true) { + if (fs.existsSync(path.join(currentDir, 'package.json'))) { + return currentDir; + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +function getBrowserMcpServerEnv(): Record { + const sourcePath = resolveBundledServerSourcePath(); + if (!sourcePath) { + return {}; + } + + const packageRoot = resolvePackageRoot(sourcePath); + if (!packageRoot) { + return {}; + } + + const nodeModulesPath = path.join(packageRoot, 'node_modules'); + return fs.existsSync(nodeModulesPath) ? { NODE_PATH: nodeModulesPath } : {}; +} + +function getCcsMcpDir(): string { + return path.join(getCcsDir(), 'mcp'); +} + +export function getBrowserMcpServerName(): string { + return BROWSER_MCP_SERVER_NAME; +} + +export function getBrowserMcpServerPath(): string { + return path.join(getCcsMcpDir(), BROWSER_MCP_SERVER); +} + +function hasMatchingContents(sourcePath: string, destinationPath: string): boolean { + if (!fs.existsSync(destinationPath)) { + return false; + } + + const source = fs.readFileSync(sourcePath); + try { + const destination = fs.readFileSync(destinationPath); + return source.equals(destination); + } catch { + return false; + } +} + +function getTempPath(targetPath: string): string { + const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${targetPath}.${suffix}.tmp`; +} + +function resolveBundledServerSourcePath(): string | null { + const possiblePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER), + path.join(__dirname, '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER), + path.join(__dirname, '..', 'lib', 'mcp', BROWSER_MCP_SERVER), + ]; + + for (const candidate of possiblePaths) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null { + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return null; + } + return parsed as ClaudeUserConfig; + } catch { + return null; + } +} + +function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean { + const tempPath = getTempPath(configPath); + const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600; + + try { + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.chmodSync(tempPath, fileMode); + fs.renameSync(tempPath, configPath); + return true; + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} + +function withClaudeUserConfigLock(configPath: string, callback: () => T): T { + const configDir = path.dirname(configPath); + const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`); + let release: (() => void) | undefined; + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + } + + if (!fs.existsSync(lockTarget)) { + fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 }); + } + + try { + release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void; + return callback(); + } finally { + if (release) { + try { + release(); + } catch { + // Best-effort release. + } + } + } +} + +function isLockUnavailableError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === 'ELOCKED' || code === 'ENOTACQUIRED'; +} + +function removeManagedServerConfig(configPath: string): boolean { + if (!fs.existsSync(configPath)) { + return false; + } + + try { + return withClaudeUserConfigLock(configPath, () => { + const config = readClaudeUserConfig(configPath); + if (config === null) { + return false; + } + + const existingServers = + config.mcpServers && + typeof config.mcpServers === 'object' && + !Array.isArray(config.mcpServers) + ? { ...(config.mcpServers as Record) } + : {}; + + if (!(BROWSER_MCP_SERVER_NAME in existingServers)) { + return false; + } + + delete existingServers[BROWSER_MCP_SERVER_NAME]; + + const nextConfig: ClaudeUserConfig = { ...config }; + if (Object.keys(existingServers).length === 0) { + delete nextConfig.mcpServers; + } else { + nextConfig.mcpServers = existingServers; + } + + try { + return writeClaudeUserConfig(configPath, nextConfig); + } catch { + return false; + } + }); + } catch (error) { + if (isLockUnavailableError(error)) { + return false; + } + throw error; + } +} + +export function installBrowserMcpServer(): boolean { + const sourcePath = resolveBundledServerSourcePath(); + if (!sourcePath) { + return false; + } + + const mcpDir = getCcsMcpDir(); + if (!fs.existsSync(mcpDir)) { + fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 }); + } + + const serverPath = getBrowserMcpServerPath(); + const sourceMode = fs.statSync(sourcePath).mode & 0o777; + if (hasMatchingContents(sourcePath, serverPath)) { + if ((fs.statSync(serverPath).mode & 0o777) !== sourceMode) { + fs.chmodSync(serverPath, sourceMode); + } + return true; + } + + const tempPath = getTempPath(serverPath); + + try { + fs.copyFileSync(sourcePath, tempPath); + fs.chmodSync(tempPath, sourceMode); + try { + fs.renameSync(tempPath, serverPath); + } catch (renameError) { + const errorCode = (renameError as NodeJS.ErrnoException).code; + if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') { + throw renameError; + } + + if (!hasMatchingContents(sourcePath, serverPath)) { + fs.copyFileSync(tempPath, serverPath); + fs.chmodSync(serverPath, sourceMode); + } + } + return true; + } catch { + return false; + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} + +export function ensureBrowserMcpConfig(): boolean { + const claudeUserConfigPath = getClaudeUserConfigPath(); + const claudeUserConfigDir = path.dirname(claudeUserConfigPath); + if (!fs.existsSync(claudeUserConfigDir)) { + fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 }); + } + + const desiredServerConfig: ManagedBrowserMcpConfig = { + type: 'stdio', + command: 'node', + args: [getBrowserMcpServerPath()], + env: getBrowserMcpServerEnv(), + }; + + try { + return withClaudeUserConfigLock(claudeUserConfigPath, () => { + const config = readClaudeUserConfig(claudeUserConfigPath); + if (config === null) { + return false; + } + + const existingServers = + config.mcpServers && + typeof config.mcpServers === 'object' && + !Array.isArray(config.mcpServers) + ? (config.mcpServers as Record) + : {}; + const currentConfig = existingServers[BROWSER_MCP_SERVER_NAME]; + if ( + typeof currentConfig === 'object' && + currentConfig !== null && + JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig) + ) { + return true; + } + + const nextConfig: ClaudeUserConfig = { + ...config, + mcpServers: { + ...existingServers, + [BROWSER_MCP_SERVER_NAME]: desiredServerConfig, + }, + }; + + try { + writeClaudeUserConfig(claudeUserConfigPath, nextConfig); + return true; + } catch { + return false; + } + }); + } catch (error) { + if (isLockUnavailableError(error)) { + return false; + } + throw error; + } +} + +export function ensureBrowserMcp(): boolean { + const installed = installBrowserMcpServer(); + const configured = installed && ensureBrowserMcpConfig(); + return installed && configured; +} + +export function syncBrowserMcpToConfigDir(claudeConfigDir: string | undefined): boolean { + if (!claudeConfigDir) { + return false; + } + + return new InstanceManager().syncMcpServers(claudeConfigDir); +} + +export function uninstallBrowserMcpServer(): boolean { + const serverPath = getBrowserMcpServerPath(); + if (!fs.existsSync(serverPath)) { + return false; + } + + try { + fs.unlinkSync(serverPath); + return true; + } catch { + return false; + } +} + +export function removeBrowserMcpConfig(): boolean { + let removed = removeManagedServerConfig(getClaudeUserConfigPath()); + + const instanceManager = new InstanceManager(); + for (const instanceName of instanceManager.listInstances()) { + const instancePath = instanceManager.getInstancePath(instanceName); + const instanceClaudeConfigPath = path.join(instancePath, '.claude.json'); + removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed; + } + + return removed; +} + +export function uninstallBrowserMcp(): boolean { + const removedConfig = removeBrowserMcpConfig(); + const removedServer = uninstallBrowserMcpServer(); + return removedConfig || removedServer; +} + +export function ensureBrowserMcpOrThrow(): boolean { + const ready = ensureBrowserMcp(); + if (!ready) { + throw new Error('Browser MCP is enabled, but CCS could not prepare the local browser tool.'); + } + + return ready; +} diff --git a/src/utils/claude-tool-args.ts b/src/utils/claude-tool-args.ts new file mode 100644 index 00000000..31984ed1 --- /dev/null +++ b/src/utils/claude-tool-args.ts @@ -0,0 +1,46 @@ +export function splitArgsAtTerminator(args: string[]): { + optionArgs: string[]; + trailingArgs: string[]; +} { + const terminatorIndex = args.indexOf('--'); + if (terminatorIndex === -1) { + return { optionArgs: args, trailingArgs: [] }; + } + + return { + optionArgs: args.slice(0, terminatorIndex), + trailingArgs: args.slice(terminatorIndex), + }; +} + +export function getImmediateFlagValue(args: string[], index: number): string | null { + const value = args[index + 1]; + if (value === undefined || value === '--' || value.startsWith('--')) { + return null; + } + return value; +} + +export function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === flag) { + const value = getImmediateFlagValue(args, index); + if (value === expectedValue) { + return true; + } + continue; + } + + if (arg === `${flag}=${expectedValue}`) { + return true; + } + + if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) { + return true; + } + } + + return false; +} diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts index f1d0cf0b..267e731a 100644 --- a/src/utils/image-analysis/claude-tool-args.ts +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -2,58 +2,21 @@ * Claude launch argument helpers for first-class Image Analysis. */ +import { + hasExactFlagValue as hasExactClaudeFlagValue, + splitArgsAtTerminator as splitClaudeArgsAtTerminator, +} from '../claude-tool-args'; + const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; const IMAGE_ANALYSIS_STEERING_PROMPT = 'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.'; -function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } { - const terminatorIndex = args.indexOf('--'); - if (terminatorIndex === -1) { - return { optionArgs: args, trailingArgs: [] }; - } - - return { - optionArgs: args.slice(0, terminatorIndex), - trailingArgs: args.slice(terminatorIndex), - }; -} - -function getImmediateFlagValue(args: string[], index: number): string | null { - const value = args[index + 1]; - if (value === undefined || value === '--' || value.startsWith('--')) { - return null; - } - return value; -} - -function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - - if (arg === flag) { - const value = getImmediateFlagValue(args, index); - if (value === expectedValue) { - return true; - } - continue; - } - - if (arg === `${flag}=${expectedValue}`) { - return true; - } - - if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) { - return true; - } - } - - return false; -} - function ensureImageAnalysisSteeringPrompt(args: string[]): string[] { - const { optionArgs, trailingArgs } = splitArgsAtTerminator(args); + const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args); - if (hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)) { + if ( + hasExactClaudeFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT) + ) { return args; } diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index 15028178..254353a2 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -2,6 +2,12 @@ * Claude launch argument helpers for third-party WebSearch. */ +import { + getImmediateFlagValue, + hasExactFlagValue as hasExactClaudeFlagValue, + splitArgsAtTerminator as splitClaudeArgsAtTerminator, +} from '../claude-tool-args'; + const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; const DISALLOWED_TOOLS_FLAG = '--disallowedTools'; const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; @@ -23,26 +29,6 @@ function mergeToolValues(rawValues: string[], toolName: string): string { return merged.join(','); } -function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } { - const terminatorIndex = args.indexOf('--'); - if (terminatorIndex === -1) { - return { optionArgs: args, trailingArgs: [] }; - } - - return { - optionArgs: args.slice(0, terminatorIndex), - trailingArgs: args.slice(terminatorIndex), - }; -} - -function getImmediateFlagValue(args: string[], index: number): string | null { - const value = args[index + 1]; - if (value === undefined || value === '--' || value.startsWith('--')) { - return null; - } - return value; -} - function hasToolInFlag(args: string[], flag: string, toolName: string): boolean { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -68,32 +54,8 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean return false; } -function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - - if (arg === flag) { - const value = getImmediateFlagValue(args, index); - if (value === expectedValue) { - return true; - } - continue; - } - - if (arg === `${flag}=${expectedValue}`) { - return true; - } - - if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) { - return true; - } - } - - return false; -} - function ensureDisallowedNativeWebSearchTool(args: string[]): string[] { - const { optionArgs, trailingArgs } = splitArgsAtTerminator(args); + const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args); if (hasToolInFlag(optionArgs, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) { return args; @@ -132,10 +94,14 @@ function ensureDisallowedNativeWebSearchTool(args: string[]): string[] { } function ensureWebSearchSteeringPrompt(args: string[]): string[] { - const { optionArgs, trailingArgs } = splitArgsAtTerminator(args); + const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args); if ( - hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, THIRD_PARTY_WEBSEARCH_STEERING_PROMPT) + hasExactClaudeFlagValue( + optionArgs, + APPEND_SYSTEM_PROMPT_FLAG, + THIRD_PARTY_WEBSEARCH_STEERING_PROMPT + ) ) { return args; } diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts new file mode 100644 index 00000000..b0d3eb5a --- /dev/null +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -0,0 +1,827 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { spawn } from 'child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import * as http from 'node:http'; +import { WebSocketServer } from 'ws'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type JsonRpcMessage = Record; + +type MockPageState = { + id: string; + title: string; + currentUrl: string; + readyStateSequence?: string[]; + visibleText?: string; + domSnapshot?: string; + navigate?: Record; + click?: Record; + screenshot?: { + data?: string; + lastCaptureBeyondViewport?: boolean; + }; + type?: Record< + string, + { + kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable'; + inputType?: string; + value?: string; + expectedValueWhenClearFirst?: string; + expectedValueWhenAppend?: string; + requireFocus?: boolean; + focused?: boolean; + } + >; +}; + +const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); + +function encodeMessage(message: unknown): string { + return `${JSON.stringify(message)}\n`; +} + +function collectResponses( + child: ReturnType, + expectedCount: number +): Promise>> { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const responses: Array> = []; + const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 7000); + + function tryParse(): void { + while (true) { + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + const body = buffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + buffer = buffer.subarray(newlineIndex + 1); + if (!body) { + continue; + } + + responses.push(JSON.parse(body) as Record); + if (responses.length >= expectedCount) { + clearTimeout(timer); + resolve(responses); + return; + } + } + } + + if (!child.stdout) { + clearTimeout(timer); + reject(new Error('MCP child stdout is unavailable')); + return; + } + + child.stdout.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + try { + tryParse(); + } catch (error) { + clearTimeout(timer); + reject(error); + } + }); + + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function getResponseText(message: Record | undefined): string { + const result = (message?.result as { content?: Array<{ text?: string }> }) || {}; + return result.content?.[0]?.text || ''; +} + +function parseJsonArgument(expression: string, key: string): string | undefined { + const marker = `const ${key} = JSON.parse(`; + const start = expression.indexOf(marker); + if (start === -1) { + return undefined; + } + + const quoteStart = start + marker.length; + const quote = expression[quoteStart]; + if (quote !== '"' && quote !== "'") { + return undefined; + } + + let index = quoteStart + 1; + while (index < expression.length) { + if (expression[index] === '\\') { + index += 2; + continue; + } + if (expression[index] === quote) { + const encoded = expression.slice(quoteStart, index + 1); + const decoded = JSON.parse(encoded) as string; + if (!decoded.startsWith('"') && !decoded.startsWith("'")) { + return undefined; + } + return JSON.parse(decoded) as string; + } + index += 1; + } + + return undefined; +} + +function createMockBrowser(pagesInput: MockPageState[]) { + let tempDir = ''; + let httpServer: http.Server | null = null; + let wsServer: WebSocketServer | null = null; + const pageStates = new Map(); + + for (const [index, page] of pagesInput.entries()) { + pageStates.set(`/devtools/page/${index + 1}`, { + visibleText: 'Hello from visible text', + domSnapshot: 'Hello from DOM snapshot', + ...page, + }); + } + + async function start() { + tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-')); + + const port = await new Promise((resolve, reject) => { + httpServer = http.createServer((req, res) => { + if (req.url === '/json/list') { + const address = httpServer?.address(); + const serverPort = address && typeof address !== 'string' ? address.port : 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify( + Array.from(pageStates.entries()).map(([wsPath, page]) => ({ + id: page.id, + type: 'page', + title: page.title, + url: page.currentUrl, + webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, + })) + ) + ); + return; + } + res.writeHead(404); + res.end('not found'); + }); + + httpServer.once('error', reject); + httpServer.listen(0, '127.0.0.1', () => { + const address = httpServer?.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to resolve mock browser server port')); + return; + } + resolve(address.port); + }); + }); + + wsServer = new WebSocketServer({ server: httpServer as http.Server }); + wsServer.on('connection', (socket, request) => { + const page = pageStates.get(request.url || ''); + if (!page) { + socket.close(); + return; + } + + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) as { + id: number; + method: string; + params?: Record; + }; + + function reply(result: unknown): void { + socket.send(JSON.stringify({ id: message.id, result })); + } + + function replyError(errorText: string): void { + socket.send(JSON.stringify({ id: message.id, result: { result: { subtype: 'error', description: errorText } } })); + } + + if (message.method === 'Page.navigate') { + const targetUrl = typeof message.params?.url === 'string' ? message.params.url : ''; + const navigatePlan = page.navigate?.[targetUrl]; + if (navigatePlan?.errorText) { + reply({ frameId: 'frame-1', errorText: navigatePlan.errorText }); + return; + } + if (navigatePlan) { + page.currentUrl = navigatePlan.finalUrl; + page.readyStateSequence = [...(navigatePlan.readyStates || ['loading', 'interactive'])]; + } + reply({ frameId: 'frame-1' }); + return; + } + + if (message.method === 'Page.captureScreenshot') { + if (!page.screenshot) { + reply({ data: '' }); + return; + } + page.screenshot.lastCaptureBeyondViewport = message.params?.captureBeyondViewport === true; + reply({ data: page.screenshot.data || '' }); + return; + } + + if (message.method !== 'Runtime.evaluate') { + return; + } + + const expression = String(message.params?.expression || ''); + + if (expression.includes('document.title') && expression.includes('location.href')) { + reply({ result: { type: 'string', value: JSON.stringify({ title: page.title, url: page.currentUrl }) } }); + return; + } + + if (expression.includes('document.body ? document.body.innerText')) { + reply({ result: { type: 'string', value: page.visibleText || '' } }); + return; + } + + if (expression.includes('document.documentElement ? document.documentElement.outerHTML')) { + reply({ result: { type: 'string', value: page.domSnapshot || '' } }); + return; + } + + if (expression.includes('document.readyState') && expression.includes('location.href')) { + const readyState = page.readyStateSequence?.shift() || 'complete'; + reply({ + result: { + type: 'string', + value: JSON.stringify({ href: page.currentUrl, readyState }), + }, + }); + return; + } + + if (expression.includes('scrollIntoView') && expression.includes('.click()')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const clickPlan = page.click?.[selector]; + if (!clickPlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (clickPlan.disabled) { + replyError(`element is disabled for selector: ${selector}`); + return; + } + if (clickPlan.error) { + replyError(clickPlan.error); + return; + } + reply({ result: { type: 'string', value: 'ok' } }); + return; + } + + if (expression.includes('focusTarget') && expression.includes('typedLength')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const text = parseJsonArgument(expression, 'text') ?? ''; + const clearFirst = expression.includes('const clearFirst = true'); + const typePlan = page.type?.[selector]; + if (!typePlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (typePlan.kind === 'unsupported') { + replyError(`element is not text-editable for selector: ${selector}`); + return; + } + if (typePlan.kind === 'noneditable') { + replyError(`element is not text-editable for selector: ${selector}`); + return; + } + if (typePlan.requireFocus && !expression.includes('focusTarget(element)')) { + replyError(`focus was not requested for selector: ${selector}`); + return; + } + + const currentValue = typePlan.value || ''; + const expectedValue = clearFirst + ? (typePlan.expectedValueWhenClearFirst ?? text) + : (typePlan.expectedValueWhenAppend ?? `${currentValue}${text}`); + + typePlan.focused = true; + typePlan.value = expectedValue; + reply({ + result: { + type: 'string', + value: JSON.stringify({ + value: expectedValue, + typedLength: expectedValue.length, + }), + }, + }); + return; + } + }); + }); + + const child = spawn('node', [serverPath], { + cwd: tempDir, + env: { + ...process.env, + CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + return child; + } + + async function stop() { + await new Promise((resolve) => { + wsServer?.close(() => resolve()); + if (!wsServer) resolve(); + }); + wsServer = null; + + await new Promise((resolve) => { + httpServer?.close(() => resolve()); + if (!httpServer) resolve(); + }); + httpServer = null; + + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + } + + return { start, stop }; +} + +async function runMcpRequests(pages: MockPageState[], requests: JsonRpcMessage[]) { + const browser = createMockBrowser(pages); + const child = await browser.start(); + + try { + const responsesPromise = collectResponses(child, requests.length + 1); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + + for (const request of requests) { + child.stdin.write(encodeMessage(request)); + } + + return await responsesPromise; + } finally { + child.kill(); + await browser.stop(); + } +} + +describe('ccs-browser MCP server', () => { + afterEach(() => { + // Cleanup is handled per test via runMcpRequests/createMockBrowser. + }); + + it('lists browser tools including navigate, click, type, and screenshot', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], + [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }] + ); + + const tools = (responses.find((message) => message.id === 2)?.result as { + tools: Array<{ name: string; inputSchema?: { properties?: Record } }>; + }).tools; + + expect(tools.map((tool) => tool.name)).toEqual([ + 'browser_get_session_info', + 'browser_get_url_and_title', + 'browser_get_visible_text', + 'browser_get_dom_snapshot', + 'browser_navigate', + 'browser_click', + 'browser_type', + 'browser_take_screenshot', + ]); + + for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { + expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ + type: 'integer', + minimum: 0, + }); + } + }); + + it('navigates successfully after readiness polling', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + navigate: { + 'https://example.com/next': { + finalUrl: 'https://example.com/next', + readyStates: ['loading', 'interactive'], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/next' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('pageIndex: 0'); + expect(text).toContain('url: https://example.com/next'); + expect(text).toContain('status: navigated'); + }); + + it( + 'returns a handled error when navigation readiness times out', + async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + navigate: { + 'https://example.com/slow': { + finalUrl: 'https://example.com/', + readyStates: new Array(60).fill('loading'), + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/slow' }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: navigation did not complete'); + }, + 8000 + ); + + it('returns handled errors for missing URL, malformed URL, invalid page index, and out-of-range page index', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { url: 'file:///tmp/example' } }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { pageIndex: 1.5, url: 'https://example.com/next' } }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { pageIndex: 9, url: 'https://example.com/next' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('Browser MCP failed: url is required'); + expect(getResponseText(responses.find((message) => message.id === 3))).toContain( + 'Browser MCP failed: url must be an absolute http or https URL' + ); + expect(getResponseText(responses.find((message) => message.id === 4))).toContain( + 'Browser MCP failed: pageIndex must be a non-negative integer' + ); + expect(getResponseText(responses.find((message) => message.id === 5))).toContain('page index 9 is out of range'); + }); + + it('surfaces Page.navigate CDP failures as handled errors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/start', + navigate: { + 'https://example.com/fail': { + finalUrl: 'https://example.com/start', + errorText: 'net::ERR_ABORTED', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/fail' }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: navigation failed for URL: https://example.com/fail: net::ERR_ABORTED' + ); + }); + + it('treats redirects as successful navigation', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/start', + navigate: { + 'https://example.com/redirect': { + finalUrl: 'https://example.com/final', + readyStates: ['loading', 'interactive'], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/redirect' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('status: navigated'); + expect(text).toContain('url: https://example.com/final'); + }); + + it('clicks matching elements and reports selector failures', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#submit': {}, + '#disabled': { disabled: true }, + '#throws': { error: 'click exploded' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#submit' } }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#missing' } }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#disabled' } }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#throws' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #submit'); + + const selectorMiss = responses.find((message) => message.id === 3); + expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(selectorMiss)).toContain('element not found for selector: #missing'); + + const disabledError = responses.find((message) => message.id === 4); + expect((disabledError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(disabledError)).toContain('element is disabled for selector: #disabled'); + + const pageSideError = responses.find((message) => message.id === 5); + expect((pageSideError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(pageSideError)).toContain('click exploded'); + }); + + it('captures screenshots and reports empty payload failures', async () => { + const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + screenshot: screenshotPlan, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: { fullPage: true }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('pageIndex: 0'); + expect(text).toContain('format: png'); + expect(text).toContain('fullPage: true'); + expect(text).toContain('data: c2NyZWVuc2hvdA=='); + expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true); + + const errorResponses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + screenshot: { data: '' }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: {}, + }, + }, + ] + ); + + const errorResponse = errorResponses.find((message) => message.id === 2); + expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(errorResponse)).toContain('Browser MCP failed: screenshot capture failed'); + }); + + it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + type: { + 'input[name="email"]': { + kind: 'input', + inputType: 'email', + value: 'old@example.com', + expectedValueWhenAppend: 'old@example.comhi@example.com', + requireFocus: true, + }, + '#notes': { + kind: 'textarea', + value: 'old note', + expectedValueWhenClearFirst: '', + requireFocus: true, + }, + '#editor': { + kind: 'contenteditable', + value: 'old content', + expectedValueWhenAppend: 'old contentrich text', + requireFocus: true, + }, + '#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' }, + '#plain': { kind: 'noneditable', value: 'plain' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: 'input[name="email"]', text: 'hi@example.com' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#notes', text: '', clearFirst: true }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#editor', text: 'rich text' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#color', text: 'ignored' }, + }, + }, + { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#plain', text: 'ignored' }, + }, + }, + ] + ); + + const typed = getResponseText(responses.find((message) => message.id === 2)); + expect(typed).toContain('status: typed'); + expect(typed).toContain('selector: input[name="email"]'); + expect(typed).toContain('typedLength: 29'); + + const clearFirst = getResponseText(responses.find((message) => message.id === 3)); + expect(clearFirst).toContain('status: typed'); + expect(clearFirst).toContain('selector: #notes'); + expect(clearFirst).toContain('typedLength: 0'); + + const contenteditable = getResponseText(responses.find((message) => message.id === 4)); + expect(contenteditable).toContain('status: typed'); + expect(contenteditable).toContain('selector: #editor'); + expect(contenteditable).toContain('typedLength: 20'); + + const unsupported = responses.find((message) => message.id === 5); + expect((unsupported?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(unsupported)).toContain('element is not text-editable for selector: #color'); + + const nonEditable = responses.find((message) => message.id === 6); + expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(nonEditable)).toContain('element is not text-editable for selector: #plain'); + }); +}); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 27d5dbd0..f7d1b626 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -144,7 +144,7 @@ describe('InstanceManager MCP sync', () => { }); }); - it('repairs managed ccs-websearch entries from global config while preserving other instance MCP overrides', () => { + it('repairs managed ccs-websearch and ccs-browser entries from global config while preserving other instance MCP overrides', () => { fs.writeFileSync( path.join(tempRoot, '.claude.json'), JSON.stringify( @@ -156,6 +156,12 @@ describe('InstanceManager MCP sync', () => { args: ['/global/server.cjs'], env: {}, }, + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/global/browser-server.cjs'], + env: {}, + }, shared: { command: 'global-shared' }, }, }, @@ -179,6 +185,12 @@ describe('InstanceManager MCP sync', () => { args: ['/old/server.cjs'], env: {}, }, + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/old/browser-server.cjs'], + env: {}, + }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }, @@ -201,11 +213,51 @@ describe('InstanceManager MCP sync', () => { args: ['/global/server.cjs'], env: {}, }, + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/global/browser-server.cjs'], + env: {}, + }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }); }); + it('preserves existing instance .claude.json permissions when syncing managed MCP servers', () => { + fs.writeFileSync( + path.join(tempRoot, '.claude.json'), + JSON.stringify( + { + mcpServers: { + 'ccs-browser': { + type: 'stdio', + command: 'node', + args: ['/global/browser.cjs'], + env: {}, + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const manager = new InstanceManager(); + const instancePath = manager.getInstancePath('work'); + fs.mkdirSync(instancePath, { recursive: true }); + const instanceClaudeJson = path.join(instancePath, '.claude.json'); + fs.writeFileSync(instanceClaudeJson, JSON.stringify({ existing: true }, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o640, + }); + fs.chmodSync(instanceClaudeJson, 0o640); + + expect(manager.syncMcpServers(instancePath)).toBe(true); + expect(fs.statSync(instanceClaudeJson).mode & 0o777).toBe(0o640); + }); + it('logs warning when global MCP sync fails', () => { fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8'); const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts index 063a8744..5a48af16 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { CodexAdapter } from '../../../src/targets/codex-adapter'; +import { + buildCodexBrowserMcpOverrides, + getCodexBrowserMcpServerName, +} from '../../../src/utils/browser-codex-overrides'; describe('CodexAdapter', () => { const adapter = new CodexAdapter(); @@ -21,6 +25,16 @@ describe('CodexAdapter', () => { ).toEqual(['--search']); }); + test('builds browser MCP overrides with Codex-safe defaults', () => { + const serverName = getCodexBrowserMcpServerName(); + expect(buildCodexBrowserMcpOverrides()).toEqual([ + `mcp_servers.${serverName}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + `mcp_servers.${serverName}.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, + `mcp_servers.${serverName}.enabled=true`, + `mcp_servers.${serverName}.tool_timeout_sec=30`, + ]); + }); + test('translates default-mode reasoning overrides into transient codex config', () => { const args = adapter.buildArgs('default', ['--search'], { profileType: 'default', @@ -40,6 +54,29 @@ describe('CodexAdapter', () => { expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']); }); + test('injects runtime config overrides for native Codex default launches', () => { + const runtimeConfigOverrides = buildCodexBrowserMcpOverrides(); + const args = adapter.buildArgs('default', ['--search'], { + profileType: 'default', + creds: { + profile: 'default', + baseUrl: '', + apiKey: '', + runtimeConfigOverrides, + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, + }); + + expect(args).toEqual([ + ...runtimeConfigOverrides.flatMap((override) => ['-c', override]), + '--search', + ]); + }); + test('rejects default-mode reasoning overrides when codex lacks config override support', () => { expect(() => adapter.buildArgs('default', ['--search'], { @@ -61,6 +98,7 @@ describe('CodexAdapter', () => { }); test('injects transient config overrides for CCS-backed launches', () => { + const runtimeConfigOverrides = buildCodexBrowserMcpOverrides(); const args = adapter.buildArgs('codex', ['--search'], { profileType: 'cliproxy', creds: { @@ -69,6 +107,7 @@ describe('CodexAdapter', () => { apiKey: 'cliproxy-token', model: 'gpt-5.4', reasoningOverride: 'high', + runtimeConfigOverrides, }, binaryInfo: { path: '/tmp/codex', @@ -81,8 +120,9 @@ describe('CodexAdapter', () => { expect(args).toContain('model_provider="ccs_runtime"'); expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"'); expect(args).toContain('model="gpt-5.4"'); + expect(args).toContain(`mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`); expect(args).toContain('model_reasoning_effort="high"'); - expect(args.at(-1)).toBe('--search'); + expect(args[args.length - 1]).toBe('--search'); }); test('fails fast when Codex binary lacks config override support', () => { diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 439e476d..1afdb956 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -162,7 +162,7 @@ process.exit(0); fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('does not preflight native Codex default launches when no runtime overrides are needed', () => { + it('injects browser MCP runtime overrides for native Codex default launches', () => { if (process.platform === 'win32') return; const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { @@ -177,10 +177,23 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls).toEqual([['fix failing tests']]); + expect(calls).toEqual([ + ['-c', 'model="gpt-5"', '--version'], + [ + '-c', + `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + '-c', + `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + 'fix failing tests', + ], + ]); }); - it('ignores off-style CCS_THINKING env overrides for native Codex default mode', () => { + it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => { if (process.platform === 'win32') return; const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { @@ -195,7 +208,20 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls).toEqual([['fix failing tests']]); + expect(calls).toEqual([ + ['-c', 'model="gpt-5"', '--version'], + [ + '-c', + `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + '-c', + `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + 'fix failing tests', + ], + ]); }); for (const versionFlag of ['--version', '-v']) { @@ -291,7 +317,19 @@ process.exit(0); expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], - ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], + [ + '-c', + 'mcp_servers.ccs_browser.command="npx"', + '-c', + 'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]', + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + '-c', + 'model_reasoning_effort="high"', + 'fix failing tests', + ], ]); const loggedEnv = readLoggedCodexEnv(codexEnvLogPath); expect(loggedEnv).toHaveLength(2); @@ -481,7 +519,19 @@ process.exit(0); expect(result.status).toBe(0); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], - ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], + [ + '-c', + 'mcp_servers.ccs_browser.command="npx"', + '-c', + 'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]', + '-c', + 'mcp_servers.ccs_browser.enabled=true', + '-c', + 'mcp_servers.ccs_browser.tool_timeout_sec=30', + '-c', + 'model_reasoning_effort="high"', + 'fix failing tests', + ], ]); }); diff --git a/tests/unit/targets/codex-settings-bridge-launch.test.ts b/tests/unit/targets/codex-settings-bridge-launch.test.ts index 9e520ae0..edec0748 100644 --- a/tests/unit/targets/codex-settings-bridge-launch.test.ts +++ b/tests/unit/targets/codex-settings-bridge-launch.test.ts @@ -126,6 +126,8 @@ exit 0 expect(argsLog).toContain('model_provider="ccs_runtime"'); expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"'); expect(argsLog).toContain('model_reasoning_effort="high"'); + expect(argsLog).toContain('mcp_servers.ccs_browser.command='); + expect(argsLog).toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]'); expect(argsLog).toContain('smoke'); expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token'); }); diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts new file mode 100644 index 00000000..0af444a9 --- /dev/null +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawn, spawnSync, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { + const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts'); + const result = spawnSync(process.execPath, [ccsEntry, ...args], { + encoding: 'utf8', + env, + timeout: 5000, + }); + + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +describe('default profile browser launch', () => { + let tmpHome = ''; + let fakeClaudePath = ''; + let claudeArgsLogPath = ''; + let claudeEnvLogPath = ''; + let browserProfileDir = ''; + let devtoolsServer: ChildProcess | undefined; + let baseEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + if (process.platform === 'win32') { + return; + } + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-default-browser-launch-')); + fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); + claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt'); + claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt'); + browserProfileDir = path.join(tmpHome, 'chrome-user-data'); + + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh +printf "%s\n" "$@" > "${claudeArgsLogPath}" +{ + printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" + printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" + printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" + printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" +} > "${claudeEnvLogPath}" +exit 0 +`, + { encoding: 'utf8', mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + baseEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CLAUDE_PATH: fakeClaudePath, + CCS_DEBUG: '1', + }; + }); + + afterEach(() => { + if (devtoolsServer) { + devtoolsServer.kill(); + devtoolsServer = undefined; + } + if (process.platform === 'win32') { + return; + } + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('passes browser runtime env through default Claude launches when reuse is configured', () => { + if (process.platform === 'win32') return; + + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/default-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const startDeadline = Date.now() + 5000; + while (!fs.existsSync(mockServerPortPath)) { + if (Date.now() > startDeadline) { + throw new Error('Timed out waiting for mock DevTools server to start'); + } + } + const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/default-target`, + 'utf8' + ); + + const result = runCcs(['default', 'smoke'], { + ...baseEnv, + CCS_BROWSER_PROFILE_DIR: browserProfileDir, + }); + + expect(result.stderr).not.toContain('Browser MCP is enabled, but CCS could not prepare the local browser tool.'); + expect(result.stderr).not.toContain('could not sync the browser MCP config'); + expect(result.stderr).not.toContain('Chrome reuse metadata not found'); + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain('--append-system-prompt'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); + }); +}); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts new file mode 100644 index 00000000..61052620 --- /dev/null +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawn, spawnSync, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { + const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts'); + const result = spawnSync(process.execPath, [ccsEntry, ...args], { + encoding: 'utf8', + env, + timeout: 8000, + }); + + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +describe('settings profile browser launch', () => { + let tmpHome = ''; + let ccsDir = ''; + let settingsPath = ''; + let fakeClaudePath = ''; + let claudeArgsLogPath = ''; + let claudeEnvLogPath = ''; + let browserProfileDir = ''; + let devtoolsServer: ChildProcess | undefined; + let baseEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + if (process.platform === 'win32') { + return; + } + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-launch-')); + ccsDir = path.join(tmpHome, '.ccs'); + settingsPath = path.join(ccsDir, 'glm.settings.json'); + fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); + claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt'); + claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt'); + browserProfileDir = path.join(tmpHome, 'chrome-user-data'); + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: false', 'image_analysis:', ' enabled: false', ''].join('\n'), + 'utf8' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_MODEL: 'glm-5', + }, + }, + null, + 2 + ) + '\n' + ); + + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh +printf "%s\n" "$@" > "${claudeArgsLogPath}" +{ + printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" + printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" + printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" + printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" +} > "${claudeEnvLogPath}" +exit 0 +`, + { encoding: 'utf8', mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + baseEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CLAUDE_PATH: fakeClaudePath, + CCS_DEBUG: '1', + }; + }); + + afterEach(() => { + if (devtoolsServer) { + devtoolsServer.kill(); + devtoolsServer = undefined; + } + if (process.platform === 'win32') { + return; + } + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('fails before Claude launch when browser reuse cannot resolve DevToolsActivePort', () => { + if (process.platform === 'win32') return; + + fs.mkdirSync(browserProfileDir, { recursive: true }); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_PROFILE_DIR: browserProfileDir, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('DevToolsActivePort'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(false); + }); + + it('passes browser reuse env and steering prompt into the Claude launch', async () => { + if (process.platform === 'win32') return; + + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/browser-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const startDeadline = Date.now() + 5000; + while (!fs.existsSync(mockServerPortPath)) { + if (Date.now() > startDeadline) { + throw new Error('Timed out waiting for mock DevTools server to start'); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/browser-target`, + 'utf8' + ); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_PROFILE_DIR: browserProfileDir, + }); + + expect(result.stderr).not.toContain('Browser MCP is enabled, but CCS could not prepare the local browser tool.'); + expect(result.stderr).not.toContain('could not sync the browser MCP config'); + expect(result.stderr).not.toContain('Chrome reuse metadata not found'); + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain('--append-system-prompt'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index 634c199a..45a9f1a5 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -98,7 +98,49 @@ describe('ClaudeAdapter', () => { expect(env['ANTHROPIC_MODEL']).toBe('claude-opus-4-6'); }); - it('should pass through args unchanged', () => { + it('should append browser steering prompt when browser runtime env exists', () => { + const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose'], { + creds: { + profile: 'gemini', + baseUrl: 'https://api.example.com', + apiKey: 'test-key', + browserRuntimeEnv: { + CCS_BROWSER_USER_DATA_DIR: '/tmp/chrome', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9222', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }, + }, + profileType: 'settings', + }); + + expect(args).toContain('--append-system-prompt'); + expect(args.join(' ')).toContain('prefer the CCS MCP Browser tool'); + }); + + it('should merge browser runtime env into Claude launch env', () => { + const env = adapter.buildEnv( + { + profile: 'gemini', + baseUrl: 'https://api.example.com', + apiKey: 'test-key', + browserRuntimeEnv: { + CCS_BROWSER_USER_DATA_DIR: '/tmp/chrome', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9222', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }, + }, + 'settings' + ); + + expect(env['CCS_BROWSER_USER_DATA_DIR']).toBe('/tmp/chrome'); + expect(env['CCS_BROWSER_DEVTOOLS_WS_URL']).toBe('ws://127.0.0.1/devtools/browser/test'); + }); + + it('should pass through args unchanged when browser runtime env is absent', () => { const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose']); expect(args).toEqual(['-p', 'hello', '--verbose']); }); diff --git a/tests/unit/utils/browser/chrome-reuse.test.ts b/tests/unit/utils/browser/chrome-reuse.test.ts new file mode 100644 index 00000000..79a78e81 --- /dev/null +++ b/tests/unit/utils/browser/chrome-reuse.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + resolveBrowserRuntimeEnv, + resolveDefaultChromeUserDataDir, +} from '../../../../src/utils/browser/chrome-reuse'; + +describe('chrome reuse resolver', () => { + const originalLocalAppData = process.env.LOCALAPPDATA; + let tempDirs: string[] = []; + let servers: Array<{ stop: () => void }> = []; + + function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + function writeDevToolsActivePort(profileDir: string, content: string): void { + fs.mkdirSync(profileDir, { recursive: true }); + fs.writeFileSync(path.join(profileDir, 'DevToolsActivePort'), content, 'utf8'); + } + + async function startDevToolsServer(versionPayload: Record) { + const server = Bun.serve({ + port: 0, + fetch(request: Request) { + if (new URL(request.url).pathname === '/json/version') { + return Response.json(versionPayload); + } + return new Response('not found', { status: 404 }); + }, + }); + servers.push(server); + return server; + } + + async function startFailingDevToolsServer(status: number, body = 'error') { + const server = Bun.serve({ + port: 0, + fetch(request: Request) { + if (new URL(request.url).pathname === '/json/version') { + return new Response(body, { status }); + } + return new Response('not found', { status: 404 }); + }, + }); + servers.push(server); + return server; + } + + async function startMalformedJsonDevToolsServer() { + const server = Bun.serve({ + port: 0, + fetch(request: Request) { + if (new URL(request.url).pathname === '/json/version') { + return new Response('{invalid-json', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('not found', { status: 404 }); + }, + }); + servers.push(server); + return server; + } + + async function reserveClosedPort(): Promise { + const server = Bun.serve({ + port: 0, + fetch() { + return new Response('ok'); + }, + }); + const port = server.port; + server.stop(true); + return port; + } + + afterEach(() => { + for (const server of servers) { + server.stop(true); + } + servers = []; + + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; + + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + }); + + it('uses explicit profile-dir before the default path and resolves the websocket target', async () => { + const explicitProfileDir = createTempDir('ccs-chrome-explicit-'); + const defaultProfileDir = createTempDir('ccs-chrome-default-'); + const server = await startDevToolsServer({ + Browser: 'Chrome/136.0.0.0', + webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/target-1', + }); + + writeDevToolsActivePort(explicitProfileDir, `${server.port}\n/devtools/browser/from-explicit`); + + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir: explicitProfileDir, + }); + + expect(runtimeEnv).toEqual({ + CCS_BROWSER_USER_DATA_DIR: explicitProfileDir, + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: String(server.port), + CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${server.port}`, + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/target-1', + }); + expect(fs.existsSync(path.join(defaultProfileDir, 'DevToolsActivePort'))).toBe(false); + }); + + it('uses an explicit devtools port override when metadata is missing', async () => { + const profileDir = createTempDir('ccs-chrome-explicit-port-'); + const server = await startDevToolsServer({ + Browser: 'Chrome/136.0.0.0', + webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/explicit-port', + }); + + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir, + devtoolsPort: String(server.port), + }); + + expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_PORT).toBe(String(server.port)); + expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_WS_URL).toBe('ws://127.0.0.1/devtools/browser/explicit-port'); + }); + + it('throws a clear error when DevToolsActivePort metadata is missing', async () => { + const profileDir = createTempDir('ccs-chrome-missing-metadata-'); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}` + ); + }); + + it('throws a clear error when DevToolsActivePort metadata is invalid', async () => { + const profileDir = createTempDir('ccs-chrome-invalid-metadata-'); + writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target'); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}` + ); + }); + + it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => { + const profileDir = createTempDir('ccs-chrome-unreachable-'); + const port = await reserveClosedPort(); + writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}` + ); + }); + + it('throws a clear error when the DevTools endpoint is reachable without a websocket target', async () => { + const profileDir = createTempDir('ccs-chrome-missing-ws-'); + const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' }); + writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version` + ); + }); + + it('throws a clear error when the DevTools endpoint returns a non-200 response', async () => { + const profileDir = createTempDir('ccs-chrome-bad-status-'); + const server = await startFailingDevToolsServer(500); + writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` + ); + }); + + it('throws a clear error when the DevTools endpoint returns malformed JSON', async () => { + const profileDir = createTempDir('ccs-chrome-bad-json-'); + const server = await startMalformedJsonDevToolsServer(); + writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`); + + await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` + ); + }); + + it('resolves platform default Chrome user-data-dir paths', () => { + process.env.LOCALAPPDATA = 'C:/Users/test/AppData/Local'; + + expect(resolveDefaultChromeUserDataDir('darwin')).toBe( + path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome') + ); + expect(resolveDefaultChromeUserDataDir('linux')).toBe( + path.join(os.homedir(), '.config', 'google-chrome') + ); + expect(resolveDefaultChromeUserDataDir('win32')).toBe( + path.normalize('C:/Users/test/AppData/Local/Google/Chrome/User Data') + ); + }); + + it('throws on win32 when LOCALAPPDATA is missing', () => { + delete process.env.LOCALAPPDATA; + + expect(() => resolveDefaultChromeUserDataDir('win32')).toThrow( + 'LOCALAPPDATA is required to resolve the default Chrome user-data-dir on Windows' + ); + }); + + it('throws a clear error when the resolved profile directory does not exist', async () => { + const missingProfileDir = path.join(createTempDir('ccs-chrome-missing-dir-'), 'missing-profile'); + + await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow( + `Chrome profile directory is invalid: ${missingProfileDir}` + ); + }); +}); diff --git a/tests/unit/utils/browser/claude-tool-args.test.ts b/tests/unit/utils/browser/claude-tool-args.test.ts new file mode 100644 index 00000000..57077dff --- /dev/null +++ b/tests/unit/utils/browser/claude-tool-args.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test'; +import { appendBrowserToolArgs } from '../../../../src/utils/browser/claude-tool-args'; + +const BROWSER_STEERING_PROMPT = + 'For DOM/screenshots/elements/page actions, prefer the CCS MCP Browser tool, reuse the configured running Chrome context whenever possible, and if the tool or context is unavailable, explain that clearly instead of pretending page state is available.'; + +describe('appendBrowserToolArgs', () => { + it('appends the browser steering prompt when it is missing', () => { + expect(appendBrowserToolArgs(['navigate'])).toEqual([ + 'navigate', + '--append-system-prompt', + BROWSER_STEERING_PROMPT, + ]); + }); + + it('does not append the prompt when it already exists in equals form', () => { + expect( + appendBrowserToolArgs([ + 'navigate', + `--append-system-prompt=${BROWSER_STEERING_PROMPT}`, + ]) + ).toEqual(['navigate', `--append-system-prompt=${BROWSER_STEERING_PROMPT}`]); + }); + + it('does not append the prompt when it already exists in split-flag form', () => { + expect( + appendBrowserToolArgs([ + 'navigate', + '--append-system-prompt', + BROWSER_STEERING_PROMPT, + ]) + ).toEqual(['navigate', '--append-system-prompt', BROWSER_STEERING_PROMPT]); + }); + + it('inserts the prompt before the end-of-options terminator', () => { + expect(appendBrowserToolArgs(['--', 'take screenshot'])).toEqual([ + '--append-system-prompt', + BROWSER_STEERING_PROMPT, + '--', + 'take screenshot', + ]); + }); +}); diff --git a/tests/unit/utils/browser/mcp-installer.test.ts b/tests/unit/utils/browser/mcp-installer.test.ts new file mode 100644 index 00000000..58ab3b23 --- /dev/null +++ b/tests/unit/utils/browser/mcp-installer.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as browserInstaller from '../../../../src/utils/browser/mcp-installer'; +import { + ensureBrowserMcp, + ensureBrowserMcpConfig, + ensureBrowserMcpOrThrow, + getBrowserMcpServerName, + getBrowserMcpServerPath, + syncBrowserMcpToConfigDir, + uninstallBrowserMcp, +} from '../../../../src/utils/browser'; + +describe('ensureBrowserMcp', () => { + let tempHome: string | undefined; + let originalCcsHome: string | undefined; + + function setupTempHome(): string { + const nextTempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-mcp-')); + tempHome = nextTempHome; + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = nextTempHome; + return nextTempHome; + } + + function getManagedConfig() { + return { + type: 'stdio', + command: 'node', + args: [getBrowserMcpServerPath()], + env: { + NODE_PATH: path.join(process.cwd(), 'node_modules'), + }, + }; + } + + afterEach(() => { + mock.restore(); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + + tempHome = undefined; + originalCcsHome = undefined; + }); + + it('installs the bundled browser MCP server and preserves existing user mcpServers entries', () => { + setupTempHome(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync( + claudeUserConfigPath, + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['some-server'] }, + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(ensureBrowserMcp()).toBe(true); + expect(fs.existsSync(getBrowserMcpServerPath())).toBe(true); + + const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { + mcpServers: Record; + }; + + expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] }); + expect(config.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig()); + }); + + it('preserves the existing ~/.claude.json permissions when provisioning browser MCP', () => { + setupTempHome(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync(claudeUserConfigPath, JSON.stringify({ existing: true }, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); + fs.chmodSync(claudeUserConfigPath, 0o600); + + expect(ensureBrowserMcpConfig()).toBe(true); + expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600); + }); + + it('copies the bundled browser MCP artifact while preserving source permissions', () => { + setupTempHome(); + + const bundledServerPath = path.join( + process.cwd(), + 'lib', + 'mcp', + 'ccs-browser-server.cjs' + ); + const originalMode = fs.statSync(bundledServerPath).mode & 0o777; + + expect(ensureBrowserMcp()).toBe(true); + expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode); + }); + + it('reconciles installed browser MCP permissions when contents already match', () => { + setupTempHome(); + + const bundledServerPath = path.join( + process.cwd(), + 'lib', + 'mcp', + 'ccs-browser-server.cjs' + ); + const originalMode = fs.statSync(bundledServerPath).mode & 0o777; + + expect(ensureBrowserMcp()).toBe(true); + fs.chmodSync(getBrowserMcpServerPath(), 0o600); + + expect(ensureBrowserMcp()).toBe(true); + expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode); + }); + + it('removes the managed browser runtime while preserving unrelated server entries', () => { + setupTempHome(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync( + claudeUserConfigPath, + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['some-server'] }, + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(ensureBrowserMcp()).toBe(true); + + const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work'); + fs.mkdirSync(instancePath, { recursive: true }); + fs.writeFileSync( + path.join(instancePath, '.claude.json'), + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['instance-server'] }, + [getBrowserMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] }, + }, + otherKey: 'keep-me', + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(uninstallBrowserMcp()).toBe(true); + expect(fs.existsSync(getBrowserMcpServerPath())).toBe(false); + + const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { + mcpServers: Record; + }; + expect(globalConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['some-server'] }, + }); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') + ) as { + otherKey: string; + mcpServers: Record; + }; + expect(instanceConfig.otherKey).toBe('keep-me'); + expect(instanceConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['instance-server'] }, + }); + }); + + it('syncs the managed browser MCP entry into an instance config dir', () => { + setupTempHome(); + + fs.writeFileSync( + path.join(tempHome as string, '.claude.json'), + JSON.stringify( + { + mcpServers: { + [getBrowserMcpServerName()]: getManagedConfig(), + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work'); + fs.mkdirSync(instancePath, { recursive: true }); + + expect(syncBrowserMcpToConfigDir(instancePath)).toBe(true); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') + ) as { + mcpServers: Record; + }; + expect(instanceConfig.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig()); + }); + + it('throws when the bundled browser MCP runtime cannot be prepared', () => { + setupTempHome(); + + const ensureSpy = spyOn(browserInstaller, 'ensureBrowserMcp').mockReturnValue(false); + + expect(() => ensureBrowserMcpOrThrow()).toThrow( + 'Browser MCP is enabled, but CCS could not prepare the local browser tool.' + ); + expect(ensureSpy).toHaveBeenCalled(); + }); +}); From 74f028168dfa4d5c8478679465bb527a7a5f8b7f Mon Sep 17 00:00:00 2001 From: walker <13750528578@163.com> Date: Sun, 12 Apr 2026 13:13:29 +0800 Subject: [PATCH 32/42] =?UTF-8?q?fix(browser):=20=E5=8A=A0=E5=9B=BA=20brow?= =?UTF-8?q?ser=5Fclick=20=E6=BF=80=E6=B4=BB=E8=AF=AD=E4=B9=89=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=9B=B8=E5=85=B3=E9=AA=8C=E8=AF=81=E9=98=BB?= =?UTF-8?q?=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 browser_click 调整为 mousedown/mouseup 后走 native click - 在取消激活和中途 detached 场景下避免强制触发 native activation - 补齐 browser_click 的回归测试与取消激活场景覆盖 - 修复 cliproxy 本地代理、Droid 环境隔离、图像分析 hook 与 proxy 集成测试的验证阻塞 Co-Authored-By: Claude Opus 4.6 --- lib/mcp/ccs-browser-server.cjs | 45 ++- src/cliproxy/tool-sanitization-proxy.ts | 3 - src/targets/droid-adapter.ts | 7 +- src/web-server/routes/cliproxy-local-proxy.ts | 36 ++- tests/integration/image-analyzer-hook.test.ts | 5 + ...ool-sanitization-proxy-integration.test.ts | 15 + .../unit/hooks/ccs-browser-mcp-server.test.ts | 304 +++++++++++++++++- 7 files changed, 400 insertions(+), 15 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 515d367a..3182c874 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -133,7 +133,7 @@ function getTools() { { name: TOOL_CLICK, description: - 'Click the first element matching a CSS selector in the selected page using synthetic element.click(). Optionally choose a page by index.', + '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: { @@ -516,11 +516,54 @@ async function handleClick(toolArgs) { 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'; })()`; 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/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/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/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/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/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index b0d3eb5a..58cd0db8 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -16,7 +16,22 @@ type MockPageState = { visibleText?: string; domSnapshot?: string; navigate?: Record; - click?: 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; @@ -267,14 +282,77 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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; @@ -402,7 +480,11 @@ describe('ccs-browser MCP server', () => { ); const tools = (responses.find((message) => message.id === 2)?.result as { - tools: Array<{ name: string; inputSchema?: { properties?: Record } }>; + tools: Array<{ + name: string; + description?: string; + inputSchema?: { properties?: Record }; + }>; }).tools; expect(tools.map((tool) => tool.name)).toEqual([ @@ -416,6 +498,10 @@ describe('ccs-browser MCP server', () => { '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', @@ -662,6 +748,220 @@ describe('ccs-browser MCP server', () => { 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( From 26059c78dc4f19e0309e96be42b4238146a34ff5 Mon Sep 17 00:00:00 2001 From: walker <13750528578@163.com> Date: Sun, 12 Apr 2026 19:42:26 +0800 Subject: [PATCH 33/42] =?UTF-8?q?build(types):=20=E8=A1=A5=E5=85=85=20Bun?= =?UTF-8?q?=20=E7=B1=BB=E5=9E=8B=E5=A3=B0=E6=98=8E=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + tsconfig.json | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1a972940..5a142ac5 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "@semantic-release/release-notes-generator": "^14.1.0", "@tailwindcss/vite": "^4.1.17", "@types/bcrypt": "^6.0.0", + "@types/bun": "^1.3.12", "@types/chokidar": "^2.1.7", "@types/express": "^4.17.21", "@types/express-rate-limit": "6.0.0", 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/**/*" From 7754c6c0ff3aa8f69f17860d283c80b752d5a6cc Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 13 Apr 2026 10:27:38 +0800 Subject: [PATCH 34/42] =?UTF-8?q?test(browser):=20=E4=BF=AE=E5=A4=8D=20def?= =?UTF-8?q?ault=20profile=20browser=20launch=20=E7=AB=9E=E6=80=81=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../default-profile-browser-launch.test.ts | 76 +++++++++++++++++-- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index 0af444a9..1f42f749 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -1,4 +1,5 @@ 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'; @@ -12,6 +13,58 @@ interface RunResult { 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], { @@ -86,7 +139,19 @@ exit 0 fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('passes browser runtime env through default Claude launches when reuse is configured', () => { + 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'); @@ -117,13 +182,8 @@ server.listen(0, '127.0.0.1', () => { env: baseEnv, }); - const startDeadline = Date.now() + 5000; - while (!fs.existsSync(mockServerPortPath)) { - if (Date.now() > startDeadline) { - throw new Error('Timed out waiting for mock DevTools server to start'); - } - } - const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + const port = await waitForMockDevtoolsPort(mockServerPortPath); + await waitForDevtoolsVersionEndpoint(port); fs.mkdirSync(browserProfileDir, { recursive: true }); fs.writeFileSync( From 2527889d1d28ee1e7c58581b697e18a220f15cf3 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 13 Apr 2026 11:16:05 +0800 Subject: [PATCH 35/42] =?UTF-8?q?test(ui):=20=E4=BF=AE=E5=A4=8D=E5=BD=A9?= =?UTF-8?q?=E8=89=B2=E8=BE=93=E5=87=BA=E4=B8=8B=E7=9A=84=E8=84=86=E5=BC=B1?= =?UTF-8?q?=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/auth/resume-lane-warning.test.ts | 14 +++- .../unit/commands/config-auth-command.test.ts | 6 +- tests/unit/utils/ui.test.js | 79 ++++++++++++------- 3 files changed, 67 insertions(+), 32 deletions(-) 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/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/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; + } + } + }); }); }); }); From 45c231a71732c40046a67f1cb46f1a5bcd8e96ee Mon Sep 17 00:00:00 2001 From: Yehor Baklanov Date: Sun, 12 Apr 2026 20:40:50 +0000 Subject: [PATCH 36/42] feat(cli): add --append-system-prompt-file support --- src/ccs.ts | 3 + src/utils/image-analysis/claude-tool-args.ts | 66 ++++++++++--- src/utils/prompt-injection-strategy.ts | 92 +++++++++++++++++++ src/utils/websearch/claude-tool-args.ts | 62 ++++++++++--- src/utils/websearch/trace.ts | 54 +++++++++-- .../image-analysis/claude-tool-args.test.ts | 30 ++++++ .../utils/prompt-injection-strategy.test.ts | 79 ++++++++++++++++ .../utils/websearch/claude-tool-args.test.ts | 30 +++++- 8 files changed, 382 insertions(+), 34 deletions(-) create mode 100644 src/utils/prompt-injection-strategy.ts create mode 100644 tests/unit/utils/prompt-injection-strategy.test.ts diff --git a/src/ccs.ts b/src/ccs.ts index a6edc978..d19f71a7 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1014,6 +1014,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) { @@ -1254,6 +1255,7 @@ async function main(): Promise { const imageAnalysisArgs = imageAnalysisMcpReady ? appendThirdPartyImageAnalysisToolArgs(remainingArgs) : remainingArgs; + const launchArgs = [ '--settings', expandedSettingsPath, @@ -1266,6 +1268,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) diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts index f1d0cf0b..81131de4 100644 --- a/src/utils/image-analysis/claude-tool-args.ts +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -1,10 +1,21 @@ /** * 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 { + buildSteeringArg, + PROMPT_FLAG_INLINE, + PROMPT_FLAG_FILE, +} from '../prompt-injection-strategy'; + +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 splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } { const terminatorIndex = args.indexOf('--'); @@ -26,15 +37,28 @@ function getImmediateFlagValue(args: string[], index: number): string | null { return value; } -function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { +function hasExactFlagValue(params: { + args: string[]; + flag: string; + expectedValue: string; + allowPartiallyMatch?: boolean; +}): boolean { + const { args, flag, expectedValue, allowPartiallyMatch } = params; + for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === flag) { const value = getImmediateFlagValue(args, index); + if (value === expectedValue) { return true; } + + if (allowPartiallyMatch && value?.includes(expectedValue)) { + return true; + } + continue; } @@ -53,16 +77,34 @@ function hasExactFlagValue(args: string[], flag: string, expectedValue: string): function ensureImageAnalysisSteeringPrompt(args: string[]): string[] { const { optionArgs, trailingArgs } = splitArgsAtTerminator(args); - if (hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)) { + if ( + hasExactFlagValue({ + args: optionArgs, + flag: PROMPT_FLAG_INLINE, + expectedValue: IMAGE_ANALYSIS_STEERING_PROMPT.content, + }) + ) { return args; } - return [ - ...optionArgs, - APPEND_SYSTEM_PROMPT_FLAG, - IMAGE_ANALYSIS_STEERING_PROMPT, - ...trailingArgs, - ]; + if ( + hasExactFlagValue({ + args: optionArgs, + flag: PROMPT_FLAG_FILE, + expectedValue: IMAGE_ANALYSIS_STEERING_PROMPT.name, + allowPartiallyMatch: true, + }) + ) { + 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 +112,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..53e15e4a --- /dev/null +++ b/src/utils/prompt-injection-strategy.ts @@ -0,0 +1,92 @@ +/** + * 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'; + +/** + * 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 ccsDir = getCcsDir(); + + const promptsFolder = path.join(ccsDir, '/prompts'); + + 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: `${params.promptName}.txt`, + 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..52a821a2 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -1,12 +1,23 @@ /** * 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 { + buildSteeringArg, + PROMPT_FLAG_INLINE, + PROMPT_FLAG_FILE, +} 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 @@ -68,15 +79,28 @@ 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; + allowPartiallyMatch?: boolean; +}): boolean { + const { args, flag, expectedValue, allowPartiallyMatch } = params; + for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === flag) { const value = getImmediateFlagValue(args, index); + if (value === expectedValue) { return true; } + + if (allowPartiallyMatch && value?.includes(expectedValue)) { + return true; + } + continue; } @@ -135,17 +159,33 @@ function ensureWebSearchSteeringPrompt(args: string[]): string[] { const { optionArgs, trailingArgs } = splitArgsAtTerminator(args); if ( - hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, THIRD_PARTY_WEBSEARCH_STEERING_PROMPT) + hasExactFlagValue({ + args: optionArgs, + flag: PROMPT_FLAG_INLINE, + expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content, + }) ) { return args; } - return [ - ...optionArgs, - APPEND_SYSTEM_PROMPT_FLAG, - THIRD_PARTY_WEBSEARCH_STEERING_PROMPT, - ...trailingArgs, - ]; + if ( + hasExactFlagValue({ + args: optionArgs, + flag: PROMPT_FLAG_FILE, + expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, + allowPartiallyMatch: true, + }) + ) { + 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/trace.ts b/src/utils/websearch/trace.ts index 5629be7a..64a9e596 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 { PROMPT_FLAG_INLINE, PROMPT_FLAG_FILE } 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,28 @@ 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; + allowIncludesValue?: boolean; +}): boolean { + const { args, flag, expectedValue, allowIncludesValue } = 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; } + + if (allowIncludesValue && immediateFlagValue?.includes(expectedValue)) { + return true; + } + continue; } @@ -179,16 +192,37 @@ 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 ( + hasExactFlagValue({ + args, + flag: PROMPT_FLAG_FILE, + expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, + allowIncludesValue: true, + }) + ) { + 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/tests/unit/utils/image-analysis/claude-tool-args.test.ts b/tests/unit/utils/image-analysis/claude-tool-args.test.ts index 8f62322b..a68bc5e9 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,34 @@ 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); + }); }); 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..b69693fb --- /dev/null +++ b/tests/unit/utils/prompt-injection-strategy.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'bun:test'; +import { + detectPromptInjectionMode, + buildInlineSteeringArg, + buildFileSteeringArg, + buildSteeringArg, + PROMPT_FLAG_INLINE, + PROMPT_FLAG_FILE, +} from '../../../src/utils/prompt-injection-strategy'; + +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 temp file', () => { + const result = buildFileSteeringArg({promptFileName: 'ccs-test-prompt.txt', promptContent: 'hello world' }); + expect(result[0]).toBe('--append-system-prompt-file'); + expect(result[1]).toContain('ccs-test-prompt.txt'); + }); +}); + +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]).toContain('ccs-test.txt'); + }); +}); diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts index 9f902b80..acad3acf 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,32 @@ 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'); + }); }); From a63417e567edc1cca36a184527504ea78a45a1f8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 13 Apr 2026 08:33:29 -0400 Subject: [PATCH 37/42] fix(ci): sync bun.lock for browser MCP types --- bun.lock | 5 +++++ 1 file changed, 5 insertions(+) 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=="], From 7fc39f7c0319c19ec52d21dc60248a27c128fe3f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 13 Apr 2026 08:39:59 -0400 Subject: [PATCH 38/42] fix(cli): match managed prompt files by exact path --- src/utils/image-analysis/claude-tool-args.ts | 16 +--- src/utils/prompt-injection-strategy.ts | 43 ++++++++++- src/utils/websearch/claude-tool-args.ts | 15 +--- src/utils/websearch/trace.ts | 18 +---- .../image-analysis/claude-tool-args.test.ts | 13 ++++ .../utils/prompt-injection-strategy.test.ts | 75 +++++++++++++++---- .../utils/websearch/claude-tool-args.test.ts | 12 +++ 7 files changed, 135 insertions(+), 57 deletions(-) diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts index 81131de4..6c54e1ef 100644 --- a/src/utils/image-analysis/claude-tool-args.ts +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -7,8 +7,8 @@ import { buildSteeringArg, + hasManagedPromptFileArg, PROMPT_FLAG_INLINE, - PROMPT_FLAG_FILE, } from '../prompt-injection-strategy'; const IMAGE_ANALYSIS_STEERING_PROMPT = { @@ -41,9 +41,8 @@ function hasExactFlagValue(params: { args: string[]; flag: string; expectedValue: string; - allowPartiallyMatch?: boolean; }): boolean { - const { args, flag, expectedValue, allowPartiallyMatch } = params; + const { args, flag, expectedValue } = params; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -55,10 +54,6 @@ function hasExactFlagValue(params: { return true; } - if (allowPartiallyMatch && value?.includes(expectedValue)) { - return true; - } - continue; } @@ -88,12 +83,7 @@ function ensureImageAnalysisSteeringPrompt(args: string[]): string[] { } if ( - hasExactFlagValue({ - args: optionArgs, - flag: PROMPT_FLAG_FILE, - expectedValue: IMAGE_ANALYSIS_STEERING_PROMPT.name, - allowPartiallyMatch: true, - }) + hasManagedPromptFileArg({ args: optionArgs, promptName: IMAGE_ANALYSIS_STEERING_PROMPT.name }) ) { return args; } diff --git a/src/utils/prompt-injection-strategy.ts b/src/utils/prompt-injection-strategy.ts index 53e15e4a..7624556d 100644 --- a/src/utils/prompt-injection-strategy.ts +++ b/src/utils/prompt-injection-strategy.ts @@ -22,6 +22,43 @@ 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. * @@ -56,9 +93,7 @@ export function buildFileSteeringArg(params: { promptFileName: string; promptContent: string; }): string[] { - const ccsDir = getCcsDir(); - - const promptsFolder = path.join(ccsDir, '/prompts'); + const promptsFolder = getManagedPromptsDir(); if (!fs.existsSync(promptsFolder)) { fs.mkdirSync(promptsFolder, { recursive: true }); @@ -83,7 +118,7 @@ export function buildSteeringArg(params: { if (mode === 'file') { return buildFileSteeringArg({ - promptFileName: `${params.promptName}.txt`, + promptFileName: getManagedPromptFileName(params.promptName), promptContent: params.promptContent, }); } diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index 52a821a2..de73e829 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -7,8 +7,8 @@ import { buildSteeringArg, + hasManagedPromptFileArg, PROMPT_FLAG_INLINE, - PROMPT_FLAG_FILE, } from '../prompt-injection-strategy'; const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; @@ -83,9 +83,8 @@ function hasExactFlagValue(params: { args: string[]; flag: string; expectedValue: string; - allowPartiallyMatch?: boolean; }): boolean { - const { args, flag, expectedValue, allowPartiallyMatch } = params; + const { args, flag, expectedValue } = params; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -97,10 +96,6 @@ function hasExactFlagValue(params: { return true; } - if (allowPartiallyMatch && value?.includes(expectedValue)) { - return true; - } - continue; } @@ -169,11 +164,9 @@ function ensureWebSearchSteeringPrompt(args: string[]): string[] { } if ( - hasExactFlagValue({ + hasManagedPromptFileArg({ args: optionArgs, - flag: PROMPT_FLAG_FILE, - expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, - allowPartiallyMatch: true, + promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, }) ) { return args; diff --git a/src/utils/websearch/trace.ts b/src/utils/websearch/trace.ts index 64a9e596..114cc48c 100644 --- a/src/utils/websearch/trace.ts +++ b/src/utils/websearch/trace.ts @@ -11,7 +11,7 @@ import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../config-manager'; import { createLogger } from '../../services/logging'; -import { PROMPT_FLAG_INLINE, PROMPT_FLAG_FILE } from '../prompt-injection-strategy'; +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'; @@ -61,9 +61,8 @@ function hasExactFlagValue(params: { args: string[]; flag: string; expectedValue: string; - allowIncludesValue?: boolean; }): boolean { - const { args, flag, expectedValue, allowIncludesValue } = params; + const { args, flag, expectedValue } = params; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -75,10 +74,6 @@ function hasExactFlagValue(params: { return true; } - if (allowIncludesValue && immediateFlagValue?.includes(expectedValue)) { - return true; - } - continue; } @@ -203,14 +198,7 @@ function hasSteeringPromptInArgs(args: string[]): boolean { return true; } - if ( - hasExactFlagValue({ - args, - flag: PROMPT_FLAG_FILE, - expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, - allowIncludesValue: true, - }) - ) { + if (hasManagedPromptFileArg({ args, promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name })) { return true; } 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 a68bc5e9..a95c19c9 100644 --- a/tests/unit/utils/image-analysis/claude-tool-args.test.ts +++ b/tests/unit/utils/image-analysis/claude-tool-args.test.ts @@ -66,4 +66,17 @@ describe('appendThirdPartyImageAnalysisToolArgs', () => { ); 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 index b69693fb..e2ccdb34 100644 --- a/tests/unit/utils/prompt-injection-strategy.test.ts +++ b/tests/unit/utils/prompt-injection-strategy.test.ts @@ -1,13 +1,36 @@ -import { describe, expect, it } from 'bun:test'; +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 { - detectPromptInjectionMode, 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'); @@ -48,23 +71,47 @@ describe('buildInlineSteeringArg', () => { }); describe('buildFileSteeringArg', () => { - it('returns file flag and writes temp file', () => { - const result = buildFileSteeringArg({promptFileName: 'ccs-test-prompt.txt', promptContent: 'hello world' }); + 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]).toContain('ccs-test-prompt.txt'); + 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', - ]); + expect( + buildSteeringArg({ + args: [PROMPT_FLAG_INLINE], + promptName: 'ignored.txt', + promptContent: 'hello', + }) + ).toEqual(['--append-system-prompt', 'hello']); }); it('delegates to file in file mode', () => { @@ -74,6 +121,6 @@ describe('buildSteeringArg', () => { promptContent: 'hello', }); expect(result[0]).toBe('--append-system-prompt-file'); - expect(result[1]).toContain('ccs-test.txt'); + expect(result[1]).toBe(getManagedPromptFilePath('ccs-test')); }); }); diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts index acad3acf..59d484c0 100644 --- a/tests/unit/utils/websearch/claude-tool-args.test.ts +++ b/tests/unit/utils/websearch/claude-tool-args.test.ts @@ -157,4 +157,16 @@ describe('appendThirdPartyWebSearchToolArgs', () => { 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); + }); }); From a68bc46cafd27b3a07d63df9597729b7ea6261a3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 13 Apr 2026 08:41:19 -0400 Subject: [PATCH 39/42] fix(test): isolate chrome reuse home env expectations --- src/utils/browser/chrome-reuse.ts | 21 ++++++--------- tests/unit/utils/browser/chrome-reuse.test.ts | 26 +++++++++++++------ 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/utils/browser/chrome-reuse.ts b/src/utils/browser/chrome-reuse.ts index 8fd77903..eacefe8b 100644 --- a/src/utils/browser/chrome-reuse.ts +++ b/src/utils/browser/chrome-reuse.ts @@ -19,24 +19,23 @@ export interface BrowserRuntimeEnv { const DEVTOOLS_HOST = '127.0.0.1'; const DEVTOOLS_ACTIVE_PORT_FILE = 'DevToolsActivePort'; -export function resolveDefaultChromeUserDataDir(platform = process.platform): string { +export function resolveDefaultChromeUserDataDir( + platform = process.platform, + env: NodeJS.ProcessEnv = process.env +): string { switch (platform) { case 'darwin': return path.join( - process.env.HOME || process.env.USERPROFILE || '', + env.HOME || env.USERPROFILE || '', 'Library', 'Application Support', 'Google', 'Chrome' ); case 'linux': - return path.join( - process.env.HOME || process.env.USERPROFILE || '', - '.config', - 'google-chrome' - ); + return path.join(env.HOME || env.USERPROFILE || '', '.config', 'google-chrome'); case 'win32': { - const localAppData = process.env.LOCALAPPDATA; + const localAppData = env.LOCALAPPDATA; if (!localAppData) { throw new Error( 'LOCALAPPDATA is required to resolve the default Chrome user-data-dir on Windows' @@ -45,11 +44,7 @@ export function resolveDefaultChromeUserDataDir(platform = process.platform): st return path.join(localAppData, 'Google', 'Chrome', 'User Data'); } default: - return path.join( - process.env.HOME || process.env.USERPROFILE || '', - '.config', - 'google-chrome' - ); + return path.join(env.HOME || env.USERPROFILE || '', '.config', 'google-chrome'); } } diff --git a/tests/unit/utils/browser/chrome-reuse.test.ts b/tests/unit/utils/browser/chrome-reuse.test.ts index 79a78e81..e0b8ac8a 100644 --- a/tests/unit/utils/browser/chrome-reuse.test.ts +++ b/tests/unit/utils/browser/chrome-reuse.test.ts @@ -135,7 +135,9 @@ describe('chrome reuse resolver', () => { }); 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'); + 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 () => { @@ -196,15 +198,20 @@ describe('chrome reuse resolver', () => { }); it('resolves platform default Chrome user-data-dir paths', () => { - process.env.LOCALAPPDATA = 'C:/Users/test/AppData/Local'; + const isolatedHome = createTempDir('ccs-chrome-home-'); + const env = { + HOME: isolatedHome, + USERPROFILE: isolatedHome, + LOCALAPPDATA: 'C:/Users/test/AppData/Local', + }; - expect(resolveDefaultChromeUserDataDir('darwin')).toBe( - path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome') + expect(resolveDefaultChromeUserDataDir('darwin', env)).toBe( + path.join(isolatedHome, 'Library', 'Application Support', 'Google', 'Chrome') ); - expect(resolveDefaultChromeUserDataDir('linux')).toBe( - path.join(os.homedir(), '.config', 'google-chrome') + expect(resolveDefaultChromeUserDataDir('linux', env)).toBe( + path.join(isolatedHome, '.config', 'google-chrome') ); - expect(resolveDefaultChromeUserDataDir('win32')).toBe( + expect(resolveDefaultChromeUserDataDir('win32', env)).toBe( path.normalize('C:/Users/test/AppData/Local/Google/Chrome/User Data') ); }); @@ -218,7 +225,10 @@ describe('chrome reuse resolver', () => { }); 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'); + const missingProfileDir = path.join( + createTempDir('ccs-chrome-missing-dir-'), + 'missing-profile' + ); await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow( `Chrome profile directory is invalid: ${missingProfileDir}` From 01b98ff9f3b8c056753da59359c90ca0eb09a9d2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Apr 2026 12:42:30 +0000 Subject: [PATCH 40/42] chore(release): 7.68.1-dev.7 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1a972940..b5dd1fd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1-dev.6", + "version": "7.68.1-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From abd2b28428f2f80c7b00dd5e2bd5bc3a3ac90281 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Apr 2026 13:36:38 +0000 Subject: [PATCH 41/42] chore(release): 7.68.1-dev.8 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0a0a8083..106e65af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1-dev.7", + "version": "7.68.1-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From dd71d7349bb661b887cc3ff9175b224c61adb7f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Apr 2026 13:49:30 +0000 Subject: [PATCH 42/42] chore(release): 7.68.1-dev.9 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 106e65af..421cedd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1-dev.8", + "version": "7.68.1-dev.9", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",