mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
Merge pull request #978 from kaitranntt/dev
feat(release): promote dev to main
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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:<port>`
|
||||
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 <token> --machine-id <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
|
||||
|
||||
+45
-29
@@ -1,6 +1,6 @@
|
||||
# WebSearch Configuration Guide
|
||||
|
||||
Last Updated: 2026-04-04
|
||||
Last Updated: 2026-04-11
|
||||
|
||||
CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API.
|
||||
|
||||
@@ -17,29 +17,30 @@ Third-party profiles cannot execute Anthropic's server-side WebSearch because th
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Claude Code CLI │
|
||||
│ │
|
||||
│ Search Request │
|
||||
│ │ │
|
||||
│ ├── Native Claude Account? → Anthropic WebSearch API │
|
||||
│ │ │
|
||||
│ └── Third-party Profile? → native WebSearch disabled │
|
||||
│ │ │
|
||||
│ ├── CCS MCP tool when ready│
|
||||
│ │ ccs-websearch.WebSearch│
|
||||
│ │ │ │
|
||||
│ │ ├── 1. Exa │
|
||||
│ │ ├── 2. Tavily│
|
||||
│ │ ├── 3. Brave │
|
||||
│ │ ├── 4. DuckDuckGo│
|
||||
│ │ └── 5. Legacy CLI│
|
||||
│ │ fallback │
|
||||
│ │ (Gemini/ │
|
||||
│ │ OpenCode/ │
|
||||
│ │ Grok) │
|
||||
│ └── Bash/network fallback │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Claude Code CLI │
|
||||
│ │
|
||||
│ Search Request │
|
||||
│ │ │
|
||||
│ ├── Native Claude Account? → Anthropic WebSearch API │
|
||||
│ │ │
|
||||
│ └── Third-party Profile? → native WebSearch disabled │
|
||||
│ │ │
|
||||
│ ├── CCS MCP tool when ready │
|
||||
│ │ ccs-websearch.WebSearch │
|
||||
│ │ │ │
|
||||
│ │ ├── 1. Exa │
|
||||
│ │ ├── 2. Tavily │
|
||||
│ │ ├── 3. Brave │
|
||||
│ │ ├── 4. SearXNG │
|
||||
│ │ ├── 5. DuckDuckGo│
|
||||
│ │ └── 6. Legacy CLI│
|
||||
│ │ fallback │
|
||||
│ │ (Gemini/ │
|
||||
│ │ OpenCode/│
|
||||
│ │ Grok) │
|
||||
│ └── Bash/network fallback │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Why This Changed
|
||||
@@ -66,8 +67,9 @@ That shared launch helper applies to normal third-party settings profiles, CLIPr
|
||||
|----------|------|-------|---------|-------|
|
||||
| Exa | HTTP API | `EXA_API_KEY` | No | High-quality API search with extracted content |
|
||||
| Tavily | HTTP API | `TAVILY_API_KEY` | No | Agent-oriented search API |
|
||||
| DuckDuckGo | HTML fetch | None | Yes | Built-in zero-setup fallback |
|
||||
| Brave Search | HTTP API | `BRAVE_API_KEY` | No | Cleaner snippets and metadata |
|
||||
| SearXNG | JSON API | `providers.searxng.url` | No | Self-hosted/public SearXNG backend via `/search?format=json` |
|
||||
| DuckDuckGo | HTML fetch | None | Yes | Built-in zero-setup fallback |
|
||||
| Gemini CLI | Legacy CLI | `npm i -g @google/gemini-cli` | No | Optional compatibility fallback |
|
||||
| OpenCode | Legacy CLI | `curl -fsSL https://opencode.ai/install \| bash` | No | Optional compatibility fallback |
|
||||
| Grok CLI | Legacy CLI | `npm i -g @vibe-kit/grok-cli` + `GROK_API_KEY` | No | Optional compatibility fallback |
|
||||
@@ -78,7 +80,9 @@ That shared launch helper applies to normal third-party settings profiles, CLIPr
|
||||
|
||||
Open `ccs config` → `Settings` → `WebSearch`.
|
||||
|
||||
- Enable Exa, Tavily, Brave, or DuckDuckGo in the backend chain
|
||||
- Enable Exa, Tavily, Brave, SearXNG, or DuckDuckGo in the backend chain
|
||||
- Configure the SearXNG base URL (for example `https://search.example.com`) when SearXNG is enabled
|
||||
Do not include `/search`, embedded credentials, query parameters, or URL fragments. CCS appends `/search?format=json`.
|
||||
- Set or rotate Exa, Tavily, and Brave API keys directly inside each provider card
|
||||
- Saved keys are persisted in `global_env` and injected at runtime, so readiness updates from the same screen
|
||||
- Review whether any legacy fallback CLIs are still enabled in config
|
||||
@@ -97,12 +101,16 @@ websearch:
|
||||
tavily:
|
||||
enabled: false
|
||||
max_results: 5
|
||||
duckduckgo:
|
||||
enabled: true
|
||||
max_results: 5
|
||||
brave:
|
||||
enabled: false
|
||||
max_results: 5
|
||||
searxng:
|
||||
enabled: false
|
||||
url: ""
|
||||
max_results: 5
|
||||
duckduckgo:
|
||||
enabled: true
|
||||
max_results: 5
|
||||
gemini:
|
||||
enabled: false
|
||||
model: gemini-2.5-flash
|
||||
@@ -125,6 +133,8 @@ Note: `enabled: false` stops provisioning the managed local `ccs-websearch.WebSe
|
||||
| `EXA_API_KEY` | Enables Exa when `providers.exa.enabled: true` |
|
||||
| `TAVILY_API_KEY` | Enables Tavily when `providers.tavily.enabled: true` |
|
||||
| `BRAVE_API_KEY` | Enables Brave Search when `providers.brave.enabled: true` |
|
||||
| `CCS_WEBSEARCH_SEARXNG_URL` | Runtime URL used when `providers.searxng.enabled: true` |
|
||||
| `CCS_WEBSEARCH_SEARXNG_MAX_RESULTS` | Optional runtime override for SearXNG result count (clamped 1..10) |
|
||||
| `GROK_API_KEY` | Required only for legacy Grok CLI fallback |
|
||||
| `CCS_WEBSEARCH_SKIP` | Disable the CCS local WebSearch runtime for the current process; third-party launches still keep native Anthropic `WebSearch` disabled |
|
||||
| `CCS_DEBUG` | Verbose WebSearch runtime logging |
|
||||
@@ -156,6 +166,12 @@ ccs config
|
||||
|
||||
If the dashboard says the key is stored but still not ready, check whether `Settings -> Global Env` is disabled. WebSearch reuses that injection path for dashboard-managed keys.
|
||||
|
||||
### SearXNG is enabled but not ready
|
||||
|
||||
1. Confirm the configured base URL is valid (for example `https://search.example.com`)
|
||||
2. Confirm the instance exposes `GET /search?q=<query>&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.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* - Exa Search API
|
||||
* - Tavily Search API
|
||||
* - Brave Search API
|
||||
* - SearXNG JSON API
|
||||
* - DuckDuckGo HTML search
|
||||
*
|
||||
* Legacy compatibility fallback:
|
||||
@@ -369,6 +370,36 @@ function getResultCount(provider) {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10) : DEFAULT_RESULT_COUNT;
|
||||
}
|
||||
|
||||
function getSearxngBaseUrl() {
|
||||
const raw = (process.env.CCS_WEBSEARCH_SEARXNG_URL || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.search || parsed.hash) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let pathname = parsed.pathname.replace(/\/+$/, '');
|
||||
if (pathname.toLowerCase().endsWith('/search')) {
|
||||
pathname = pathname.slice(0, -'/search'.length);
|
||||
}
|
||||
|
||||
parsed.pathname = pathname || '/';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompt(providerId, query) {
|
||||
const config = PROVIDER_CONFIG[providerId];
|
||||
const parts = [
|
||||
@@ -569,6 +600,104 @@ async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
}
|
||||
}
|
||||
|
||||
async function trySearxngSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
const baseUrl = getSearxngBaseUrl();
|
||||
if (!baseUrl) {
|
||||
return { success: false, error: 'SearXNG URL is invalid or not configured' };
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'json',
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
`${baseUrl}/search?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
timeoutSec * 1000
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
if (
|
||||
response.status === 403 &&
|
||||
/format(?:=|\s*)json|json[^\n]{0,40}disabled|disabled[^\n]{0,40}json/i.test(body)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SearXNG returned 403: format=json is disabled on this instance',
|
||||
statusCode: response.status,
|
||||
retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `SearXNG returned ${response.status}: ${body.slice(0, 160)}`,
|
||||
statusCode: response.status,
|
||||
retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
|
||||
};
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SearXNG returned malformed JSON payload',
|
||||
};
|
||||
}
|
||||
|
||||
if (!body || typeof body !== 'object' || !Array.isArray(body.results)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SearXNG JSON response is missing results[]',
|
||||
};
|
||||
}
|
||||
|
||||
const count = getResultCount('searxng');
|
||||
const results = body.results.slice(0, count).map((entry) => {
|
||||
const result = entry && typeof entry === 'object' ? entry : {};
|
||||
const url = typeof result.url === 'string' ? result.url : '';
|
||||
const titleSource =
|
||||
typeof result.title === 'string' && result.title.trim().length > 0
|
||||
? result.title
|
||||
: url || 'Untitled';
|
||||
const descriptionSource =
|
||||
typeof result.content === 'string'
|
||||
? result.content
|
||||
: typeof result.description === 'string'
|
||||
? result.description
|
||||
: typeof result.snippet === 'string'
|
||||
? result.snippet
|
||||
: '';
|
||||
|
||||
return {
|
||||
title: compactText(titleSource, 120),
|
||||
url,
|
||||
description: compactText(descriptionSource, 240),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: formatStructuredSearchResults(query, 'SearXNG', results),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.name === 'AbortError' ? 'SearXNG timed out' : error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
const apiKey = getProviderApiKey('exa');
|
||||
if (!apiKey) {
|
||||
@@ -892,6 +1021,12 @@ function getConfiguredProviders() {
|
||||
available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')),
|
||||
fn: tryBraveSearch,
|
||||
},
|
||||
{
|
||||
name: 'SearXNG',
|
||||
id: 'searxng',
|
||||
available: () => isProviderEnabled('searxng') && Boolean(getSearxngBaseUrl()),
|
||||
fn: trySearxngSearch,
|
||||
},
|
||||
{
|
||||
name: 'DuckDuckGo',
|
||||
id: 'duckduckgo',
|
||||
@@ -1280,4 +1415,5 @@ module.exports = {
|
||||
tryTavilySearch,
|
||||
tryDuckDuckGoSearch,
|
||||
tryBraveSearch,
|
||||
trySearxngSearch,
|
||||
};
|
||||
|
||||
Executable
+877
@@ -0,0 +1,877 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { WebSocket } = require('ws');
|
||||
|
||||
const PROTOCOL_VERSION = '2024-11-05';
|
||||
const SERVER_NAME = 'ccs-browser';
|
||||
const SERVER_VERSION = '1.0.0';
|
||||
const TOOL_SESSION_INFO = 'browser_get_session_info';
|
||||
const TOOL_URL_TITLE = 'browser_get_url_and_title';
|
||||
const TOOL_VISIBLE_TEXT = 'browser_get_visible_text';
|
||||
const TOOL_DOM_SNAPSHOT = 'browser_get_dom_snapshot';
|
||||
const TOOL_NAVIGATE = 'browser_navigate';
|
||||
const TOOL_CLICK = 'browser_click';
|
||||
const TOOL_TYPE = 'browser_type';
|
||||
const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot';
|
||||
const TOOL_NAMES = [
|
||||
TOOL_SESSION_INFO,
|
||||
TOOL_URL_TITLE,
|
||||
TOOL_VISIBLE_TEXT,
|
||||
TOOL_DOM_SNAPSHOT,
|
||||
TOOL_NAVIGATE,
|
||||
TOOL_CLICK,
|
||||
TOOL_TYPE,
|
||||
TOOL_TAKE_SCREENSHOT,
|
||||
];
|
||||
const CDP_TIMEOUT_MS = 5000;
|
||||
const NAVIGATION_POLL_INTERVAL_MS = 100;
|
||||
|
||||
let inputBuffer = Buffer.alloc(0);
|
||||
let requestCounter = 0;
|
||||
|
||||
function shouldExposeTools() {
|
||||
return Boolean(process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL);
|
||||
}
|
||||
|
||||
function writeMessage(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function writeResponse(id, result) {
|
||||
writeMessage({ jsonrpc: '2.0', id, result });
|
||||
}
|
||||
|
||||
function writeError(id, code, message) {
|
||||
writeMessage({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
|
||||
function getTools() {
|
||||
if (!shouldExposeTools()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: TOOL_SESSION_INFO,
|
||||
description:
|
||||
'List the current Chrome session pages available through the configured DevTools connection, including page ids, titles, URLs, and websocket endpoints.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_URL_TITLE,
|
||||
description:
|
||||
'Read the current page URL and title from the configured Chrome session. Optionally choose a page by index.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_VISIBLE_TEXT,
|
||||
description:
|
||||
'Read visible text from the current page via DOM evaluation in the configured Chrome session. Optionally choose a page by index.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_DOM_SNAPSHOT,
|
||||
description:
|
||||
'Read a DOM snapshot from the current page by returning the document outerHTML. Optionally choose a page by index.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAVIGATE,
|
||||
description:
|
||||
'Navigate the selected page to an absolute http or https URL and wait until navigation is ready. Optionally choose a page by index.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Required absolute http or https URL to navigate to.',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_CLICK,
|
||||
description:
|
||||
'Click the first element matching a CSS selector in the selected page using a minimal mouse event chain with click fallback. Optionally choose a page by index.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
description: 'Required CSS selector for the element to click.',
|
||||
},
|
||||
},
|
||||
required: ['selector'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_TYPE,
|
||||
description:
|
||||
'Type text into the first element matching a CSS selector when it is a supported text-editable target. Optionally choose a page by index.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
description: 'Required CSS selector for the target element.',
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'Required text to assign. May be an empty string.',
|
||||
},
|
||||
clearFirst: {
|
||||
type: 'boolean',
|
||||
description: 'When true, clear the current value or content before assigning text.',
|
||||
},
|
||||
},
|
||||
required: ['selector', 'text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_TAKE_SCREENSHOT,
|
||||
description:
|
||||
'Capture a PNG screenshot from the selected page. Optionally choose a page by index or request fullPage capture.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
fullPage: {
|
||||
type: 'boolean',
|
||||
description: 'Optional full-page capture flag.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
function getHttpUrl() {
|
||||
const value = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||
if (!value) {
|
||||
throw new Error('Browser MCP is unavailable because CCS_BROWSER_DEVTOOLS_HTTP_URL is missing.');
|
||||
}
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async function listPageTargets() {
|
||||
const targets = await fetchJson(`${getHttpUrl()}/json/list`);
|
||||
if (!Array.isArray(targets)) {
|
||||
throw new Error('Browser MCP received an invalid /json/list response.');
|
||||
}
|
||||
|
||||
return targets
|
||||
.filter((target) => target && typeof target === 'object' && target.type === 'page')
|
||||
.map((target) => ({
|
||||
id: typeof target.id === 'string' ? target.id : '',
|
||||
title: typeof target.title === 'string' ? target.title : '',
|
||||
url: typeof target.url === 'string' ? target.url : '',
|
||||
type: typeof target.type === 'string' ? target.type : 'page',
|
||||
webSocketDebuggerUrl:
|
||||
typeof target.webSocketDebuggerUrl === 'string' ? target.webSocketDebuggerUrl : '',
|
||||
}));
|
||||
}
|
||||
|
||||
function parsePageIndex(toolArgs) {
|
||||
if (!toolArgs || !Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(toolArgs.pageIndex) || toolArgs.pageIndex < 0) {
|
||||
throw new Error('pageIndex must be a non-negative integer');
|
||||
}
|
||||
|
||||
return toolArgs.pageIndex;
|
||||
}
|
||||
|
||||
async function getSelectedPage(toolArgs) {
|
||||
const pages = await listPageTargets();
|
||||
if (pages.length === 0) {
|
||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||
}
|
||||
|
||||
const pageIndex = parsePageIndex(toolArgs);
|
||||
|
||||
const page = pages[pageIndex];
|
||||
if (!page) {
|
||||
throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`);
|
||||
}
|
||||
if (!page.webSocketDebuggerUrl) {
|
||||
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
||||
}
|
||||
|
||||
return { page, pageIndex, pages };
|
||||
}
|
||||
|
||||
function formatSessionInfo(pages) {
|
||||
return [
|
||||
'[CCS Browser Session]',
|
||||
'',
|
||||
...pages.map((page, index) => `${index}. ${page.title || '<untitled>'} | ${page.url || '<empty>'}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function createEvaluateExpression(kind) {
|
||||
switch (kind) {
|
||||
case 'url-title':
|
||||
return `JSON.stringify({ title: document.title, url: location.href })`;
|
||||
case 'visible-text':
|
||||
return `document.body ? document.body.innerText : ''`;
|
||||
case 'dom-snapshot':
|
||||
return `document.documentElement ? document.documentElement.outerHTML : ''`;
|
||||
default:
|
||||
throw new Error(`Unknown browser evaluation kind: ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCdpCommand(page, method, params = {}) {
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
const requestId = ++requestCounter;
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ws.terminate();
|
||||
reject(new Error('Browser MCP timed out waiting for a DevTools response.'));
|
||||
}
|
||||
}, CDP_TIMEOUT_MS);
|
||||
|
||||
function settleError(error) {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: requestId,
|
||||
method,
|
||||
params,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
settled = true;
|
||||
ws.close();
|
||||
|
||||
if (message.error) {
|
||||
reject(new Error(message.error.message || 'DevTools request failed.'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(message.result || null);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
settleError(error);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settleError(new Error('Browser MCP lost the DevTools websocket connection.'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function evaluateInPage(page, kind) {
|
||||
const response = await sendCdpCommand(page, 'Runtime.evaluate', {
|
||||
expression: createEvaluateExpression(kind),
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
const result = response && response.result ? response.result : null;
|
||||
if (!result) {
|
||||
throw new Error('Browser MCP received an invalid DevTools evaluation response.');
|
||||
}
|
||||
|
||||
if (result.subtype === 'error') {
|
||||
throw new Error(result.description || 'DevTools evaluation returned an error.');
|
||||
}
|
||||
|
||||
return typeof result.value === 'string' ? result.value : result.value ?? '';
|
||||
}
|
||||
|
||||
async function evaluateExpression(page, expression) {
|
||||
const response = await sendCdpCommand(page, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
const result = response && response.result ? response.result : null;
|
||||
if (!result) {
|
||||
throw new Error('Browser MCP received an invalid DevTools evaluation response.');
|
||||
}
|
||||
|
||||
if (result.subtype === 'error') {
|
||||
throw new Error(result.description || 'DevTools evaluation returned an error.');
|
||||
}
|
||||
|
||||
return typeof result.value === 'string' ? result.value : result.value ?? '';
|
||||
}
|
||||
|
||||
function requireNonEmptyString(value, label) {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requireString(value, label) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireValidHttpUrl(value) {
|
||||
const raw = requireNonEmptyString(value, 'url');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error('url must be an absolute http or https URL');
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('url must be an absolute http or https URL');
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getNavigationState(page) {
|
||||
const raw = await evaluateExpression(page, `JSON.stringify({ href: location.href, readyState: document.readyState })`);
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
href: typeof parsed.href === 'string' ? parsed.href : '',
|
||||
readyState: typeof parsed.readyState === 'string' ? parsed.readyState : '',
|
||||
};
|
||||
}
|
||||
|
||||
function isSameDocumentHashNavigation(beforeHref, requestedUrl) {
|
||||
try {
|
||||
const before = new URL(beforeHref);
|
||||
const requested = new URL(requestedUrl);
|
||||
return (
|
||||
before.origin === requested.origin &&
|
||||
before.pathname === requested.pathname &&
|
||||
before.search === requested.search &&
|
||||
before.hash !== requested.hash
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isNavigationReady(state, beforeHref, requestedUrl) {
|
||||
if (state.readyState !== 'interactive' && state.readyState !== 'complete') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.href === requestedUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (state.href && state.href !== beforeHref) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isSameDocumentHashNavigation(beforeHref, requestedUrl) && state.href === requestedUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitForNavigationReady(page, beforeHref, requestedUrl) {
|
||||
const deadline = Date.now() + CDP_TIMEOUT_MS;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
const state = await getNavigationState(page);
|
||||
if (isNavigationReady(state, beforeHref, requestedUrl)) {
|
||||
return state.href;
|
||||
}
|
||||
if (Date.now() + NAVIGATION_POLL_INTERVAL_MS > deadline) {
|
||||
break;
|
||||
}
|
||||
await sleep(NAVIGATION_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`navigation did not complete for URL: ${requestedUrl}`);
|
||||
}
|
||||
|
||||
async function handleNavigate(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const url = requireValidHttpUrl(toolArgs.url);
|
||||
const before = await getNavigationState(page);
|
||||
const navigateResult = await sendCdpCommand(page, 'Page.navigate', { url });
|
||||
if (navigateResult && typeof navigateResult.errorText === 'string' && navigateResult.errorText) {
|
||||
throw new Error(`navigation failed for URL: ${url}: ${navigateResult.errorText}`);
|
||||
}
|
||||
const finalUrl = await waitForNavigationReady(page, before.href, url);
|
||||
return `pageIndex: ${pageIndex}\nurl: ${finalUrl}\nstatus: navigated`;
|
||||
}
|
||||
|
||||
async function handleClick(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
||||
|
||||
const expression = `(() => {
|
||||
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))});
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error('element not found for selector: ' + selector);
|
||||
}
|
||||
if (!element.isConnected) {
|
||||
throw new Error('element is detached for selector: ' + selector);
|
||||
}
|
||||
if ('disabled' in element && element.disabled) {
|
||||
throw new Error('element is disabled for selector: ' + selector);
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0
|
||||
) {
|
||||
throw new Error('element is hidden or not interactable for selector: ' + selector);
|
||||
}
|
||||
element.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
|
||||
const dispatchMouseEvent = (type, init) => {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
view: window,
|
||||
detail: 1,
|
||||
...init,
|
||||
});
|
||||
return element.dispatchEvent(event);
|
||||
};
|
||||
|
||||
try {
|
||||
const dispatchResult = {
|
||||
shouldActivate:
|
||||
dispatchMouseEvent('mousedown', { button: 0, buttons: 1 }) &&
|
||||
dispatchMouseEvent('mouseup', { button: 0, buttons: 0 }),
|
||||
};
|
||||
if (!dispatchResult.shouldActivate) {
|
||||
return 'ok';
|
||||
}
|
||||
if (!element.isConnected) {
|
||||
return 'ok';
|
||||
}
|
||||
} catch (mouseError) {
|
||||
// Fall through to the native activation path below.
|
||||
}
|
||||
|
||||
element.click();
|
||||
|
||||
return 'ok';
|
||||
})()`;
|
||||
|
||||
await evaluateExpression(page, expression);
|
||||
return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: clicked`;
|
||||
}
|
||||
|
||||
async function handleType(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
||||
const text = requireString(toolArgs.text, 'text');
|
||||
const clearFirst = toolArgs.clearFirst === true;
|
||||
|
||||
const expression = `(() => {
|
||||
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))});
|
||||
const text = JSON.parse(${JSON.stringify(JSON.stringify(text))});
|
||||
const clearFirst = ${clearFirst ? 'true' : 'false'};
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error('element not found for selector: ' + selector);
|
||||
}
|
||||
|
||||
const dispatchEvents = (target) => {
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
target.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
|
||||
const focusTarget = (target) => {
|
||||
if (typeof target.focus === 'function') {
|
||||
target.focus();
|
||||
}
|
||||
};
|
||||
|
||||
let readback = '';
|
||||
let expectedValue = '';
|
||||
|
||||
if (element instanceof HTMLTextAreaElement) {
|
||||
focusTarget(element);
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
expectedValue = (clearFirst ? '' : element.value) + text;
|
||||
if (setter) {
|
||||
setter.call(element, expectedValue);
|
||||
} else {
|
||||
element.value = expectedValue;
|
||||
}
|
||||
dispatchEvents(element);
|
||||
readback = element.value;
|
||||
} else if (element instanceof HTMLInputElement) {
|
||||
const supportedTypes = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'password']);
|
||||
const normalizedType = (element.getAttribute('type') || '').toLowerCase();
|
||||
if (!supportedTypes.has(normalizedType)) {
|
||||
throw new Error('element is not text-editable for selector: ' + selector);
|
||||
}
|
||||
focusTarget(element);
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
expectedValue = (clearFirst ? '' : element.value) + text;
|
||||
if (setter) {
|
||||
setter.call(element, expectedValue);
|
||||
} else {
|
||||
element.value = expectedValue;
|
||||
}
|
||||
dispatchEvents(element);
|
||||
readback = element.value;
|
||||
} else if (element.isContentEditable === true) {
|
||||
focusTarget(element);
|
||||
expectedValue = (clearFirst ? '' : (element.textContent || '')) + text;
|
||||
element.textContent = expectedValue;
|
||||
dispatchEvents(element);
|
||||
readback = element.textContent || '';
|
||||
} else {
|
||||
throw new Error('element is not text-editable for selector: ' + selector);
|
||||
}
|
||||
|
||||
if (readback !== expectedValue) {
|
||||
throw new Error('typed text verification failed for selector: ' + selector);
|
||||
}
|
||||
|
||||
return JSON.stringify({ value: readback, typedLength: readback.length });
|
||||
})()`;
|
||||
|
||||
const raw = await evaluateExpression(page, expression);
|
||||
const parsed = JSON.parse(raw);
|
||||
const typedLength = typeof parsed.typedLength === 'number' ? parsed.typedLength : String(parsed.value || '').length;
|
||||
return `pageIndex: ${pageIndex}\nselector: ${selector}\ntypedLength: ${typedLength}\nstatus: typed`;
|
||||
}
|
||||
|
||||
async function handleScreenshot(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const fullPage = toolArgs.fullPage === true;
|
||||
const response = await sendCdpCommand(page, 'Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
captureBeyondViewport: fullPage,
|
||||
});
|
||||
|
||||
const data = response && typeof response.data === 'string' ? response.data : '';
|
||||
if (!data) {
|
||||
throw new Error('screenshot capture failed');
|
||||
}
|
||||
|
||||
return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`;
|
||||
}
|
||||
|
||||
async function handleToolCall(message) {
|
||||
const id = message.id;
|
||||
const params = message.params || {};
|
||||
const toolName = params.name || '<missing>';
|
||||
const toolArgs = params.arguments || {};
|
||||
|
||||
if (!TOOL_NAMES.includes(toolName)) {
|
||||
writeError(id, -32602, `Unknown tool: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldExposeTools()) {
|
||||
writeResponse(id, {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Browser MCP is unavailable because browser reuse is not configured for this Claude session.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (toolName === TOOL_SESSION_INFO) {
|
||||
const pages = await listPageTargets();
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text: formatSessionInfo(pages) }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_NAVIGATE) {
|
||||
const text = await handleNavigate(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_CLICK) {
|
||||
const text = await handleClick(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_TYPE) {
|
||||
const text = await handleType(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_TAKE_SCREENSHOT) {
|
||||
const text = await handleScreenshot(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
|
||||
if (toolName === TOOL_URL_TITLE) {
|
||||
const raw = await evaluateInPage(page, 'url-title');
|
||||
const parsed = JSON.parse(raw);
|
||||
writeResponse(id, {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `pageIndex: ${pageIndex}\ntitle: ${parsed.title || ''}\nurl: ${parsed.url || ''}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_VISIBLE_TEXT) {
|
||||
const text = await evaluateInPage(page, 'visible-text');
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text: text || '' }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await evaluateInPage(page, 'dom-snapshot');
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text: html || '' }],
|
||||
});
|
||||
} catch (error) {
|
||||
writeResponse(id, {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Browser MCP failed: ${(error && error.message) || String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessage(message) {
|
||||
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.method) {
|
||||
case 'initialize':
|
||||
writeResponse(message.id, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
||||
});
|
||||
return;
|
||||
case 'notifications/initialized':
|
||||
return;
|
||||
case 'ping':
|
||||
writeResponse(message.id, {});
|
||||
return;
|
||||
case 'tools/list':
|
||||
writeResponse(message.id, { tools: getTools() });
|
||||
return;
|
||||
case 'tools/call':
|
||||
await handleToolCall(message);
|
||||
return;
|
||||
default:
|
||||
if (message.id !== undefined) {
|
||||
writeError(message.id, -32601, `Method not found: ${message.method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseMessages() {
|
||||
while (true) {
|
||||
let body;
|
||||
const startsWithLegacyHeaders = inputBuffer
|
||||
.subarray(0, Math.min(inputBuffer.length, 32))
|
||||
.toString('utf8')
|
||||
.toLowerCase()
|
||||
.startsWith('content-length:');
|
||||
|
||||
if (startsWithLegacyHeaders) {
|
||||
const headerEnd = inputBuffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerText = inputBuffer.subarray(0, headerEnd).toString('utf8');
|
||||
const match = headerText.match(/content-length:\s*(\d+)/i);
|
||||
if (!match) {
|
||||
inputBuffer = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentLength = Number.parseInt(match[1], 10);
|
||||
const messageEnd = headerEnd + 4 + contentLength;
|
||||
if (inputBuffer.length < messageEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
body = inputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8');
|
||||
inputBuffer = inputBuffer.subarray(messageEnd);
|
||||
} else {
|
||||
const newlineIndex = inputBuffer.indexOf('\n');
|
||||
if (newlineIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
body = inputBuffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
|
||||
inputBuffer = inputBuffer.subarray(newlineIndex + 1);
|
||||
if (!body) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(body);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
Promise.resolve(handleMessage(message)).catch((error) => {
|
||||
if (message && message.id !== undefined) {
|
||||
writeError(message.id, -32603, (error && error.message) || 'Internal error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on('data', (chunk) => {
|
||||
inputBuffer = Buffer.concat([inputBuffer, chunk]);
|
||||
parseMessages();
|
||||
});
|
||||
|
||||
process.stdin.on('error', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) => {
|
||||
process.on(signal, () => process.exit(0));
|
||||
});
|
||||
|
||||
process.stdin.resume();
|
||||
@@ -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);
|
||||
|
||||
+77
-2
@@ -36,6 +36,13 @@ import {
|
||||
syncImageAnalysisMcpToConfigDir,
|
||||
appendThirdPartyImageAnalysisToolArgs,
|
||||
} from './utils/image-analysis';
|
||||
import {
|
||||
appendBrowserToolArgs,
|
||||
ensureBrowserMcpOrThrow,
|
||||
resolveBrowserRuntimeEnv,
|
||||
resolveConfiguredBrowserProfileDir,
|
||||
syncBrowserMcpToConfigDir,
|
||||
} from './utils/browser';
|
||||
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
|
||||
import {
|
||||
ensureProfileHooks as ensureImageAnalyzerHooks,
|
||||
@@ -69,6 +76,7 @@ import { execClaude } from './utils/shell-executor';
|
||||
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
||||
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
||||
import { createLogger } from './services/logging';
|
||||
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
|
||||
|
||||
// Import target adapter system
|
||||
import {
|
||||
@@ -117,6 +125,15 @@ interface RuntimeReasoningResolution {
|
||||
const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||
const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
|
||||
|
||||
function resolveCodexRuntimeConfigOverrides(
|
||||
target: ReturnType<typeof resolveTargetType>
|
||||
): string[] {
|
||||
if (target !== 'codex') {
|
||||
return [];
|
||||
}
|
||||
return buildCodexBrowserMcpOverrides();
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart profile detection
|
||||
*/
|
||||
@@ -624,6 +641,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// For non-claude targets, verify target binary exists once and pass it through.
|
||||
const targetBinaryInfo = targetAdapter?.detectBinary() ?? null;
|
||||
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(resolvedTarget);
|
||||
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
|
||||
const displayName = targetAdapter?.displayName || resolvedTarget;
|
||||
console.error(fail(`${displayName} CLI not found.`));
|
||||
@@ -867,6 +885,7 @@ async function main(): Promise<void> {
|
||||
model: envVars['ANTHROPIC_MODEL'],
|
||||
}),
|
||||
reasoningOverride: runtimeReasoningOverride,
|
||||
runtimeConfigOverrides: codexRuntimeConfigOverrides,
|
||||
envVars,
|
||||
};
|
||||
|
||||
@@ -982,8 +1001,16 @@ async function main(): Promise<void> {
|
||||
// Settings-based profiles (glm, glmt) are third-party providers
|
||||
const imageAnalysisMcpReady =
|
||||
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
|
||||
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
||||
const browserProfileDir =
|
||||
resolvedTarget === 'claude'
|
||||
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
|
||||
: undefined;
|
||||
if (resolvedTarget === 'claude') {
|
||||
ensureWebSearchMcpOrThrow();
|
||||
if (browserProfileDir) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
}
|
||||
}
|
||||
|
||||
// Display WebSearch status (single line, equilibrium UX)
|
||||
@@ -1007,6 +1034,15 @@ async function main(): Promise<void> {
|
||||
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
||||
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
if (
|
||||
browserProfileDir &&
|
||||
inheritedClaudeConfigDir &&
|
||||
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
||||
) {
|
||||
throw new Error(
|
||||
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
|
||||
);
|
||||
}
|
||||
const expandedSettingsPath =
|
||||
resolvedSettingsPath ??
|
||||
(profileInfo.settingsPath
|
||||
@@ -1014,6 +1050,7 @@ async function main(): Promise<void> {
|
||||
: getSettingsPath(profileInfo.name));
|
||||
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
|
||||
const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
|
||||
|
||||
let imageAnalysisFallbackHookReady: boolean | undefined;
|
||||
if (resolvedTarget === 'claude') {
|
||||
if (imageAnalysisMcpReady) {
|
||||
@@ -1208,12 +1245,21 @@ async function main(): Promise<void> {
|
||||
|
||||
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
|
||||
// values from prior sessions cannot leak into the active profile.
|
||||
if (browserProfileDir) {
|
||||
browserRuntimeEnv = {
|
||||
...(await resolveBrowserRuntimeEnv({
|
||||
profileDir: browserProfileDir,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
...globalEnv,
|
||||
...settingsEnv,
|
||||
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
||||
...webSearchEnv,
|
||||
...imageAnalysisEnv,
|
||||
...(browserRuntimeEnv || {}),
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
};
|
||||
|
||||
@@ -1238,6 +1284,7 @@ async function main(): Promise<void> {
|
||||
model: settingsEnv['ANTHROPIC_MODEL'],
|
||||
}),
|
||||
reasoningOverride: runtimeReasoningOverride,
|
||||
runtimeConfigOverrides: codexRuntimeConfigOverrides,
|
||||
envVars,
|
||||
};
|
||||
await adapter.prepareCredentials(creds);
|
||||
@@ -1254,10 +1301,13 @@ async function main(): Promise<void> {
|
||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||
? appendThirdPartyImageAnalysisToolArgs(remainingArgs)
|
||||
: remainingArgs;
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
expandedSettingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'ccs.settings-profile',
|
||||
@@ -1266,6 +1316,7 @@ async function main(): Promise<void> {
|
||||
profileType: profileInfo.type,
|
||||
settingsPath: expandedSettingsPath,
|
||||
});
|
||||
|
||||
execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv });
|
||||
} else if (profileInfo.type === 'account') {
|
||||
// NEW FLOW: Account-based profile (work, personal)
|
||||
@@ -1314,8 +1365,22 @@ async function main(): Promise<void> {
|
||||
CCS_WEBSEARCH_SKIP: '1',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
};
|
||||
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
||||
const browserProfileDir =
|
||||
resolvedTarget === 'claude'
|
||||
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
|
||||
: undefined;
|
||||
|
||||
if (resolvedTarget === 'claude') {
|
||||
if (browserProfileDir) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
browserRuntimeEnv = {
|
||||
...(await resolveBrowserRuntimeEnv({
|
||||
profileDir: browserProfileDir,
|
||||
})),
|
||||
};
|
||||
Object.assign(envVars, browserRuntimeEnv);
|
||||
}
|
||||
const defaultContinuityInheritance = await resolveProfileContinuityInheritance({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
@@ -1330,6 +1395,14 @@ async function main(): Promise<void> {
|
||||
}
|
||||
if (defaultContinuityInheritance.claudeConfigDir) {
|
||||
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
|
||||
if (
|
||||
browserProfileDir &&
|
||||
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
|
||||
) {
|
||||
throw new Error(
|
||||
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1355,6 +1428,8 @@ async function main(): Promise<void> {
|
||||
model: process.env['ANTHROPIC_MODEL'],
|
||||
}),
|
||||
reasoningOverride: runtimeReasoningOverride,
|
||||
runtimeConfigOverrides: codexRuntimeConfigOverrides,
|
||||
browserRuntimeEnv,
|
||||
};
|
||||
if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) {
|
||||
console.error(
|
||||
@@ -1377,7 +1452,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const launchArgs = resolveNativeClaudeLaunchArgs(
|
||||
remainingArgs,
|
||||
browserRuntimeEnv ? appendBrowserToolArgs(remainingArgs) : remainingArgs,
|
||||
'default',
|
||||
envVars.CLAUDE_CONFIG_DIR
|
||||
);
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface ProxyChainConfig {
|
||||
claudeConfigDir?: string;
|
||||
/** Execution-aware image analysis env prepared by the caller */
|
||||
imageAnalysisEnv?: Record<string, string>;
|
||||
/** Optional browser runtime env for Claude browser MCP reuse. */
|
||||
browserRuntimeEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CliproxyImageAnalysisDeps {
|
||||
@@ -240,6 +242,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
||||
compositeDefaultTier,
|
||||
claudeConfigDir,
|
||||
imageAnalysisEnv: resolvedImageAnalysisEnv,
|
||||
browserRuntimeEnv,
|
||||
} = config;
|
||||
|
||||
// Build base env vars - check remote mode first
|
||||
@@ -394,6 +397,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
||||
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
||||
...webSearchEnv,
|
||||
...imageAnalysisEnv,
|
||||
...(browserRuntimeEnv || {}),
|
||||
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,13 @@ import {
|
||||
syncImageAnalysisMcpToConfigDir,
|
||||
appendThirdPartyImageAnalysisToolArgs,
|
||||
} from '../../utils/image-analysis';
|
||||
import {
|
||||
appendBrowserToolArgs,
|
||||
ensureBrowserMcpOrThrow,
|
||||
resolveBrowserRuntimeEnv,
|
||||
resolveConfiguredBrowserProfileDir,
|
||||
syncBrowserMcpToConfigDir,
|
||||
} from '../../utils/browser';
|
||||
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||
import {
|
||||
@@ -245,6 +252,10 @@ export async function execClaudeWithCLIProxy(
|
||||
// Setup first-class CCS WebSearch runtime
|
||||
ensureWebSearchMcpOrThrow();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
const browserProfileDir = resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR);
|
||||
if (browserProfileDir) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
}
|
||||
displayWebSearchStatus();
|
||||
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
@@ -1006,6 +1017,15 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
if (
|
||||
browserProfileDir &&
|
||||
inheritedClaudeConfigDir &&
|
||||
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
||||
) {
|
||||
throw new Error(
|
||||
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
|
||||
);
|
||||
}
|
||||
|
||||
// Build initial env vars to get ANTHROPIC_BASE_URL
|
||||
const initialEnvVars = buildClaudeEnvironment({
|
||||
@@ -1101,6 +1121,14 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
// 11. Build final environment with all proxy chains
|
||||
const browserRuntimeEnv = browserProfileDir
|
||||
? {
|
||||
...(await resolveBrowserRuntimeEnv({
|
||||
profileDir: browserProfileDir,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider,
|
||||
useRemoteProxy,
|
||||
@@ -1128,6 +1156,7 @@ export async function execClaudeWithCLIProxy(
|
||||
compositeDefaultTier: cfg.compositeDefaultTier,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
imageAnalysisEnv,
|
||||
browserRuntimeEnv,
|
||||
});
|
||||
|
||||
if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) {
|
||||
@@ -1143,6 +1172,14 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
`[cliproxy-browser-debug] keys=${Object.keys(env)
|
||||
.filter((key) => key.startsWith('CCS_BROWSER_'))
|
||||
.sort()
|
||||
.join(',')} ws=${env.CCS_BROWSER_DEVTOOLS_WS_URL || ''}`
|
||||
);
|
||||
}
|
||||
logEnvironment(env, webSearchEnv, verbose);
|
||||
if (imageAnalysisWarning) {
|
||||
console.error(info(imageAnalysisWarning));
|
||||
@@ -1222,10 +1259,13 @@ export async function execClaudeWithCLIProxy(
|
||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
||||
: claudeArgs;
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
settingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'cliproxy.executor',
|
||||
|
||||
@@ -483,7 +483,6 @@ export class ToolSanitizationProxy {
|
||||
port: upstreamUrl.port,
|
||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
||||
method: originalReq.method,
|
||||
timeout: this.config.timeoutMs,
|
||||
headers: this.buildForwardHeaders(originalReq.headers),
|
||||
},
|
||||
(upstreamRes) => {
|
||||
@@ -520,7 +519,6 @@ export class ToolSanitizationProxy {
|
||||
port: upstreamUrl.port,
|
||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
||||
method: originalReq.method,
|
||||
timeout: this.config.timeoutMs,
|
||||
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
||||
},
|
||||
(upstreamRes) => {
|
||||
@@ -594,7 +592,6 @@ export class ToolSanitizationProxy {
|
||||
port: upstreamUrl.port,
|
||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
||||
method: originalReq.method,
|
||||
timeout: this.config.timeoutMs,
|
||||
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
||||
},
|
||||
(upstreamRes) => {
|
||||
|
||||
@@ -229,6 +229,8 @@ export interface ExecutorConfig {
|
||||
profileName?: string;
|
||||
/** Optional inherited continuity directory from mapped account profile */
|
||||
claudeConfigDir?: string;
|
||||
/** Optional browser runtime env for Claude browser MCP reuse. */
|
||||
browserRuntimeEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
SYNCABLE_PROVIDERS,
|
||||
getResolvedCatalog,
|
||||
refreshCatalogFromProxy,
|
||||
getAllResolvedCatalogs,
|
||||
} from '../../cliproxy/catalog-cache';
|
||||
import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
||||
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
||||
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||
import type { ThinkingSupport } from '../../cliproxy/model-catalog';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
|
||||
import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing';
|
||||
@@ -173,6 +175,50 @@ export async function handleCatalogRefresh(verbose: boolean): Promise<void> {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/** JSON-serialisable model entry emitted by `catalog --json`. */
|
||||
interface CatalogJsonModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tier?: 'free' | 'pro' | 'ultra';
|
||||
description?: string;
|
||||
deprecated?: boolean;
|
||||
deprecationReason?: string;
|
||||
broken?: boolean;
|
||||
issueUrl?: string;
|
||||
thinking?: ThinkingSupport;
|
||||
extendedContext?: boolean;
|
||||
nativeImageInput?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output catalog as JSON for programmatic consumption.
|
||||
* Used by OnSteroids and other tools to get available models per provider.
|
||||
* Format: { [providerName: string]: CatalogJsonModel[] }
|
||||
*/
|
||||
export function handleCatalogJson(): void {
|
||||
const catalogs = getAllResolvedCatalogs();
|
||||
const result: Record<string, CatalogJsonModel[]> = {};
|
||||
for (const [provider, catalog] of Object.entries(catalogs)) {
|
||||
if (!catalog) {
|
||||
continue;
|
||||
}
|
||||
result[provider] = catalog.models.map((m) => {
|
||||
const entry: CatalogJsonModel = { id: m.id, name: m.name };
|
||||
if (m.tier !== undefined) entry.tier = m.tier;
|
||||
if (m.description !== undefined) entry.description = m.description;
|
||||
if (m.deprecated !== undefined) entry.deprecated = m.deprecated;
|
||||
if (m.deprecationReason !== undefined) entry.deprecationReason = m.deprecationReason;
|
||||
if (m.broken !== undefined) entry.broken = m.broken;
|
||||
if (m.issueUrl !== undefined) entry.issueUrl = m.issueUrl;
|
||||
if (m.thinking !== undefined) entry.thinking = m.thinking;
|
||||
if (m.extendedContext !== undefined) entry.extendedContext = m.extendedContext;
|
||||
if (m.nativeImageInput !== undefined) entry.nativeImageInput = m.nativeImageInput;
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
|
||||
/** Reset catalog cache */
|
||||
export async function handleCatalogReset(): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
@@ -39,6 +39,7 @@ export async function showHelp(): Promise<void> {
|
||||
['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'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
handleCatalogStatus,
|
||||
handleCatalogRefresh,
|
||||
handleCatalogReset,
|
||||
handleCatalogJson,
|
||||
} from './catalog-subcommand';
|
||||
|
||||
/**
|
||||
@@ -146,6 +147,12 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
|
||||
// Catalog commands
|
||||
if (command === 'catalog') {
|
||||
// --json takes priority over subcommands (refresh/reset) — it always
|
||||
// outputs the current resolved catalog regardless of other arguments.
|
||||
if (hasAnyFlag(remainingArgs, ['--json'])) {
|
||||
handleCatalogJson();
|
||||
return;
|
||||
}
|
||||
const subcommand = remainingArgs[1];
|
||||
if (subcommand === 'refresh') {
|
||||
await handleCatalogRefresh(verbose);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
}
|
||||
|
||||
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 <token> --machine-id <machine-id>');
|
||||
@@ -156,6 +192,13 @@ async function handleStatus(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleProbe(): Promise<number> {
|
||||
const cursorConfig = getCursorConfig();
|
||||
const result = await probeCursorRuntime(cursorConfig);
|
||||
renderCursorProbe(result);
|
||||
return result.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
async function handleModels(): Promise<number> {
|
||||
const cursorConfig = getCursorConfig();
|
||||
const models = await getAvailableModels(cursorConfig.port);
|
||||
|
||||
@@ -121,9 +121,10 @@ export function loadMigrationCheckData(): MigrationCheckData {
|
||||
|
||||
if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) {
|
||||
const legacyProfiles = legacyConfig.profiles as Record<string, unknown>;
|
||||
const unifiedProfiles = unifiedConfig.profiles ?? {};
|
||||
for (const profileName of Object.keys(legacyProfiles)) {
|
||||
const targetProfileName = resolveAliasToCanonical(profileName);
|
||||
if (!unifiedConfig.profiles[targetProfileName]) {
|
||||
if (!unifiedProfiles[targetProfileName]) {
|
||||
needsMigration = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
resolveLegacyDiscordSelection,
|
||||
} from '../channels/official-channels-runtime';
|
||||
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
|
||||
import { normalizeSearxngBaseUrl } from '../utils/websearch/types';
|
||||
|
||||
const CONFIG_YAML = 'config.yaml';
|
||||
const CONFIG_JSON = 'config.json';
|
||||
@@ -407,14 +408,19 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
enabled: partial.websearch?.providers?.tavily?.enabled ?? false,
|
||||
max_results: partial.websearch?.providers?.tavily?.max_results ?? 5,
|
||||
},
|
||||
duckduckgo: {
|
||||
enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true,
|
||||
max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5,
|
||||
},
|
||||
brave: {
|
||||
enabled: partial.websearch?.providers?.brave?.enabled ?? false,
|
||||
max_results: partial.websearch?.providers?.brave?.max_results ?? 5,
|
||||
},
|
||||
searxng: {
|
||||
enabled: partial.websearch?.providers?.searxng?.enabled ?? false,
|
||||
url: normalizeSearxngBaseUrl(partial.websearch?.providers?.searxng?.url) ?? '',
|
||||
max_results: partial.websearch?.providers?.searxng?.max_results ?? 5,
|
||||
},
|
||||
duckduckgo: {
|
||||
enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true,
|
||||
max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5,
|
||||
},
|
||||
gemini: {
|
||||
enabled:
|
||||
partial.websearch?.providers?.gemini?.enabled ??
|
||||
@@ -1093,15 +1099,16 @@ export interface GeminiWebSearchInfo {
|
||||
/**
|
||||
* Get websearch configuration.
|
||||
* Returns defaults if not configured.
|
||||
* Supports Gemini CLI, OpenCode, and Grok CLI providers.
|
||||
* Supports deterministic providers and optional Gemini/OpenCode/Grok CLI fallbacks.
|
||||
*/
|
||||
export function getWebSearchConfig(): {
|
||||
enabled: boolean;
|
||||
providers?: {
|
||||
exa?: { enabled?: boolean; max_results?: number };
|
||||
tavily?: { enabled?: boolean; max_results?: number };
|
||||
duckduckgo?: { enabled?: boolean; max_results?: number };
|
||||
brave?: { enabled?: boolean; max_results?: number };
|
||||
searxng?: { enabled?: boolean; url?: string; max_results?: number };
|
||||
duckduckgo?: { enabled?: boolean; max_results?: number };
|
||||
gemini?: GeminiWebSearchInfo;
|
||||
opencode?: { enabled?: boolean; model?: string; timeout?: number };
|
||||
grok?: { enabled?: boolean; timeout?: number };
|
||||
@@ -1132,6 +1139,12 @@ export function getWebSearchConfig(): {
|
||||
max_results: config.websearch?.providers?.brave?.max_results ?? 5,
|
||||
};
|
||||
|
||||
const searxngConfig = {
|
||||
enabled: config.websearch?.providers?.searxng?.enabled ?? false,
|
||||
url: normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '',
|
||||
max_results: config.websearch?.providers?.searxng?.max_results ?? 5,
|
||||
};
|
||||
|
||||
const geminiConfig: GeminiWebSearchInfo = {
|
||||
enabled:
|
||||
config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false,
|
||||
@@ -1155,8 +1168,9 @@ export function getWebSearchConfig(): {
|
||||
const anyProviderEnabled =
|
||||
exaConfig.enabled ||
|
||||
tavilyConfig.enabled ||
|
||||
duckDuckGoConfig.enabled ||
|
||||
braveConfig.enabled ||
|
||||
searxngConfig.enabled ||
|
||||
duckDuckGoConfig.enabled ||
|
||||
geminiConfig.enabled ||
|
||||
opencodeConfig.enabled ||
|
||||
grokConfig.enabled;
|
||||
@@ -1167,8 +1181,9 @@ export function getWebSearchConfig(): {
|
||||
providers: {
|
||||
exa: exaConfig,
|
||||
tavily: tavilyConfig,
|
||||
duckduckgo: duckDuckGoConfig,
|
||||
brave: braveConfig,
|
||||
searxng: searxngConfig,
|
||||
duckduckgo: duckDuckGoConfig,
|
||||
gemini: geminiConfig,
|
||||
opencode: opencodeConfig,
|
||||
grok: grokConfig,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const CURSOR_SUBCOMMANDS = [
|
||||
'auth',
|
||||
'status',
|
||||
'probe',
|
||||
'models',
|
||||
'start',
|
||||
'stop',
|
||||
|
||||
+197
-41
@@ -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.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+368
-201
@@ -6,10 +6,20 @@
|
||||
import type { IncomingHttpHeaders } from 'http';
|
||||
import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js';
|
||||
import { buildCursorRequest } from './cursor-translator.js';
|
||||
import type { CursorTool, CursorApiCredentials } from './cursor-protobuf-schema.js';
|
||||
import {
|
||||
isEndStreamConnectFrame,
|
||||
type CursorTool,
|
||||
type CursorApiCredentials,
|
||||
} from './cursor-protobuf-schema.js';
|
||||
import { buildCursorConnectHeaders, generateCursorChecksum } from './cursor-client-policy.js';
|
||||
|
||||
import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js';
|
||||
import {
|
||||
CursorConnectFrameError,
|
||||
type FrameResult,
|
||||
StreamingFrameParser,
|
||||
decompressPayload,
|
||||
mapCursorConnectError,
|
||||
} from './cursor-stream-parser.js';
|
||||
|
||||
/** Executor parameters */
|
||||
interface ExecutorParams {
|
||||
@@ -41,6 +51,13 @@ interface Http2Response {
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
interface CursorExecutorErrorPayload {
|
||||
message: string;
|
||||
status: number;
|
||||
errorType: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Lazy import http2 */
|
||||
let http2Module: typeof import('http2') | null = null;
|
||||
async function getHttp2() {
|
||||
@@ -59,34 +76,63 @@ async function getHttp2() {
|
||||
/**
|
||||
* Create error response from JSON error
|
||||
*/
|
||||
function createErrorResponse(jsonError: {
|
||||
function toCursorErrorPayloadFromJson(jsonError: {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: Array<{ debug?: { details?: { title?: string; detail?: string }; error?: string } }>;
|
||||
};
|
||||
}): Response {
|
||||
}): CursorExecutorErrorPayload {
|
||||
const errorMsg =
|
||||
jsonError?.error?.details?.[0]?.debug?.details?.title ||
|
||||
jsonError?.error?.details?.[0]?.debug?.details?.detail ||
|
||||
jsonError?.error?.message ||
|
||||
'API Error';
|
||||
|
||||
const isRateLimit = jsonError?.error?.code === 'resource_exhausted';
|
||||
const mappedError = mapCursorConnectError(jsonError?.error?.code);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: isRateLimit ? 'rate_limit_error' : 'api_error',
|
||||
code: jsonError?.error?.details?.[0]?.debug?.error || 'unknown',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: isRateLimit ? 429 : 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
return {
|
||||
message: errorMsg,
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
code: jsonError?.error?.details?.[0]?.debug?.error || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function buildCursorErrorEnvelope(error: CursorExecutorErrorPayload): string {
|
||||
return JSON.stringify({
|
||||
error: {
|
||||
message: error.message,
|
||||
type: error.errorType,
|
||||
code: error.code,
|
||||
status: error.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createCursorErrorResponse(error: CursorExecutorErrorPayload): Response {
|
||||
return new Response(buildCursorErrorEnvelope(error), {
|
||||
status: error.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function toCursorExecutorErrorPayload(error: unknown): CursorExecutorErrorPayload {
|
||||
if (error instanceof CursorConnectFrameError) {
|
||||
return {
|
||||
message: error.message,
|
||||
status: error.status,
|
||||
errorType: error.errorType,
|
||||
code: 'cursor_protocol_error',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: error instanceof Error ? error.message : 'Cursor streaming failed.',
|
||||
status: 502,
|
||||
errorType: 'server_error',
|
||||
code: 'cursor_error',
|
||||
};
|
||||
}
|
||||
|
||||
export class CursorExecutor {
|
||||
@@ -413,180 +459,243 @@ export class CursorExecutor {
|
||||
index: number;
|
||||
}
|
||||
>();
|
||||
const pendingPackets: string[] = [];
|
||||
let chunkCount = 0;
|
||||
let toolCallCount = 0;
|
||||
let streamResponseResolved = false;
|
||||
|
||||
const flushPendingPackets = () => {
|
||||
if (!streamController || pendingPackets.length === 0 || streamClosed) return;
|
||||
for (const packet of pendingPackets.splice(0)) {
|
||||
streamController.enqueue(enc.encode(packet));
|
||||
}
|
||||
};
|
||||
|
||||
const queuePacket = (packet: string) => {
|
||||
if (streamClosed) return;
|
||||
if (streamController) {
|
||||
streamController.enqueue(enc.encode(packet));
|
||||
return;
|
||||
}
|
||||
pendingPackets.push(packet);
|
||||
};
|
||||
|
||||
const emitSSE = (data: string) => {
|
||||
queuePacket(`data: ${data}\n\n`);
|
||||
};
|
||||
|
||||
const emitSSEEvent = (event: string, data: string) => {
|
||||
queuePacket(`event: ${event}\ndata: ${data}\n\n`);
|
||||
};
|
||||
|
||||
const readable = new ReadableStream<Uint8Array>({
|
||||
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<string, unknown>, 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<string, unknown>, finishReason: string | null) =>
|
||||
JSON.stringify({
|
||||
id: responseId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
});
|
||||
|
||||
const handleFrameError = (frame: Extract<FrameResult, { type: 'error' }>) => {
|
||||
const errorPayload = buildCursorErrorEnvelope({
|
||||
message: frame.message,
|
||||
status: frame.status,
|
||||
errorType: frame.errorType,
|
||||
code: frame.errorType === 'rate_limit_error' ? 'rate_limited' : 'cursor_error',
|
||||
});
|
||||
|
||||
if (!streamResponseResolved && chunkCount === 0 && toolCallCount === 0) {
|
||||
streamClosed = true;
|
||||
req.close();
|
||||
client.close();
|
||||
resolveOnce(
|
||||
new Response(errorPayload, {
|
||||
status: frame.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolveStreamingResponse();
|
||||
emitSSEEvent('error', errorPayload);
|
||||
closeStream();
|
||||
};
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
if (streamClosed) return;
|
||||
for (const frame of parser.push(chunk)) {
|
||||
if (frame.type === 'error') {
|
||||
handleFrameError(frame);
|
||||
return;
|
||||
}
|
||||
|
||||
resolveStreamingResponse();
|
||||
|
||||
if (frame.type === 'toolCall') {
|
||||
const tc = frame.toolCall;
|
||||
|
||||
// Emit role chunk on first content
|
||||
if (chunkCount === 0) {
|
||||
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
|
||||
chunkCount++;
|
||||
}
|
||||
|
||||
if (toolCallsMap.has(tc.id)) {
|
||||
const existing = toolCallsMap.get(tc.id);
|
||||
if (!existing) continue;
|
||||
existing.function.arguments += tc.function.arguments;
|
||||
existing.isLast = tc.isLast;
|
||||
if (tc.function.arguments) {
|
||||
emitSSE(
|
||||
buildChunk(
|
||||
{
|
||||
tool_calls: [
|
||||
{
|
||||
index: existing.index,
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
null
|
||||
)
|
||||
);
|
||||
chunkCount++;
|
||||
}
|
||||
} else {
|
||||
const idx = toolCallCount++;
|
||||
toolCallsMap.set(tc.id, { ...tc, index: idx });
|
||||
emitSSE(
|
||||
buildChunk(
|
||||
{
|
||||
tool_calls: [
|
||||
{
|
||||
index: idx,
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
null
|
||||
)
|
||||
);
|
||||
chunkCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.type === 'text') {
|
||||
const delta =
|
||||
chunkCount === 0 && toolCallCount === 0
|
||||
? { role: 'assistant', content: frame.text }
|
||||
: { content: frame.text };
|
||||
emitSSE(buildChunk(delta, null));
|
||||
chunkCount++;
|
||||
}
|
||||
|
||||
if (frame.type === 'thinking') {
|
||||
const delta =
|
||||
chunkCount === 0 && toolCallCount === 0
|
||||
? { role: 'assistant', reasoning_content: frame.text }
|
||||
: { reasoning_content: frame.text };
|
||||
emitSSE(buildChunk(delta, null));
|
||||
chunkCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
if (streamClosed) return;
|
||||
for (const frame of parser.finish()) {
|
||||
if (frame.type === 'error') {
|
||||
handleFrameError(frame);
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolveStreamingResponse();
|
||||
if (chunkCount === 0 && toolCallCount === 0) {
|
||||
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
|
||||
}
|
||||
emitSSE(
|
||||
JSON.stringify({
|
||||
id: responseId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: toolCallCount > 0 ? 'tool_calls' : 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
})
|
||||
);
|
||||
emitSSE('[DONE]');
|
||||
closeStream();
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
client.close();
|
||||
if (!streamResponseResolved) {
|
||||
rejectOnce(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!streamClosed) {
|
||||
try {
|
||||
streamController?.error(err);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
@@ -604,7 +713,7 @@ export class CursorExecutor {
|
||||
* Shared logic between JSON and SSE transformers.
|
||||
*/
|
||||
private *parseProtobufFrames(buffer: Buffer): Generator<
|
||||
| { type: 'error'; response: Response }
|
||||
| { type: 'error'; error: CursorExecutorErrorPayload }
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'thinking'; text: string }
|
||||
| {
|
||||
@@ -630,13 +739,54 @@ export class CursorExecutor {
|
||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||
offset += 5 + length;
|
||||
|
||||
payload = decompressPayload(payload, flags);
|
||||
try {
|
||||
payload = decompressPayload(payload, flags);
|
||||
} catch (error) {
|
||||
yield { type: 'error', error: toCursorExecutorErrorPayload(error) };
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEndStreamConnectFrame(flags)) {
|
||||
try {
|
||||
const json = JSON.parse(payload.toString('utf-8')) as {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: Array<{
|
||||
debug?: { details?: { title?: string; detail?: string }; error?: string };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const msg =
|
||||
json?.error?.details?.[0]?.debug?.details?.title ||
|
||||
json?.error?.details?.[0]?.debug?.details?.detail ||
|
||||
json?.error?.message;
|
||||
|
||||
if (msg) {
|
||||
const mappedError = mapCursorConnectError(json?.error?.code);
|
||||
yield {
|
||||
type: 'error',
|
||||
error: {
|
||||
message: msg,
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
code: json?.error?.code || 'cursor_error',
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore successful end-stream metadata trailers.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for JSON error format
|
||||
try {
|
||||
const text = payload.toString('utf-8');
|
||||
if (text.startsWith('{') && text.includes('"error"')) {
|
||||
yield { type: 'error', response: createErrorResponse(JSON.parse(text)) };
|
||||
yield { type: 'error', error: toCursorErrorPayloadFromJson(JSON.parse(text)) };
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -656,19 +806,12 @@ export class CursorExecutor {
|
||||
errorLower.includes('too many requests');
|
||||
yield {
|
||||
type: 'error',
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: result.error,
|
||||
type: isRateLimit ? 'rate_limit_error' : 'server_error',
|
||||
code: isRateLimit ? 'rate_limited' : 'cursor_error',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: isRateLimit ? 429 : 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
),
|
||||
error: {
|
||||
message: result.error,
|
||||
status: isRateLimit ? 429 : 502,
|
||||
errorType: isRateLimit ? 'rate_limit_error' : 'server_error',
|
||||
code: isRateLimit ? 'rate_limited' : 'cursor_error',
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -685,6 +828,18 @@ export class CursorExecutor {
|
||||
yield { type: 'thinking', text: result.thinking };
|
||||
}
|
||||
}
|
||||
|
||||
if (offset !== buffer.length) {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: {
|
||||
message: 'Truncated Cursor ConnectRPC frame.',
|
||||
status: 502,
|
||||
errorType: 'server_error',
|
||||
code: 'cursor_protocol_error',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
transformProtobufToJSON(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
|
||||
@@ -711,7 +866,7 @@ export class CursorExecutor {
|
||||
|
||||
for (const frame of this.parseProtobufFrames(buffer)) {
|
||||
if (frame.type === 'error') {
|
||||
return frame.response;
|
||||
return createCursorErrorResponse(frame.error);
|
||||
}
|
||||
|
||||
if (frame.type === 'toolCall') {
|
||||
@@ -840,7 +995,19 @@ export class CursorExecutor {
|
||||
|
||||
for (const frame of this.parseProtobufFrames(buffer)) {
|
||||
if (frame.type === 'error') {
|
||||
return frame.response;
|
||||
if (chunks.length === 0 && toolCalls.length === 0) {
|
||||
return createCursorErrorResponse(frame.error);
|
||||
}
|
||||
|
||||
chunks.push(`event: error\ndata: ${buildCursorErrorEnvelope(frame.error)}\n\n`);
|
||||
return new Response(chunks.join(''), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (frame.type === 'toolCall') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { CursorConfig } from '../config/unified-config-types';
|
||||
import { checkAuthStatus } from './cursor-auth';
|
||||
import { isDaemonRunning, startDaemon } from './cursor-daemon';
|
||||
import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models';
|
||||
|
||||
export interface CursorProbeResult {
|
||||
ok: boolean;
|
||||
stage: 'config' | 'auth' | 'daemon' | 'runtime';
|
||||
status: number;
|
||||
duration_ms: number;
|
||||
model?: string;
|
||||
error_type?: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const PROBE_PROMPT = 'Reply with OK only.';
|
||||
const PROBE_TIMEOUT_MS = 15_000;
|
||||
const PROBE_SUCCESS_PATTERN = /^ok[.!]?$/i;
|
||||
|
||||
function isDaemonReachabilityError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
if (
|
||||
message.includes('fetch failed') ||
|
||||
message.includes('econnrefused') ||
|
||||
message.includes('econnreset') ||
|
||||
message.includes('socket hang up') ||
|
||||
message.includes('connection refused')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cause = (error as Error & { cause?: unknown }).cause;
|
||||
if (!cause || typeof cause !== 'object' || !('code' in cause)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = String((cause as { code?: unknown }).code ?? '').toUpperCase();
|
||||
return ['ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'EPIPE', 'UND_ERR_SOCKET'].includes(code);
|
||||
}
|
||||
|
||||
function parseProbeError(text: string): { errorType: string | null; message: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
error?: { type?: string; message?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
if (parsed.error?.message) {
|
||||
return {
|
||||
errorType: parsed.error.type ?? null,
|
||||
message: parsed.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.type === 'error') {
|
||||
return {
|
||||
errorType: null,
|
||||
message: text,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through to raw text
|
||||
}
|
||||
|
||||
return {
|
||||
errorType: null,
|
||||
message: text || 'Unknown probe failure',
|
||||
};
|
||||
}
|
||||
|
||||
function parseProbeSuccess(text: string): { ok: boolean; message: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
choices?: Array<{
|
||||
message?: { content?: string | null };
|
||||
}>;
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
if (parsed.error?.message) {
|
||||
return { ok: false, message: parsed.error.message };
|
||||
}
|
||||
|
||||
const content = parsed.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string' || !content.trim()) {
|
||||
return { ok: false, message: 'Probe response was missing assistant content.' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: PROBE_SUCCESS_PATTERN.test(content.trim()),
|
||||
message: PROBE_SUCCESS_PATTERN.test(content.trim())
|
||||
? 'Live probe succeeded.'
|
||||
: `Probe returned unexpected assistant content: ${content.trim()}`,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, message: 'Probe response was not valid JSON.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeCursorRuntime(config: CursorConfig): Promise<CursorProbeResult> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (!config.enabled) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'config',
|
||||
status: 400,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: 'Cursor integration is disabled.',
|
||||
error_type: 'configuration_error',
|
||||
};
|
||||
}
|
||||
|
||||
const authStatus = checkAuthStatus();
|
||||
if (!authStatus.authenticated || !authStatus.credentials) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'auth',
|
||||
status: 401,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: 'Cursor credentials not found. Run `ccs cursor auth` first.',
|
||||
error_type: 'authentication_error',
|
||||
};
|
||||
}
|
||||
|
||||
if (authStatus.expired) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'auth',
|
||||
status: 401,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: 'Cursor credentials expired. Run `ccs cursor auth` again.',
|
||||
error_type: 'authentication_error',
|
||||
};
|
||||
}
|
||||
|
||||
let daemonRunning = await isDaemonRunning(config.port);
|
||||
if (!daemonRunning && config.auto_start) {
|
||||
const startResult = await startDaemon({
|
||||
port: config.port,
|
||||
ghost_mode: config.ghost_mode,
|
||||
});
|
||||
|
||||
if (!startResult.success) {
|
||||
daemonRunning = await isDaemonRunning(config.port);
|
||||
} else {
|
||||
daemonRunning = true;
|
||||
}
|
||||
|
||||
if (!daemonRunning) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
status: 503,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: startResult.error || 'Failed to start Cursor daemon for live probe.',
|
||||
error_type: 'daemon_start_failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!daemonRunning) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
status: 503,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message:
|
||||
'Cursor daemon is not running. Start it with `ccs cursor start` or enable auto_start.',
|
||||
error_type: 'daemon_not_running',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = {
|
||||
accessToken: authStatus.credentials.accessToken,
|
||||
machineId: authStatus.credentials.machineId,
|
||||
ghostMode: config.ghost_mode,
|
||||
};
|
||||
const availableModels = await getModelsForDaemon({ credentials });
|
||||
const model = resolveCursorRequestModel(config.model, availableModels);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), PROBE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${config.port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 8,
|
||||
messages: [{ role: 'user', content: PROBE_PROMPT }],
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
const duration = Date.now() - startedAt;
|
||||
|
||||
if (!response.ok) {
|
||||
const error = parseProbeError(text);
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'runtime',
|
||||
status: response.status,
|
||||
duration_ms: duration,
|
||||
model,
|
||||
error_type: error.errorType,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
const success = parseProbeSuccess(text);
|
||||
return {
|
||||
ok: success.ok,
|
||||
stage: 'runtime',
|
||||
status: success.ok ? response.status : 502,
|
||||
duration_ms: duration,
|
||||
model,
|
||||
error_type: success.ok ? null : 'probe_validation_failed',
|
||||
message: success.message,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
const daemonReachabilityError = isDaemonReachabilityError(error);
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
stage:
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 'runtime'
|
||||
: daemonReachabilityError
|
||||
? 'daemon'
|
||||
: 'runtime',
|
||||
status:
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 504
|
||||
: daemonReachabilityError
|
||||
? 503
|
||||
: 500,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
error_type:
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 'probe_timeout'
|
||||
: daemonReachabilityError
|
||||
? 'daemon_unreachable'
|
||||
: 'runtime_error',
|
||||
message:
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? `Live probe timed out after ${PROBE_TIMEOUT_MS}ms.`
|
||||
: daemonReachabilityError
|
||||
? 'Cursor daemon became unreachable during the live probe. Start it again and retry.'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown runtime probe failure.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
import * as zlib from 'zlib';
|
||||
import { COMPRESS_FLAG } from './cursor-protobuf-schema.js';
|
||||
import {
|
||||
hasUnknownConnectFrameFlags,
|
||||
isCompressedConnectFrame,
|
||||
isEndStreamConnectFrame,
|
||||
} from './cursor-protobuf-schema.js';
|
||||
import { extractTextFromResponse } from './cursor-protobuf-decoder.js';
|
||||
|
||||
/** Frame parsing result types */
|
||||
@@ -22,12 +26,85 @@ export type FrameResult =
|
||||
};
|
||||
};
|
||||
|
||||
export class CursorConnectFrameError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status = 502,
|
||||
readonly errorType = 'server_error'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CursorConnectFrameError';
|
||||
}
|
||||
}
|
||||
|
||||
export function mapCursorConnectError(code?: string): { status: number; errorType: string } {
|
||||
switch (code?.toLowerCase()) {
|
||||
case 'resource_exhausted':
|
||||
return { status: 429, errorType: 'rate_limit_error' };
|
||||
case 'unauthenticated':
|
||||
return { status: 401, errorType: 'authentication_error' };
|
||||
case 'permission_denied':
|
||||
return { status: 403, errorType: 'permission_error' };
|
||||
case 'not_found':
|
||||
return { status: 404, errorType: 'api_error' };
|
||||
case 'already_exists':
|
||||
case 'aborted':
|
||||
return { status: 409, errorType: 'api_error' };
|
||||
case 'deadline_exceeded':
|
||||
return { status: 504, errorType: 'api_error' };
|
||||
case 'unimplemented':
|
||||
return { status: 501, errorType: 'api_error' };
|
||||
case 'unavailable':
|
||||
return { status: 503, errorType: 'api_error' };
|
||||
case 'invalid_argument':
|
||||
case 'failed_precondition':
|
||||
case 'out_of_range':
|
||||
return { status: 400, errorType: 'api_error' };
|
||||
default:
|
||||
return { status: 500, errorType: 'api_error' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatConnectFrameFlags(flags: number): string {
|
||||
return `0x${flags.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function toFrameErrorResult(error: unknown): Extract<FrameResult, { type: 'error' }> {
|
||||
if (error instanceof CursorConnectFrameError) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
status: error.status,
|
||||
errorType: error.errorType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'Cursor stream parsing failed.',
|
||||
status: 502,
|
||||
errorType: 'server_error',
|
||||
};
|
||||
}
|
||||
|
||||
function createTruncatedFrameError(): CursorConnectFrameError {
|
||||
return new CursorConnectFrameError('Truncated Cursor ConnectRPC frame.', 502, 'server_error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress payload if gzip-compressed.
|
||||
* Skips decompression for JSON error payloads.
|
||||
* NOTE: Uses synchronous gzip for single-request CLI tool. Async not warranted for small payloads.
|
||||
*/
|
||||
export function decompressPayload(payload: Buffer, flags: number): Buffer {
|
||||
if (hasUnknownConnectFrameFlags(flags)) {
|
||||
throw new CursorConnectFrameError(
|
||||
`Unsupported ConnectRPC frame flags: ${formatConnectFrameFlags(flags)}`,
|
||||
502,
|
||||
'server_error'
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
|
||||
try {
|
||||
const text = payload.toString('utf-8');
|
||||
@@ -37,18 +114,18 @@ export function decompressPayload(payload: Buffer, flags: number): Buffer {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
flags === COMPRESS_FLAG.GZIP ||
|
||||
flags === COMPRESS_FLAG.GZIP_ALT ||
|
||||
flags === COMPRESS_FLAG.GZIP_BOTH
|
||||
) {
|
||||
if (isCompressedConnectFrame(flags)) {
|
||||
try {
|
||||
return zlib.gunzipSync(payload);
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[cursor] gzip decompression failed:', err);
|
||||
}
|
||||
return Buffer.alloc(0);
|
||||
throw new CursorConnectFrameError(
|
||||
'Failed to decompress Cursor ConnectRPC frame.',
|
||||
502,
|
||||
'server_error'
|
||||
);
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
@@ -80,7 +157,46 @@ export class StreamingFrameParser {
|
||||
let payload = this.buffer.slice(5, frameSize);
|
||||
this.buffer = this.buffer.slice(frameSize);
|
||||
|
||||
payload = decompressPayload(payload, flags);
|
||||
try {
|
||||
payload = decompressPayload(payload, flags);
|
||||
} catch (error) {
|
||||
results.push(toFrameErrorResult(error));
|
||||
return results;
|
||||
}
|
||||
|
||||
if (isEndStreamConnectFrame(flags)) {
|
||||
try {
|
||||
const text = payload.toString('utf-8');
|
||||
const json = JSON.parse(text) as {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: Array<{
|
||||
debug?: { details?: { title?: string; detail?: string }; error?: string };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const msg =
|
||||
json?.error?.details?.[0]?.debug?.details?.title ||
|
||||
json?.error?.details?.[0]?.debug?.details?.detail ||
|
||||
json?.error?.message;
|
||||
|
||||
if (msg) {
|
||||
const mappedError = mapCursorConnectError(json?.error?.code);
|
||||
results.push({
|
||||
type: 'error',
|
||||
message: msg,
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
});
|
||||
return results;
|
||||
}
|
||||
} catch {
|
||||
// Ignore successful end-stream metadata trailers.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for JSON error
|
||||
try {
|
||||
@@ -92,12 +208,12 @@ export class StreamingFrameParser {
|
||||
json?.error?.details?.[0]?.debug?.details?.detail ||
|
||||
json?.error?.message ||
|
||||
'API Error';
|
||||
const isRateLimit = json?.error?.code === 'resource_exhausted';
|
||||
const mappedError = mapCursorConnectError(json?.error?.code);
|
||||
results.push({
|
||||
type: 'error',
|
||||
message: msg,
|
||||
status: isRateLimit ? 429 : 400,
|
||||
errorType: isRateLimit ? 'rate_limit_error' : 'api_error',
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
});
|
||||
return results;
|
||||
}
|
||||
@@ -116,7 +232,7 @@ export class StreamingFrameParser {
|
||||
results.push({
|
||||
type: 'error',
|
||||
message: result.error,
|
||||
status: isRateLimit ? 429 : 400,
|
||||
status: isRateLimit ? 429 : 502,
|
||||
errorType: isRateLimit ? 'rate_limit_error' : 'server_error',
|
||||
});
|
||||
return results;
|
||||
@@ -133,4 +249,13 @@ export class StreamingFrameParser {
|
||||
hasPartial(): boolean {
|
||||
return this.buffer.length > 0;
|
||||
}
|
||||
|
||||
finish(): FrameResult[] {
|
||||
if (this.buffer.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.buffer = Buffer.alloc(0);
|
||||
return [toFrameErrorResult(createTruncatedFrameError())];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,3 +46,4 @@ export {
|
||||
// Executor
|
||||
export { CursorExecutor } from './cursor-executor';
|
||||
export { executeCursorProfile, generateCursorEnv } from './cursor-profile-executor';
|
||||
export { probeCursorRuntime } from './cursor-runtime-probe';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
|
||||
import type { AccountContextPolicy } from '../auth/account-context';
|
||||
import { getCcsDir, getCcsHome } from '../utils/config-manager';
|
||||
|
||||
const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis']);
|
||||
const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis', 'ccs-browser']);
|
||||
|
||||
/** Options for instance creation */
|
||||
export interface InstanceOptions {
|
||||
@@ -252,9 +252,12 @@ class InstanceManager {
|
||||
}
|
||||
instanceContent.mcpServers = mergedMcpServers;
|
||||
|
||||
const fileMode = fs.existsSync(instanceClaudeJson)
|
||||
? fs.statSync(instanceClaudeJson).mode & 0o777
|
||||
: 0o600;
|
||||
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
mode: fileMode,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ProfileType } from '../types/profile';
|
||||
import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from '../utils/shell-executor';
|
||||
import { ErrorManager } from '../utils/error-manager';
|
||||
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||
import { appendBrowserToolArgs } from '../utils/browser';
|
||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||
import { runCleanup } from '../errors';
|
||||
|
||||
@@ -32,8 +33,16 @@ export class ClaudeAdapter implements TargetAdapter {
|
||||
// No-op: Claude receives credentials via environment variables
|
||||
}
|
||||
|
||||
buildArgs(_profile: string, userArgs: string[]): string[] {
|
||||
return userArgs;
|
||||
buildArgs(
|
||||
_profile: string,
|
||||
userArgs: string[],
|
||||
options?: {
|
||||
creds?: TargetCredentials;
|
||||
profileType?: ProfileType;
|
||||
binaryInfo?: TargetBinaryInfo;
|
||||
}
|
||||
): string[] {
|
||||
return options?.creds?.browserRuntimeEnv ? appendBrowserToolArgs(userArgs) : userArgs;
|
||||
}
|
||||
|
||||
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
|
||||
@@ -51,6 +60,9 @@ export class ClaudeAdapter implements TargetAdapter {
|
||||
if (creds.envVars) {
|
||||
Object.assign(env, creds.envVars);
|
||||
}
|
||||
if (creds.browserRuntimeEnv) {
|
||||
Object.assign(env, creds.browserRuntimeEnv);
|
||||
}
|
||||
|
||||
if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl;
|
||||
if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey;
|
||||
|
||||
@@ -188,20 +188,23 @@ export class CodexAdapter implements TargetAdapter {
|
||||
const profileType = options?.profileType || 'default';
|
||||
const creds = options?.creds;
|
||||
const reasoningOverride = normalizeCodexReasoningOverride(creds?.reasoningOverride);
|
||||
const runtimeConfigOverrides = creds?.runtimeConfigOverrides ?? [];
|
||||
|
||||
if (profileType === 'default') {
|
||||
const overrides = [...runtimeConfigOverrides];
|
||||
if (reasoningOverride) {
|
||||
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
|
||||
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
|
||||
}
|
||||
if (overrides.length === 0) {
|
||||
return userArgs;
|
||||
}
|
||||
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
|
||||
if (reasoningOverride) {
|
||||
throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo));
|
||||
}
|
||||
return [
|
||||
...buildConfigOverrideArgs([
|
||||
`model_reasoning_effort=${formatTomlString(reasoningOverride)}`,
|
||||
]),
|
||||
...userArgs,
|
||||
];
|
||||
return userArgs;
|
||||
}
|
||||
return userArgs;
|
||||
return [...buildConfigOverrideArgs(overrides), ...userArgs];
|
||||
}
|
||||
|
||||
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
|
||||
@@ -233,6 +236,8 @@ export class CodexAdapter implements TargetAdapter {
|
||||
overrides.push(`model=${formatTomlString(creds.model)}`);
|
||||
}
|
||||
|
||||
overrides.push(...runtimeConfigOverrides);
|
||||
|
||||
if (reasoningOverride) {
|
||||
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-d
|
||||
import type { ProfileType } from '../types/profile';
|
||||
import { upsertCcsModel } from './droid-config-manager';
|
||||
import { resolveDroidProvider } from './droid-provider';
|
||||
import { escapeShellArg } from '../utils/shell-executor';
|
||||
import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor';
|
||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||
import { runCleanup } from '../errors';
|
||||
|
||||
@@ -74,10 +74,11 @@ export class DroidAdapter implements TargetAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Droid uses config file for credentials — minimal env needed.
|
||||
* Droid uses config file for credentials — keep parent env, but strip stale
|
||||
* ANTHROPIC_* values so prior CCS/CLIProxy sessions do not leak into Droid.
|
||||
*/
|
||||
buildEnv(_creds: TargetCredentials, _profileType: ProfileType): NodeJS.ProcessEnv {
|
||||
return { ...process.env };
|
||||
return { ...stripAnthropicEnv(process.env) };
|
||||
}
|
||||
|
||||
exec(
|
||||
|
||||
@@ -29,8 +29,12 @@ export interface TargetCredentials {
|
||||
* Targets may ignore this when unsupported.
|
||||
*/
|
||||
reasoningOverride?: string | number;
|
||||
/** Target-native runtime config overrides (for example Codex -c key=value entries). */
|
||||
runtimeConfigOverrides?: string[];
|
||||
/** Additional env vars from profile resolution (websearch, hooks, etc.) */
|
||||
envVars?: NodeJS.ProcessEnv;
|
||||
/** Runtime browser reuse env passed through to Claude launches when browser MCP is active. */
|
||||
browserRuntimeEnv?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const CODEX_BROWSER_MCP_SERVER_NAME = 'ccs_browser';
|
||||
const DEFAULT_BROWSER_TOOL_TIMEOUT_SEC = 30;
|
||||
const PLAYWRIGHT_MCP_PACKAGE = '@playwright/mcp@0.0.70';
|
||||
|
||||
function formatTomlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function formatTomlArray(values: string[]): string {
|
||||
return JSON.stringify(values);
|
||||
}
|
||||
|
||||
function getNpxCommand(): string {
|
||||
return process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
}
|
||||
|
||||
export function getCodexBrowserMcpServerName(): string {
|
||||
return CODEX_BROWSER_MCP_SERVER_NAME;
|
||||
}
|
||||
|
||||
export function buildCodexBrowserMcpOverrides(): string[] {
|
||||
return [
|
||||
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.command=${formatTomlString(getNpxCommand())}`,
|
||||
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.args=${formatTomlArray(['-y', PLAYWRIGHT_MCP_PACKAGE])}`,
|
||||
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.enabled=true`,
|
||||
`mcp_servers.${CODEX_BROWSER_MCP_SERVER_NAME}.tool_timeout_sec=${DEFAULT_BROWSER_TOOL_TIMEOUT_SEC}`,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { expandPath } from '../helpers';
|
||||
|
||||
export interface BrowserReuseOptions {
|
||||
profileDir?: string;
|
||||
devtoolsPort?: string;
|
||||
}
|
||||
|
||||
export interface BrowserRuntimeEnv {
|
||||
CCS_BROWSER_USER_DATA_DIR: string;
|
||||
CCS_BROWSER_DEVTOOLS_HOST: string;
|
||||
CCS_BROWSER_DEVTOOLS_PORT: string;
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: string;
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: string;
|
||||
}
|
||||
|
||||
const DEVTOOLS_HOST = '127.0.0.1';
|
||||
const DEVTOOLS_ACTIVE_PORT_FILE = 'DevToolsActivePort';
|
||||
|
||||
export function resolveDefaultChromeUserDataDir(
|
||||
platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): string {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return path.join(
|
||||
env.HOME || env.USERPROFILE || '',
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Google',
|
||||
'Chrome'
|
||||
);
|
||||
case 'linux':
|
||||
return path.join(env.HOME || env.USERPROFILE || '', '.config', 'google-chrome');
|
||||
case 'win32': {
|
||||
const localAppData = env.LOCALAPPDATA;
|
||||
if (!localAppData) {
|
||||
throw new Error(
|
||||
'LOCALAPPDATA is required to resolve the default Chrome user-data-dir on Windows'
|
||||
);
|
||||
}
|
||||
return path.join(localAppData, 'Google', 'Chrome', 'User Data');
|
||||
}
|
||||
default:
|
||||
return path.join(env.HOME || env.USERPROFILE || '', '.config', 'google-chrome');
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveConfiguredBrowserProfileDir(profileDir?: string): string | undefined {
|
||||
if (profileDir?.trim()) {
|
||||
return expandPath(profileDir);
|
||||
}
|
||||
|
||||
try {
|
||||
const defaultUserDataDir = resolveDefaultChromeUserDataDir();
|
||||
return fs.existsSync(path.join(defaultUserDataDir, DEVTOOLS_ACTIVE_PORT_FILE))
|
||||
? defaultUserDataDir
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveBrowserRuntimeEnv(
|
||||
options: BrowserReuseOptions
|
||||
): Promise<BrowserRuntimeEnv> {
|
||||
const requestedProfileDir = options.profileDir || resolveDefaultChromeUserDataDir();
|
||||
const userDataDir = expandPath(requestedProfileDir);
|
||||
|
||||
const directoryStat = await safeStat(userDataDir);
|
||||
if (!directoryStat?.isDirectory()) {
|
||||
throw new Error(`Chrome profile directory is invalid: ${userDataDir}`);
|
||||
}
|
||||
|
||||
const metadataPath = path.join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
|
||||
const port = await resolveDevToolsPort({
|
||||
userDataDir,
|
||||
metadataPath,
|
||||
explicitPort: options.devtoolsPort || process.env.CCS_BROWSER_DEVTOOLS_PORT,
|
||||
});
|
||||
const httpUrl = `http://${DEVTOOLS_HOST}:${port}`;
|
||||
const versionUrl = `${httpUrl}/json/version`;
|
||||
|
||||
let versionPayload: unknown;
|
||||
try {
|
||||
const response = await fetch(versionUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
versionPayload = await response.json();
|
||||
} catch {
|
||||
throw new Error(`Chrome DevTools endpoint is unreachable: ${httpUrl}`);
|
||||
}
|
||||
|
||||
const websocketUrl =
|
||||
typeof versionPayload === 'object' &&
|
||||
versionPayload !== null &&
|
||||
typeof (versionPayload as Record<string, unknown>).webSocketDebuggerUrl === 'string'
|
||||
? (versionPayload as Record<string, string>).webSocketDebuggerUrl
|
||||
: '';
|
||||
|
||||
if (!websocketUrl) {
|
||||
throw new Error(`Chrome DevTools endpoint did not provide a websocket target: ${versionUrl}`);
|
||||
}
|
||||
|
||||
return {
|
||||
CCS_BROWSER_USER_DATA_DIR: userDataDir,
|
||||
CCS_BROWSER_DEVTOOLS_HOST: DEVTOOLS_HOST,
|
||||
CCS_BROWSER_DEVTOOLS_PORT: port,
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: httpUrl,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: websocketUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDevToolsPort(metadataPath: string, metadata: string): string {
|
||||
const [rawPort] = metadata
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
if (!rawPort || !/^\d+$/.test(rawPort)) {
|
||||
throw new Error(`Chrome reuse metadata is invalid: ${metadataPath}`);
|
||||
}
|
||||
|
||||
const port = Number.parseInt(rawPort, 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Chrome reuse metadata is invalid: ${metadataPath}`);
|
||||
}
|
||||
|
||||
return String(port);
|
||||
}
|
||||
|
||||
interface ResolveDevToolsPortOptions {
|
||||
userDataDir: string;
|
||||
metadataPath: string;
|
||||
explicitPort?: string;
|
||||
}
|
||||
|
||||
async function resolveDevToolsPort(options: ResolveDevToolsPortOptions): Promise<string> {
|
||||
if (options.explicitPort?.trim()) {
|
||||
return parsePortValue(options.explicitPort.trim(), 'CCS_BROWSER_DEVTOOLS_PORT');
|
||||
}
|
||||
|
||||
let metadata: string | undefined;
|
||||
try {
|
||||
metadata = await fs.promises.readFile(options.metadataPath, 'utf8');
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code && code !== 'ENOENT') {
|
||||
throw new Error(`Chrome reuse metadata is unreadable: ${options.metadataPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata !== undefined) {
|
||||
return parseDevToolsPort(options.metadataPath, metadata);
|
||||
}
|
||||
|
||||
const discoveredPort = discoverDevToolsPortFromChromeProcess(options.userDataDir);
|
||||
if (discoveredPort) {
|
||||
return discoveredPort;
|
||||
}
|
||||
|
||||
throw new Error(`Chrome reuse metadata not found: ${options.metadataPath}`);
|
||||
}
|
||||
|
||||
function parsePortValue(rawPort: string, sourceLabel: string): string {
|
||||
if (!/^\d+$/.test(rawPort)) {
|
||||
throw new Error(`Chrome reuse metadata is invalid: ${sourceLabel}`);
|
||||
}
|
||||
|
||||
const port = Number.parseInt(rawPort, 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Chrome reuse metadata is invalid: ${sourceLabel}`);
|
||||
}
|
||||
|
||||
return String(port);
|
||||
}
|
||||
|
||||
function discoverDevToolsPortFromChromeProcess(userDataDir: string): string | undefined {
|
||||
try {
|
||||
const processList = execFileSync('ps', ['-axww', '-o', 'command='], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const lines = processList
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const normalizedDir = normalizePathForComparison(userDataDir);
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.includes('Chrome') && !line.includes('chromium')) {
|
||||
continue;
|
||||
}
|
||||
if (!matchesUserDataDir(line, normalizedDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(/--remote-debugging-port=(\d+)/);
|
||||
if (match?.[1]) {
|
||||
return parsePortValue(match[1], 'process-list');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchesUserDataDir(commandLine: string, normalizedDir: string): boolean {
|
||||
const candidates = [
|
||||
`--user-data-dir=${normalizedDir}`,
|
||||
`--user-data-dir="${normalizedDir}"`,
|
||||
`--user-data-dir='${normalizedDir}'`,
|
||||
];
|
||||
const normalizedCommand = normalizePathForComparison(commandLine);
|
||||
return candidates.some((candidate) => normalizedCommand.includes(candidate));
|
||||
}
|
||||
|
||||
function normalizePathForComparison(value: string): string {
|
||||
return process.platform === 'win32' ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
async function safeStat(targetPath: string): Promise<fs.Stats | null> {
|
||||
try {
|
||||
return await fs.promises.stat(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
hasExactFlagValue as hasExactClaudeFlagValue,
|
||||
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
|
||||
} from '../claude-tool-args';
|
||||
|
||||
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
|
||||
const BROWSER_STEERING_PROMPT =
|
||||
'For DOM/screenshots/elements/page actions, prefer the CCS MCP Browser tool, reuse the configured running Chrome context whenever possible, and if the tool or context is unavailable, explain that clearly instead of pretending page state is available.';
|
||||
|
||||
function ensureBrowserSteeringPrompt(args: string[]): string[] {
|
||||
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
|
||||
|
||||
if (hasExactClaudeFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, BROWSER_STEERING_PROMPT)) {
|
||||
return args;
|
||||
}
|
||||
|
||||
return [...optionArgs, APPEND_SYSTEM_PROMPT_FLAG, BROWSER_STEERING_PROMPT, ...trailingArgs];
|
||||
}
|
||||
|
||||
export function appendBrowserToolArgs(args: string[]): string[] {
|
||||
return ensureBrowserSteeringPrompt(args);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Browser Utilities
|
||||
*/
|
||||
|
||||
export {
|
||||
getBrowserMcpServerName,
|
||||
getBrowserMcpServerPath,
|
||||
installBrowserMcpServer,
|
||||
ensureBrowserMcpConfig,
|
||||
ensureBrowserMcp,
|
||||
uninstallBrowserMcpServer,
|
||||
removeBrowserMcpConfig,
|
||||
uninstallBrowserMcp,
|
||||
syncBrowserMcpToConfigDir,
|
||||
ensureBrowserMcpOrThrow,
|
||||
} from './mcp-installer';
|
||||
|
||||
export { appendBrowserToolArgs } from './claude-tool-args';
|
||||
|
||||
export {
|
||||
resolveBrowserRuntimeEnv,
|
||||
resolveDefaultChromeUserDataDir,
|
||||
resolveConfiguredBrowserProfileDir,
|
||||
} from './chrome-reuse';
|
||||
export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse';
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Browser MCP installer and ~/.claude.json provisioning.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { InstanceManager } from '../../management/instance-manager';
|
||||
import { getClaudeUserConfigPath } from '../claude-config-path';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
|
||||
const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs';
|
||||
const BROWSER_MCP_SERVER_NAME = 'ccs-browser';
|
||||
|
||||
interface ClaudeUserConfig {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ManagedBrowserMcpConfig {
|
||||
type: 'stdio';
|
||||
command: 'node';
|
||||
args: [string];
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
function resolvePackageRoot(fromPath: string): string | null {
|
||||
let currentDir = path.dirname(fromPath);
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(currentDir, 'package.json'))) {
|
||||
return currentDir;
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
return null;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
function getBrowserMcpServerEnv(): Record<string, string> {
|
||||
const sourcePath = resolveBundledServerSourcePath();
|
||||
if (!sourcePath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const packageRoot = resolvePackageRoot(sourcePath);
|
||||
if (!packageRoot) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nodeModulesPath = path.join(packageRoot, 'node_modules');
|
||||
return fs.existsSync(nodeModulesPath) ? { NODE_PATH: nodeModulesPath } : {};
|
||||
}
|
||||
|
||||
function getCcsMcpDir(): string {
|
||||
return path.join(getCcsDir(), 'mcp');
|
||||
}
|
||||
|
||||
export function getBrowserMcpServerName(): string {
|
||||
return BROWSER_MCP_SERVER_NAME;
|
||||
}
|
||||
|
||||
export function getBrowserMcpServerPath(): string {
|
||||
return path.join(getCcsMcpDir(), BROWSER_MCP_SERVER);
|
||||
}
|
||||
|
||||
function hasMatchingContents(sourcePath: string, destinationPath: string): boolean {
|
||||
if (!fs.existsSync(destinationPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(sourcePath);
|
||||
try {
|
||||
const destination = fs.readFileSync(destinationPath);
|
||||
return source.equals(destination);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTempPath(targetPath: string): string {
|
||||
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${targetPath}.${suffix}.tmp`;
|
||||
}
|
||||
|
||||
function resolveBundledServerSourcePath(): string | null {
|
||||
const possiblePaths = [
|
||||
path.join(__dirname, '..', '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
|
||||
path.join(__dirname, '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
|
||||
path.join(__dirname, '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
|
||||
];
|
||||
|
||||
for (const candidate of possiblePaths) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as ClaudeUserConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean {
|
||||
const tempPath = getTempPath(configPath);
|
||||
const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||
fs.chmodSync(tempPath, fileMode);
|
||||
fs.renameSync(tempPath, configPath);
|
||||
return true;
|
||||
} finally {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
|
||||
const configDir = path.dirname(configPath);
|
||||
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
|
||||
let release: (() => void) | undefined;
|
||||
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(lockTarget)) {
|
||||
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
|
||||
}
|
||||
|
||||
try {
|
||||
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
|
||||
return callback();
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
release();
|
||||
} catch {
|
||||
// Best-effort release.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLockUnavailableError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
|
||||
}
|
||||
|
||||
function removeManagedServerConfig(configPath: string): boolean {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return withClaudeUserConfigLock(configPath, () => {
|
||||
const config = readClaudeUserConfig(configPath);
|
||||
if (config === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingServers =
|
||||
config.mcpServers &&
|
||||
typeof config.mcpServers === 'object' &&
|
||||
!Array.isArray(config.mcpServers)
|
||||
? { ...(config.mcpServers as Record<string, unknown>) }
|
||||
: {};
|
||||
|
||||
if (!(BROWSER_MCP_SERVER_NAME in existingServers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete existingServers[BROWSER_MCP_SERVER_NAME];
|
||||
|
||||
const nextConfig: ClaudeUserConfig = { ...config };
|
||||
if (Object.keys(existingServers).length === 0) {
|
||||
delete nextConfig.mcpServers;
|
||||
} else {
|
||||
nextConfig.mcpServers = existingServers;
|
||||
}
|
||||
|
||||
try {
|
||||
return writeClaudeUserConfig(configPath, nextConfig);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (isLockUnavailableError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function installBrowserMcpServer(): boolean {
|
||||
const sourcePath = resolveBundledServerSourcePath();
|
||||
if (!sourcePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mcpDir = getCcsMcpDir();
|
||||
if (!fs.existsSync(mcpDir)) {
|
||||
fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const serverPath = getBrowserMcpServerPath();
|
||||
const sourceMode = fs.statSync(sourcePath).mode & 0o777;
|
||||
if (hasMatchingContents(sourcePath, serverPath)) {
|
||||
if ((fs.statSync(serverPath).mode & 0o777) !== sourceMode) {
|
||||
fs.chmodSync(serverPath, sourceMode);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const tempPath = getTempPath(serverPath);
|
||||
|
||||
try {
|
||||
fs.copyFileSync(sourcePath, tempPath);
|
||||
fs.chmodSync(tempPath, sourceMode);
|
||||
try {
|
||||
fs.renameSync(tempPath, serverPath);
|
||||
} catch (renameError) {
|
||||
const errorCode = (renameError as NodeJS.ErrnoException).code;
|
||||
if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') {
|
||||
throw renameError;
|
||||
}
|
||||
|
||||
if (!hasMatchingContents(sourcePath, serverPath)) {
|
||||
fs.copyFileSync(tempPath, serverPath);
|
||||
fs.chmodSync(serverPath, sourceMode);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureBrowserMcpConfig(): boolean {
|
||||
const claudeUserConfigPath = getClaudeUserConfigPath();
|
||||
const claudeUserConfigDir = path.dirname(claudeUserConfigPath);
|
||||
if (!fs.existsSync(claudeUserConfigDir)) {
|
||||
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const desiredServerConfig: ManagedBrowserMcpConfig = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: [getBrowserMcpServerPath()],
|
||||
env: getBrowserMcpServerEnv(),
|
||||
};
|
||||
|
||||
try {
|
||||
return withClaudeUserConfigLock(claudeUserConfigPath, () => {
|
||||
const config = readClaudeUserConfig(claudeUserConfigPath);
|
||||
if (config === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingServers =
|
||||
config.mcpServers &&
|
||||
typeof config.mcpServers === 'object' &&
|
||||
!Array.isArray(config.mcpServers)
|
||||
? (config.mcpServers as Record<string, unknown>)
|
||||
: {};
|
||||
const currentConfig = existingServers[BROWSER_MCP_SERVER_NAME];
|
||||
if (
|
||||
typeof currentConfig === 'object' &&
|
||||
currentConfig !== null &&
|
||||
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextConfig: ClaudeUserConfig = {
|
||||
...config,
|
||||
mcpServers: {
|
||||
...existingServers,
|
||||
[BROWSER_MCP_SERVER_NAME]: desiredServerConfig,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (isLockUnavailableError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureBrowserMcp(): boolean {
|
||||
const installed = installBrowserMcpServer();
|
||||
const configured = installed && ensureBrowserMcpConfig();
|
||||
return installed && configured;
|
||||
}
|
||||
|
||||
export function syncBrowserMcpToConfigDir(claudeConfigDir: string | undefined): boolean {
|
||||
if (!claudeConfigDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new InstanceManager().syncMcpServers(claudeConfigDir);
|
||||
}
|
||||
|
||||
export function uninstallBrowserMcpServer(): boolean {
|
||||
const serverPath = getBrowserMcpServerPath();
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(serverPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeBrowserMcpConfig(): boolean {
|
||||
let removed = removeManagedServerConfig(getClaudeUserConfigPath());
|
||||
|
||||
const instanceManager = new InstanceManager();
|
||||
for (const instanceName of instanceManager.listInstances()) {
|
||||
const instancePath = instanceManager.getInstancePath(instanceName);
|
||||
const instanceClaudeConfigPath = path.join(instancePath, '.claude.json');
|
||||
removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed;
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function uninstallBrowserMcp(): boolean {
|
||||
const removedConfig = removeBrowserMcpConfig();
|
||||
const removedServer = uninstallBrowserMcpServer();
|
||||
return removedConfig || removedServer;
|
||||
}
|
||||
|
||||
export function ensureBrowserMcpOrThrow(): boolean {
|
||||
const ready = ensureBrowserMcp();
|
||||
if (!ready) {
|
||||
throw new Error('Browser MCP is enabled, but CCS could not prepare the local browser tool.');
|
||||
}
|
||||
|
||||
return ready;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export function splitArgsAtTerminator(args: string[]): {
|
||||
optionArgs: string[];
|
||||
trailingArgs: string[];
|
||||
} {
|
||||
const terminatorIndex = args.indexOf('--');
|
||||
if (terminatorIndex === -1) {
|
||||
return { optionArgs: args, trailingArgs: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
optionArgs: args.slice(0, terminatorIndex),
|
||||
trailingArgs: args.slice(terminatorIndex),
|
||||
};
|
||||
}
|
||||
|
||||
export function getImmediateFlagValue(args: string[], index: number): string | null {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value === '--' || value.startsWith('--')) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === flag) {
|
||||
const value = getImmediateFlagValue(args, index);
|
||||
if (value === expectedValue) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === `${flag}=${expectedValue}`) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,68 +1,48 @@
|
||||
/**
|
||||
* Claude launch argument helpers for first-class Image Analysis.
|
||||
*
|
||||
* Uses the same prompt injection mode as the user to avoid mixing
|
||||
* `--append-system-prompt` and `--append-system-prompt-file` in one request.
|
||||
*/
|
||||
|
||||
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
|
||||
const IMAGE_ANALYSIS_STEERING_PROMPT =
|
||||
'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.';
|
||||
import {
|
||||
hasExactFlagValue as hasExactClaudeFlagValue,
|
||||
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
|
||||
} from '../claude-tool-args';
|
||||
import {
|
||||
buildSteeringArg,
|
||||
hasManagedPromptFileArg,
|
||||
PROMPT_FLAG_INLINE,
|
||||
} from '../prompt-injection-strategy';
|
||||
|
||||
function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } {
|
||||
const terminatorIndex = args.indexOf('--');
|
||||
if (terminatorIndex === -1) {
|
||||
return { optionArgs: args, trailingArgs: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
optionArgs: args.slice(0, terminatorIndex),
|
||||
trailingArgs: args.slice(terminatorIndex),
|
||||
};
|
||||
}
|
||||
|
||||
function getImmediateFlagValue(args: string[], index: number): string | null {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value === '--' || value.startsWith('--')) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === flag) {
|
||||
const value = getImmediateFlagValue(args, index);
|
||||
if (value === expectedValue) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === `${flag}=${expectedValue}`) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
const IMAGE_ANALYSIS_STEERING_PROMPT = {
|
||||
name: 'ccs-prompt-image-analysis-tool',
|
||||
content:
|
||||
'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.',
|
||||
};
|
||||
|
||||
function ensureImageAnalysisSteeringPrompt(args: string[]): string[] {
|
||||
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
|
||||
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
|
||||
|
||||
if (hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)) {
|
||||
if (
|
||||
hasExactClaudeFlagValue(optionArgs, PROMPT_FLAG_INLINE, IMAGE_ANALYSIS_STEERING_PROMPT.content)
|
||||
) {
|
||||
return args;
|
||||
}
|
||||
|
||||
return [
|
||||
...optionArgs,
|
||||
APPEND_SYSTEM_PROMPT_FLAG,
|
||||
IMAGE_ANALYSIS_STEERING_PROMPT,
|
||||
...trailingArgs,
|
||||
];
|
||||
if (
|
||||
hasManagedPromptFileArg({ args: optionArgs, promptName: IMAGE_ANALYSIS_STEERING_PROMPT.name })
|
||||
) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const steeringArg = buildSteeringArg({
|
||||
args: optionArgs,
|
||||
promptName: IMAGE_ANALYSIS_STEERING_PROMPT.name,
|
||||
promptContent: IMAGE_ANALYSIS_STEERING_PROMPT.content,
|
||||
});
|
||||
|
||||
return [...optionArgs, ...steeringArg, ...trailingArgs];
|
||||
}
|
||||
|
||||
export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] {
|
||||
@@ -70,5 +50,5 @@ export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[]
|
||||
}
|
||||
|
||||
export function getImageAnalysisSteeringPrompt(): string {
|
||||
return IMAGE_ANALYSIS_STEERING_PROMPT;
|
||||
return IMAGE_ANALYSIS_STEERING_PROMPT.content;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Shared prompt injection strategy.
|
||||
*
|
||||
* Detects which prompt injection mode the user is using and ensures CCS
|
||||
* always uses the SAME mode so Claude CLI never receives mixed
|
||||
* `--append-system-prompt` and `--append-system-prompt-file` flags.
|
||||
*
|
||||
* Rules:
|
||||
* - User passes `--append-system-prompt` → all CCS prompts use inline
|
||||
* - User passes `--append-system-prompt-file` → all CCS prompts use file
|
||||
* - Neither present → default to inline (`--append-system-prompt`)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from './config-manager';
|
||||
|
||||
export type PromptInjectionMode = 'inline' | 'file';
|
||||
|
||||
/** `--append-system-prompt` — inline prompt text */
|
||||
export const PROMPT_FLAG_INLINE = '--append-system-prompt';
|
||||
/** `--append-system-prompt-file` — prompt read from file */
|
||||
export const PROMPT_FLAG_FILE = '--append-system-prompt-file';
|
||||
|
||||
function getManagedPromptsDir(): string {
|
||||
return path.join(getCcsDir(), 'prompts');
|
||||
}
|
||||
|
||||
export function getManagedPromptFileName(promptName: string): string {
|
||||
return `${promptName}.txt`;
|
||||
}
|
||||
|
||||
export function getManagedPromptFilePath(promptName: string): string {
|
||||
return path.join(getManagedPromptsDir(), getManagedPromptFileName(promptName));
|
||||
}
|
||||
|
||||
export function hasManagedPromptFileArg(params: { args: string[]; promptName: string }): boolean {
|
||||
const expectedPath = path.resolve(getManagedPromptFilePath(params.promptName));
|
||||
|
||||
for (let index = 0; index < params.args.length; index += 1) {
|
||||
const arg = params.args[index];
|
||||
|
||||
if (arg === PROMPT_FLAG_FILE) {
|
||||
const filePath = params.args[index + 1];
|
||||
if (filePath && path.resolve(filePath) === expectedPath) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
arg.startsWith(`${PROMPT_FLAG_FILE}=`) &&
|
||||
path.resolve(arg.slice(PROMPT_FLAG_FILE.length + 1)) === expectedPath
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which prompt injection mode to use based on user-provided args.
|
||||
*
|
||||
* - `--append-system-prompt-file` found (space or `=` form) → 'file'
|
||||
* - `--append-system-prompt` found (space or `=` form) → 'inline'
|
||||
* - Neither → 'inline' (default)
|
||||
*/
|
||||
export function detectPromptInjectionMode(args: string[]): PromptInjectionMode {
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === PROMPT_FLAG_FILE || arg.startsWith(`${PROMPT_FLAG_FILE}=`)) {
|
||||
return 'file';
|
||||
}
|
||||
}
|
||||
|
||||
return 'inline';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `--append-system-prompt <text>` arg pair.
|
||||
*/
|
||||
export function buildInlineSteeringArg(params: { promptContent: string }): string[] {
|
||||
return [PROMPT_FLAG_INLINE, params.promptContent];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `--append-system-prompt-file <path>` arg pair.
|
||||
* Writes the prompt to a temp file first.
|
||||
*/
|
||||
export function buildFileSteeringArg(params: {
|
||||
promptFileName: string;
|
||||
promptContent: string;
|
||||
}): string[] {
|
||||
const promptsFolder = getManagedPromptsDir();
|
||||
|
||||
if (!fs.existsSync(promptsFolder)) {
|
||||
fs.mkdirSync(promptsFolder, { recursive: true });
|
||||
}
|
||||
|
||||
const promptFile = path.join(promptsFolder, params.promptFileName);
|
||||
|
||||
fs.writeFileSync(promptFile, params.promptContent);
|
||||
|
||||
return [PROMPT_FLAG_FILE, promptFile];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build steering prompt args in the given mode.
|
||||
*/
|
||||
export function buildSteeringArg(params: {
|
||||
args: string[];
|
||||
promptName: string;
|
||||
promptContent: string;
|
||||
}): string[] {
|
||||
const mode = detectPromptInjectionMode(params.args);
|
||||
|
||||
if (mode === 'file') {
|
||||
return buildFileSteeringArg({
|
||||
promptFileName: getManagedPromptFileName(params.promptName),
|
||||
promptContent: params.promptContent,
|
||||
});
|
||||
}
|
||||
|
||||
return buildInlineSteeringArg({ promptContent: params.promptContent });
|
||||
}
|
||||
@@ -1,12 +1,28 @@
|
||||
/**
|
||||
* Claude launch argument helpers for third-party WebSearch.
|
||||
*
|
||||
* Uses the same prompt injection mode as the user to avoid mixing
|
||||
* `--append-system-prompt` and `--append-system-prompt-file` in one request.
|
||||
*/
|
||||
|
||||
import {
|
||||
getImmediateFlagValue,
|
||||
hasExactFlagValue as hasExactClaudeFlagValue,
|
||||
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
|
||||
} from '../claude-tool-args';
|
||||
import {
|
||||
buildSteeringArg,
|
||||
hasManagedPromptFileArg,
|
||||
PROMPT_FLAG_INLINE,
|
||||
} from '../prompt-injection-strategy';
|
||||
|
||||
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
|
||||
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
|
||||
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
|
||||
const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT =
|
||||
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
|
||||
export const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT = {
|
||||
name: 'ccs-prompt-websearch-tool',
|
||||
content:
|
||||
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.',
|
||||
};
|
||||
|
||||
function parseToolValue(rawValue: string): string[] {
|
||||
return rawValue
|
||||
@@ -23,26 +39,6 @@ function mergeToolValues(rawValues: string[], toolName: string): string {
|
||||
return merged.join(',');
|
||||
}
|
||||
|
||||
function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } {
|
||||
const terminatorIndex = args.indexOf('--');
|
||||
if (terminatorIndex === -1) {
|
||||
return { optionArgs: args, trailingArgs: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
optionArgs: args.slice(0, terminatorIndex),
|
||||
trailingArgs: args.slice(terminatorIndex),
|
||||
};
|
||||
}
|
||||
|
||||
function getImmediateFlagValue(args: string[], index: number): string | null {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value === '--' || value.startsWith('--')) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hasToolInFlag(args: string[], flag: string, toolName: string): boolean {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
@@ -68,32 +64,8 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === flag) {
|
||||
const value = getImmediateFlagValue(args, index);
|
||||
if (value === expectedValue) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === `${flag}=${expectedValue}`) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function ensureDisallowedNativeWebSearchTool(args: string[]): string[] {
|
||||
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
|
||||
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
|
||||
|
||||
if (hasToolInFlag(optionArgs, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) {
|
||||
return args;
|
||||
@@ -132,20 +104,34 @@ function ensureDisallowedNativeWebSearchTool(args: string[]): string[] {
|
||||
}
|
||||
|
||||
function ensureWebSearchSteeringPrompt(args: string[]): string[] {
|
||||
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
|
||||
const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args);
|
||||
|
||||
if (
|
||||
hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, THIRD_PARTY_WEBSEARCH_STEERING_PROMPT)
|
||||
hasExactClaudeFlagValue(
|
||||
optionArgs,
|
||||
PROMPT_FLAG_INLINE,
|
||||
THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content
|
||||
)
|
||||
) {
|
||||
return args;
|
||||
}
|
||||
|
||||
return [
|
||||
...optionArgs,
|
||||
APPEND_SYSTEM_PROMPT_FLAG,
|
||||
THIRD_PARTY_WEBSEARCH_STEERING_PROMPT,
|
||||
...trailingArgs,
|
||||
];
|
||||
if (
|
||||
hasManagedPromptFileArg({
|
||||
args: optionArgs,
|
||||
promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name,
|
||||
})
|
||||
) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const steeringArgs = buildSteeringArg({
|
||||
args: optionArgs,
|
||||
promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name,
|
||||
promptContent: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content,
|
||||
});
|
||||
|
||||
return [...optionArgs, ...steeringArgs, ...trailingArgs];
|
||||
}
|
||||
|
||||
export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] {
|
||||
|
||||
@@ -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<string, string> {
|
||||
const wsConfig = getWebSearchConfig();
|
||||
const env: Record<string, string> = {};
|
||||
const env: Record<string, string> = {
|
||||
CCS_WEBSEARCH_ENABLED: '0',
|
||||
CCS_WEBSEARCH_SKIP: '0',
|
||||
CCS_WEBSEARCH_EXA: '0',
|
||||
CCS_WEBSEARCH_TAVILY: '0',
|
||||
CCS_WEBSEARCH_BRAVE: '0',
|
||||
CCS_WEBSEARCH_SEARXNG: '0',
|
||||
CCS_WEBSEARCH_DUCKDUCKGO: '0',
|
||||
CCS_WEBSEARCH_GEMINI: '0',
|
||||
CCS_WEBSEARCH_OPENCODE: '0',
|
||||
CCS_WEBSEARCH_GROK: '0',
|
||||
};
|
||||
|
||||
if (process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1') {
|
||||
env.CCS_WEBSEARCH_TRACE = '1';
|
||||
@@ -61,6 +73,13 @@ export function getWebSearchHookEnv(): Record<string, string> {
|
||||
env.CCS_WEBSEARCH_BRAVE_MAX_RESULTS = String(wsConfig.providers.brave.max_results || 5);
|
||||
}
|
||||
|
||||
const searxngBaseUrl = normalizeSearxngBaseUrl(wsConfig.providers?.searxng?.url);
|
||||
if (wsConfig.providers?.searxng?.enabled && searxngBaseUrl) {
|
||||
env.CCS_WEBSEARCH_SEARXNG = '1';
|
||||
env.CCS_WEBSEARCH_SEARXNG_URL = searxngBaseUrl;
|
||||
env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = String(wsConfig.providers.searxng.max_results || 5);
|
||||
}
|
||||
|
||||
if (wsConfig.providers?.gemini?.enabled) {
|
||||
env.CCS_WEBSEARCH_GEMINI = '1';
|
||||
if (wsConfig.providers.gemini.model) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli';
|
||||
import { getGrokCliStatus } from './grok-cli';
|
||||
import { getOpenCodeCliStatus } from './opencode-cli';
|
||||
import { getWebSearchApiKeyStates } from './provider-secrets';
|
||||
import type { WebSearchCliInfo, WebSearchStatus } from './types';
|
||||
import { normalizeSearxngBaseUrl, type WebSearchCliInfo, type WebSearchStatus } from './types';
|
||||
|
||||
const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
|
||||
|
||||
@@ -28,6 +28,11 @@ function hasEnvValue(name: string): boolean {
|
||||
return (process.env[name] || '').trim().length > 0;
|
||||
}
|
||||
|
||||
function hasValidSearxngUrl(url: string | undefined): boolean {
|
||||
const normalized = normalizeSearxngBaseUrl(url);
|
||||
return normalized !== null && normalized !== '';
|
||||
}
|
||||
|
||||
function getProviderStatePath(): string {
|
||||
return join(getCcsDir(), 'cache', PROVIDER_STATE_FILE);
|
||||
}
|
||||
@@ -209,18 +214,6 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] {
|
||||
? 'Stored in dashboard, but Global Env is disabled'
|
||||
: 'Set TAVILY_API_KEY',
|
||||
},
|
||||
{
|
||||
id: 'duckduckgo',
|
||||
kind: 'backend',
|
||||
name: 'DuckDuckGo',
|
||||
enabled: wsConfig.providers?.duckduckgo?.enabled ?? true,
|
||||
available: wsConfig.providers?.duckduckgo?.enabled ?? true,
|
||||
version: null,
|
||||
docsUrl: 'https://duckduckgo.com',
|
||||
requiresApiKey: false,
|
||||
description: 'Default built-in HTML search backend. Zero setup.',
|
||||
detail: `Built-in (${wsConfig.providers?.duckduckgo?.max_results ?? 5} results)`,
|
||||
},
|
||||
{
|
||||
id: 'brave',
|
||||
kind: 'backend',
|
||||
@@ -238,6 +231,34 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] {
|
||||
? 'Stored in dashboard, but Global Env is disabled'
|
||||
: 'Set BRAVE_API_KEY',
|
||||
},
|
||||
{
|
||||
id: 'searxng',
|
||||
kind: 'backend',
|
||||
name: 'SearXNG',
|
||||
enabled: wsConfig.providers?.searxng?.enabled ?? false,
|
||||
available:
|
||||
(wsConfig.providers?.searxng?.enabled ?? false) &&
|
||||
hasValidSearxngUrl(wsConfig.providers?.searxng?.url),
|
||||
version: null,
|
||||
docsUrl: 'https://docs.searxng.org/dev/search_api.html',
|
||||
requiresApiKey: false,
|
||||
description: 'Configurable SearXNG JSON backend for self-hosted or public instances.',
|
||||
detail: hasValidSearxngUrl(wsConfig.providers?.searxng?.url)
|
||||
? `Configured (${wsConfig.providers?.searxng?.max_results ?? 5} results)`
|
||||
: 'Set a valid SearXNG base URL',
|
||||
},
|
||||
{
|
||||
id: 'duckduckgo',
|
||||
kind: 'backend',
|
||||
name: 'DuckDuckGo',
|
||||
enabled: wsConfig.providers?.duckduckgo?.enabled ?? true,
|
||||
available: wsConfig.providers?.duckduckgo?.enabled ?? true,
|
||||
version: null,
|
||||
docsUrl: 'https://duckduckgo.com',
|
||||
requiresApiKey: false,
|
||||
description: 'Default built-in HTML search backend. Zero setup.',
|
||||
detail: `Built-in (${wsConfig.providers?.duckduckgo?.max_results ?? 5} results)`,
|
||||
},
|
||||
];
|
||||
|
||||
return [...providers, ...getLegacyProviderStatuses()].map((provider) =>
|
||||
@@ -263,6 +284,7 @@ export function getCliInstallHints(): string[] {
|
||||
return [
|
||||
'WebSearch: no ready providers',
|
||||
' Enable DuckDuckGo in Settings > WebSearch for zero-setup search',
|
||||
' Or enable SearXNG and set a valid base URL (must support /search?format=json)',
|
||||
' Or export EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY for API-backed search',
|
||||
' Optional legacy fallback: npm i -g @google/gemini-cli',
|
||||
];
|
||||
|
||||
@@ -11,13 +11,12 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
import { createLogger } from '../../services/logging';
|
||||
import { hasManagedPromptFileArg, PROMPT_FLAG_INLINE } from '../prompt-injection-strategy';
|
||||
import { THIRD_PARTY_WEBSEARCH_STEERING_PROMPT } from './claude-tool-args';
|
||||
|
||||
const TRACE_FILE_NAME = 'websearch-trace.jsonl';
|
||||
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
|
||||
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
|
||||
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
|
||||
const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT =
|
||||
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
|
||||
const logger = createLogger('websearch');
|
||||
|
||||
function parseToolValue(rawValue: string): string[] {
|
||||
@@ -58,14 +57,23 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
|
||||
function hasExactFlagValue(params: {
|
||||
args: string[];
|
||||
flag: string;
|
||||
expectedValue: string;
|
||||
}): boolean {
|
||||
const { args, flag, expectedValue } = params;
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === flag) {
|
||||
if (getImmediateFlagValue(args, index) === expectedValue) {
|
||||
const immediateFlagValue = getImmediateFlagValue(args, index);
|
||||
|
||||
if (immediateFlagValue === expectedValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -179,16 +187,30 @@ function buildLaunchId(): string {
|
||||
return `websearch-${Date.now()}-${process.pid}-${random}`;
|
||||
}
|
||||
|
||||
function hasSteeringPromptInArgs(args: string[]): boolean {
|
||||
if (
|
||||
hasExactFlagValue({
|
||||
args,
|
||||
flag: PROMPT_FLAG_INLINE,
|
||||
expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasManagedPromptFileArg({ args, promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name })) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function summarizeLaunchArgs(args: string[]): Record<string, unknown> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,9 @@ export type WebSearchReadiness = 'ready' | 'needs_setup' | 'unavailable';
|
||||
export type WebSearchProviderId =
|
||||
| 'exa'
|
||||
| 'tavily'
|
||||
| 'duckduckgo'
|
||||
| 'brave'
|
||||
| 'searxng'
|
||||
| 'duckduckgo'
|
||||
| 'gemini'
|
||||
| 'grok'
|
||||
| 'opencode';
|
||||
@@ -97,6 +98,7 @@ export interface WebSearchProviderConfig {
|
||||
model?: string;
|
||||
timeout?: number;
|
||||
max_results?: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,10 +109,53 @@ export interface WebSearchConfig {
|
||||
providers?: {
|
||||
exa?: WebSearchProviderConfig;
|
||||
tavily?: WebSearchProviderConfig;
|
||||
duckduckgo?: WebSearchProviderConfig;
|
||||
brave?: WebSearchProviderConfig;
|
||||
searxng?: WebSearchProviderConfig;
|
||||
duckduckgo?: WebSearchProviderConfig;
|
||||
gemini?: WebSearchProviderConfig;
|
||||
opencode?: WebSearchProviderConfig;
|
||||
grok?: WebSearchProviderConfig;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a SearXNG base URL so runtime code can safely append `/search`.
|
||||
*
|
||||
* Accepts optional subpaths (for reverse-proxy deployments), strips a trailing
|
||||
* `/search` endpoint suffix, and rejects query/hash-bearing URLs because the
|
||||
* runtime owns the request path and query params.
|
||||
*/
|
||||
export function normalizeSearxngBaseUrl(url: string | undefined): string | null {
|
||||
const normalized = String(url || '').trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.search || parsed.hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pathname = parsed.pathname.replace(/\/+$/, '');
|
||||
if (pathname.toLowerCase().endsWith('/search')) {
|
||||
pathname = pathname.slice(0, -'/search'.length);
|
||||
}
|
||||
|
||||
parsed.pathname = pathname || '/';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<void> => {
|
||||
*/
|
||||
router.post('/restart', async (_req: Request, res: Response): Promise<void> => {
|
||||
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
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
stopDaemon,
|
||||
checkAuthStatus,
|
||||
autoDetectTokens,
|
||||
probeCursorRuntime,
|
||||
saveCredentials,
|
||||
validateToken,
|
||||
} from '../../cursor';
|
||||
@@ -30,6 +31,34 @@ interface DaemonStartPreconditionError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function getAutoDetectFailureStatus(
|
||||
reason?: ReturnType<typeof autoDetectTokens>['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<typeof autoDetectTokens>): string {
|
||||
switch (result.reason) {
|
||||
case 'db_not_found':
|
||||
return 'Cursor state database not found on this host.';
|
||||
case 'sqlite_unavailable':
|
||||
return 'Cursor state database was found, but sqlite3 is not available in PATH.';
|
||||
case 'db_query_failed':
|
||||
return 'Cursor state database was found, but CCS could not query it.';
|
||||
default:
|
||||
return result.error ?? 'Token not found';
|
||||
}
|
||||
}
|
||||
|
||||
export function getDaemonStartPreconditionError(
|
||||
input: DaemonStartPreconditionInput
|
||||
): DaemonStartPreconditionError | null {
|
||||
@@ -124,7 +153,11 @@ router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise<v
|
||||
const result = autoDetectTokens();
|
||||
|
||||
if (!result.found || !result.accessToken || !result.machineId) {
|
||||
res.status(404).json({ error: result.error ?? 'Token not found' });
|
||||
const status = getAutoDetectFailureStatus(result.reason);
|
||||
res.status(status).json({
|
||||
error: getPublicAutoDetectError(result),
|
||||
reason: result.reason ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,6 +188,19 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cursor/probe - Run a live authenticated runtime probe
|
||||
*/
|
||||
router.post('/probe', async (_req: Request, res: Response): Promise<void> => {
|
||||
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}
|
||||
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
WEBSEARCH_API_KEY_PROVIDERS,
|
||||
type WebSearchApiKeyProviderId,
|
||||
} from '../../utils/websearch/provider-secrets';
|
||||
import { normalizeSearxngBaseUrl } from '../../utils/websearch/types';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
const WEBSEARCH_LOCAL_ACCESS_ERROR =
|
||||
'WebSearch endpoints require localhost access when dashboard auth is disabled.';
|
||||
const DEFAULT_WEBSEARCH_MAX_RESULTS = 5;
|
||||
const MAX_WEBSEARCH_MAX_RESULTS = 10;
|
||||
|
||||
type WebSearchApiKeyUpdates = Partial<Record<WebSearchApiKeyProviderId, string | null>>;
|
||||
|
||||
@@ -28,6 +31,12 @@ function isWebSearchApiKeyProviderId(value: string): value is WebSearchApiKeyPro
|
||||
return Object.prototype.hasOwnProperty.call(WEBSEARCH_API_KEY_PROVIDERS, value);
|
||||
}
|
||||
|
||||
function clampWebSearchMaxResults(value: number | undefined, fallback: number): number {
|
||||
const candidate = Number.isFinite(value) ? (value as number) : fallback;
|
||||
const normalized = Number.isFinite(candidate) ? candidate : DEFAULT_WEBSEARCH_MAX_RESULTS;
|
||||
return Math.max(1, Math.min(MAX_WEBSEARCH_MAX_RESULTS, Math.floor(normalized)));
|
||||
}
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (requireLocalAccessWhenAuthDisabled(req, res, WEBSEARCH_LOCAL_ACCESS_ERROR)) {
|
||||
next();
|
||||
@@ -82,6 +91,35 @@ router.put('/', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (providers?.searxng?.url !== undefined && typeof providers.searxng.url !== 'string') {
|
||||
res.status(400).json({ error: 'Invalid value for providers.searxng.url. Must be a string.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSearxngUrl =
|
||||
providers?.searxng?.url !== undefined
|
||||
? normalizeSearxngBaseUrl(providers.searxng.url)
|
||||
: undefined;
|
||||
|
||||
if (providers?.searxng?.url !== undefined && normalizedSearxngUrl === null) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
providers?.searxng?.max_results !== undefined &&
|
||||
(typeof providers.searxng.max_results !== 'number' ||
|
||||
!Number.isFinite(providers.searxng.max_results))
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid value for providers.searxng.max_results. Must be a number.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
apiKeys !== undefined &&
|
||||
(apiKeys === null || Array.isArray(apiKeys) || typeof apiKeys !== 'object')
|
||||
@@ -106,6 +144,9 @@ router.put('/', (req: Request, res: Response): void => {
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
const existingSearxngUrl =
|
||||
normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '';
|
||||
|
||||
config.websearch = {
|
||||
enabled: enabled ?? config.websearch?.enabled ?? true,
|
||||
providers: providers
|
||||
@@ -144,6 +185,17 @@ router.put('/', (req: Request, res: Response): void => {
|
||||
config.websearch?.providers?.brave?.max_results ??
|
||||
5,
|
||||
},
|
||||
searxng: {
|
||||
enabled:
|
||||
providers.searxng?.enabled ??
|
||||
config.websearch?.providers?.searxng?.enabled ??
|
||||
false,
|
||||
url: normalizedSearxngUrl ?? existingSearxngUrl,
|
||||
max_results: clampWebSearchMaxResults(
|
||||
providers.searxng?.max_results,
|
||||
config.websearch?.providers?.searxng?.max_results ?? DEFAULT_WEBSEARCH_MAX_RESULTS
|
||||
),
|
||||
},
|
||||
gemini: {
|
||||
enabled:
|
||||
providers.gemini?.enabled ??
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -53,6 +53,11 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '', // clear any inherited skip flag
|
||||
CCS_IMAGE_ANALYSIS_SKIP_HOOK: '', // clear any inherited MCP-ready bypass
|
||||
CCS_IMAGE_ANALYSIS_MODEL: '', // let each test control explicit model fallback behavior
|
||||
CCS_IMAGE_ANALYSIS_BACKEND_ID: '',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '',
|
||||
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
||||
CCS_CLIPROXY_PORT: String(mockPort),
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
|
||||
@@ -85,6 +85,20 @@ describe('npm CLI', () => {
|
||||
assert(!output.includes("Profile '--verbose' not found"), 'Should not treat flags as profiles');
|
||||
}
|
||||
});
|
||||
|
||||
it('routes cursor probe through the cursor command handler', function() {
|
||||
try {
|
||||
execSync(`bun "${srcCcsPath}" cursor probe`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 3000,
|
||||
env: { ...process.env, CCS_HOME: testCcsHome }
|
||||
});
|
||||
} catch (e) {
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
assert(!output.includes("Profile 'cursor' not found"), 'Should not fall through to profile lookup');
|
||||
assert(output.includes('Cursor Live Probe'), 'Should render the cursor probe command output');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Profile handling', () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { maybeWarnAboutResumeLaneMismatch } from '../../../src/auth/resume-lane-warning';
|
||||
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('resume lane warning', () => {
|
||||
it('prints guidance when the plain ccs lane differs from the account lane', async () => {
|
||||
const logs: string[] = [];
|
||||
@@ -15,10 +19,12 @@ describe('resume lane warning', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(logs[0]).toContain('Resume for account "work" will search that account lane');
|
||||
expect(logs).toContain('[i] Account lane: /tmp/account-lane');
|
||||
expect(logs).toContain('[i] Plain ccs lane: native Claude lane (/tmp/native-lane)');
|
||||
expect(logs).toContain('[i] Recover the original lane first: ccs -r');
|
||||
const plainLogs = logs.map((message) => stripAnsi(message));
|
||||
|
||||
expect(plainLogs[0]).toContain('Resume for account "work" will search that account lane');
|
||||
expect(plainLogs).toContain('[i] Account lane: /tmp/account-lane');
|
||||
expect(plainLogs).toContain('[i] Plain ccs lane: native Claude lane (/tmp/native-lane)');
|
||||
expect(plainLogs).toContain('[i] Recover the original lane first: ccs -r');
|
||||
});
|
||||
|
||||
it('does not log anything when resume is not requested', async () => {
|
||||
|
||||
@@ -774,6 +774,11 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
});
|
||||
|
||||
it('handles upstream connection errors gracefully', async () => {
|
||||
const originalNoProxy = process.env.NO_PROXY;
|
||||
const originalNoProxyLower = process.env.no_proxy;
|
||||
process.env.NO_PROXY = '127.0.0.1,localhost';
|
||||
process.env.no_proxy = '127.0.0.1,localhost';
|
||||
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: 'http://127.0.0.1:1', // Invalid port
|
||||
timeoutMs: 1000,
|
||||
@@ -790,6 +795,16 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
expect(response.status).toBe(502);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
if (originalNoProxy === undefined) {
|
||||
delete process.env.NO_PROXY;
|
||||
} else {
|
||||
process.env.NO_PROXY = originalNoProxy;
|
||||
}
|
||||
if (originalNoProxyLower === undefined) {
|
||||
delete process.env.no_proxy;
|
||||
} else {
|
||||
process.env.no_proxy = originalNoProxyLower;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { handleCatalogJson } from '../../../src/commands/cliproxy/catalog-subcommand';
|
||||
import { handleCliproxyCommand } from '../../../src/commands/cliproxy/index';
|
||||
|
||||
let originalConsoleLog: typeof console.log;
|
||||
let capturedOutput: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
originalConsoleLog = console.log;
|
||||
capturedOutput = [];
|
||||
console.log = (...args: unknown[]) => {
|
||||
capturedOutput.push(args.map(String).join(' '));
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalConsoleLog;
|
||||
});
|
||||
|
||||
describe('cliproxy catalog --json output', () => {
|
||||
it('outputs valid JSON mapping provider names to model arrays', () => {
|
||||
handleCatalogJson();
|
||||
|
||||
expect(capturedOutput).toHaveLength(1);
|
||||
const parsed = JSON.parse(capturedOutput[0]) as Record<string, unknown[]>;
|
||||
|
||||
// Must be a non-empty object (static catalog always has providers)
|
||||
expect(typeof parsed).toBe('object');
|
||||
expect(Object.keys(parsed).length).toBeGreaterThan(0);
|
||||
|
||||
// Every provider entry must be an array of objects with at least id and name
|
||||
for (const models of Object.values(parsed)) {
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
for (const model of models as Array<Record<string, unknown>>) {
|
||||
expect(typeof model.id).toBe('string');
|
||||
expect(typeof model.name).toBe('string');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('includes metadata fields when present on model entries', () => {
|
||||
handleCatalogJson();
|
||||
|
||||
const parsed = JSON.parse(capturedOutput[0]) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
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<Record<string, unknown>>
|
||||
>;
|
||||
const allModels = Object.values(parsed).flat();
|
||||
|
||||
for (const model of allModels) {
|
||||
for (const value of Object.values(model)) {
|
||||
expect(value).not.toBeNull();
|
||||
expect(value).not.toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('includes explicit false boolean values in output', () => {
|
||||
handleCatalogJson();
|
||||
|
||||
const parsed = JSON.parse(capturedOutput[0]) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
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<Record<string, unknown>>
|
||||
>;
|
||||
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<string, unknown>;
|
||||
expect(['budget', 'levels', 'none']).toContain(thinking.type);
|
||||
}
|
||||
});
|
||||
|
||||
it('outputs minified JSON (single line, no whitespace formatting)', () => {
|
||||
handleCatalogJson();
|
||||
|
||||
const output = capturedOutput[0];
|
||||
expect(output.includes('\n')).toBe(false);
|
||||
expect(JSON.stringify(JSON.parse(output))).toBe(output);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cliproxy catalog --json routing', () => {
|
||||
it('routes catalog --json through handleCliproxyCommand', async () => {
|
||||
await handleCliproxyCommand(['catalog', '--json']);
|
||||
|
||||
expect(capturedOutput).toHaveLength(1);
|
||||
const parsed = JSON.parse(capturedOutput[0]);
|
||||
expect(typeof parsed).toBe('object');
|
||||
expect(Object.keys(parsed).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('--json takes priority over refresh subcommand', async () => {
|
||||
await handleCliproxyCommand(['catalog', 'refresh', '--json']);
|
||||
|
||||
expect(capturedOutput).toHaveLength(1);
|
||||
// Should output JSON, not refresh output
|
||||
const parsed = JSON.parse(capturedOutput[0]);
|
||||
expect(typeof parsed).toBe('object');
|
||||
});
|
||||
|
||||
it('--json takes priority when placed before subcommand', async () => {
|
||||
await handleCliproxyCommand(['catalog', '--json', 'reset']);
|
||||
|
||||
expect(capturedOutput).toHaveLength(1);
|
||||
const parsed = JSON.parse(capturedOutput[0]);
|
||||
expect(typeof parsed).toBe('object');
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -73,6 +73,8 @@ function stubProcessExit(): void {
|
||||
beforeEach(async () => {
|
||||
tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-'));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
// Clear CLAUDE_CONFIG_DIR so scoped CCS_HOME takes effect (leaks from host CCS session)
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
originalProcessExit = process.exit;
|
||||
originalFsOpen = fs.promises.open;
|
||||
originalFsRename = fs.promises.rename;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -484,6 +484,9 @@ describe('renderCursorHelp', () => {
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(logs.some((line) => line.includes('Usage: ccs cursor <subcommand>'))).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);
|
||||
|
||||
@@ -742,14 +742,27 @@ describe('Message Translation', () => {
|
||||
|
||||
describe('Request Encoding', () => {
|
||||
describe('generateCursorBody', () => {
|
||||
it('should encode basic text message', () => {
|
||||
it('should encode a raw top-level request protobuf for basic text messages', () => {
|
||||
const result = generateCursorBody([{ role: 'user', content: 'Hello' }], 'gpt-4', [], null);
|
||||
const topLevel = decodeMessage(result);
|
||||
const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array;
|
||||
const chatRequest = decodeMessage(requestPayload);
|
||||
const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) =>
|
||||
decodeMessage(entry.value as Uint8Array)
|
||||
);
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(Array.from(result.slice(0, 5))).not.toEqual([0, 0, 0, 1, 107]);
|
||||
expect(topLevel.has(FIELD.Request.REQUEST)).toBe(true);
|
||||
expect(encodedMessages).toHaveLength(1);
|
||||
expect(decoder.decode(encodedMessages[0].get(FIELD.Message.CONTENT)?.[0]?.value as Uint8Array)).toBe(
|
||||
'Hello'
|
||||
);
|
||||
});
|
||||
|
||||
it('should encode message with tools', () => {
|
||||
it('should encode message with tools into the raw request payload', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
@@ -773,9 +786,14 @@ describe('Request Encoding', () => {
|
||||
tools,
|
||||
null
|
||||
);
|
||||
const topLevel = decodeMessage(result);
|
||||
const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array;
|
||||
const chatRequest = decodeMessage(requestPayload);
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(topLevel.has(FIELD.Request.REQUEST)).toBe(true);
|
||||
expect((chatRequest.get(FIELD.Chat.MCP_TOOLS) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it('should preserve flattened tool_result blocks through protobuf encoding', () => {
|
||||
@@ -806,10 +824,7 @@ describe('Request Encoding', () => {
|
||||
);
|
||||
|
||||
const body = generateCursorBody(translated.messages, 'gpt-4', [], null);
|
||||
const frame = parseConnectRPCFrame(Buffer.from(body));
|
||||
expect(frame).not.toBeNull();
|
||||
|
||||
const topLevel = decodeMessage(frame!.payload);
|
||||
const topLevel = decodeMessage(body);
|
||||
const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array;
|
||||
const chatRequest = decodeMessage(requestPayload);
|
||||
const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) =>
|
||||
@@ -831,7 +846,7 @@ describe('Request Encoding', () => {
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle malformed frame gracefully', () => {
|
||||
it('should reject malformed frame headers', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Incomplete frame header (only 3 bytes instead of 5)
|
||||
@@ -841,11 +856,13 @@ describe('Request Encoding', () => {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should return valid response even with malformed input
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.status).toBe(502);
|
||||
const body = JSON.parse(await result.text());
|
||||
expect(body.error.type).toBe('server_error');
|
||||
expect(body.error.message).toContain('Truncated Cursor ConnectRPC frame');
|
||||
});
|
||||
|
||||
it('should handle truncated payload', () => {
|
||||
it('should reject truncated payloads', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Frame header says payload is 100 bytes but only 5 bytes follow
|
||||
@@ -857,8 +874,10 @@ describe('Request Encoding', () => {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should handle gracefully
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.status).toBe(502);
|
||||
const body = JSON.parse(await result.text());
|
||||
expect(body.error.type).toBe('server_error');
|
||||
expect(body.error.message).toContain('Truncated Cursor ConnectRPC frame');
|
||||
});
|
||||
|
||||
it('should handle multi-frame buffer', () => {
|
||||
@@ -1042,6 +1061,29 @@ describe('CursorExecutor', () => {
|
||||
expect(body.error.type).toBe('rate_limit_error');
|
||||
});
|
||||
|
||||
it('should map unavailable end-stream errors to 503', async () => {
|
||||
const unavailableFrame = buildFrame(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'unavailable',
|
||||
message: 'upstream down',
|
||||
},
|
||||
})
|
||||
),
|
||||
0x02
|
||||
);
|
||||
|
||||
const result = executor.transformProtobufToJSON(unavailableFrame, 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(503);
|
||||
const body = JSON.parse(await result.text());
|
||||
expect(body.error.type).toBe('api_error');
|
||||
expect(body.error.message).toContain('upstream down');
|
||||
});
|
||||
|
||||
it('should surface reasoning_content when thinking payload is present', async () => {
|
||||
const textContent = 'Final answer';
|
||||
const thinkingContent = 'Internal reasoning trail';
|
||||
@@ -1177,7 +1219,7 @@ describe('CursorExecutor', () => {
|
||||
});
|
||||
|
||||
describe('decompressPayload error handling', () => {
|
||||
it('should return empty buffer on decompression failure', () => {
|
||||
it('returns an explicit executor error on decompression failure', async () => {
|
||||
// Create invalid gzip data
|
||||
const invalidGzip = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0xff, 0xff]);
|
||||
const frame = new Uint8Array(5 + invalidGzip.length);
|
||||
@@ -1192,13 +1234,15 @@ describe('CursorExecutor', () => {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should handle gracefully and return valid response
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.status).toBe(502);
|
||||
const body = JSON.parse(await result.text());
|
||||
expect(body.error.type).toBe('server_error');
|
||||
expect(body.error.message).toContain('decompress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return empty buffer on decompression failure', () => {
|
||||
it('returns an explicit error for invalid compressed payloads', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Invalid compressed payload (not actually gzipped)
|
||||
@@ -1217,13 +1261,80 @@ describe('CursorExecutor', () => {
|
||||
|
||||
const buffer = Buffer.from(frame);
|
||||
|
||||
// Should not crash - decompression failure returns empty buffer
|
||||
const result = executor.transformProtobufToJSON(buffer, 'test-model', {
|
||||
messages: [],
|
||||
stream: false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(502);
|
||||
const body = JSON.parse(await result.text());
|
||||
expect(body.error.type).toBe('server_error');
|
||||
expect(body.error.message).toContain('decompress');
|
||||
});
|
||||
|
||||
it('surfaces first-frame protocol errors in SSE mode without pretending success', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const invalidGzipPayload = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const frame = buildFrame(invalidGzipPayload, 0x01);
|
||||
|
||||
const result = executor.transformProtobufToSSE(frame, 'test-model', {
|
||||
messages: [],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(502);
|
||||
const body = JSON.parse(await result.text());
|
||||
expect(body.error.type).toBe('server_error');
|
||||
expect(body.error.message).toContain('decompress');
|
||||
});
|
||||
|
||||
it('emits an SSE error event without [DONE] when a later frame fails', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const combined = Buffer.concat([
|
||||
buildTextFrame('Before failure'),
|
||||
buildFrame(new Uint8Array([1, 2, 3, 4, 5]), 0x01),
|
||||
]);
|
||||
|
||||
const result = executor.transformProtobufToSSE(combined, 'test-model', {
|
||||
messages: [],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
const body = await result.text();
|
||||
expect(body).toContain('Before failure');
|
||||
expect(body).toContain('event: error');
|
||||
expect(body).toContain('"type":"server_error"');
|
||||
expect(body).not.toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('emits an SSE error event when trailing bytes leave a truncated frame', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const combined = Buffer.concat([buildTextFrame('Partial success'), Buffer.from([0x00, 0x00, 0x00])]);
|
||||
|
||||
const result = executor.transformProtobufToSSE(combined, 'test-model', {
|
||||
messages: [],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
const body = await result.text();
|
||||
expect(body).toContain('Partial success');
|
||||
expect(body).toContain('event: error');
|
||||
expect(body).toContain('Truncated Cursor ConnectRPC frame');
|
||||
expect(body).not.toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('returns an explicit error for unknown ConnectRPC frame flags', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const result = executor.transformProtobufToJSON(buildFrame(new Uint8Array([0x01]), 0x04), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(502);
|
||||
const body = JSON.parse(await result.text());
|
||||
expect(body.error.type).toBe('server_error');
|
||||
expect(body.error.message).toContain('0x04');
|
||||
});
|
||||
|
||||
it('should log unknown message roles in debug mode', () => {
|
||||
@@ -1418,6 +1529,55 @@ describe('StreamingFrameParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should surface end-stream JSON errors instead of treating them as gzip', () => {
|
||||
const parser = new StreamingFrameParser();
|
||||
const endStreamError = buildFrame(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'invalid_argument',
|
||||
message: 'parse binary: illegal tag: field no 0 wire type 0',
|
||||
},
|
||||
})
|
||||
),
|
||||
0x02
|
||||
);
|
||||
|
||||
const results = parser.push(endStreamError);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].type).toBe('error');
|
||||
if (results[0].type === 'error') {
|
||||
expect(results[0].status).toBe(400);
|
||||
expect(results[0].errorType).toBe('api_error');
|
||||
expect(results[0].message).toContain('illegal tag');
|
||||
}
|
||||
});
|
||||
|
||||
it('should map unavailable end-stream errors to 503 in the parser', () => {
|
||||
const parser = new StreamingFrameParser();
|
||||
const endStreamError = buildFrame(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'unavailable',
|
||||
message: 'upstream down',
|
||||
},
|
||||
})
|
||||
),
|
||||
0x02
|
||||
);
|
||||
|
||||
const results = parser.push(endStreamError);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].type).toBe('error');
|
||||
if (results[0].type === 'error') {
|
||||
expect(results[0].status).toBe(503);
|
||||
expect(results[0].errorType).toBe('api_error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse thinking frames', () => {
|
||||
const parser = new StreamingFrameParser();
|
||||
const frame = buildThinkingFrame('Think step by step');
|
||||
@@ -1458,11 +1618,39 @@ describe('StreamingFrameParser', () => {
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].type).toBe('error');
|
||||
if (results[0].type === 'error') {
|
||||
expect(results[0].status).toBe(502);
|
||||
expect(results[0].errorType).toBe('server_error');
|
||||
expect(results[0].message).toContain('Malformed protobuf response');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid gzip-compressed frames explicitly', () => {
|
||||
const parser = new StreamingFrameParser();
|
||||
const invalidCompressedFrame = buildFrame(new Uint8Array([1, 2, 3, 4, 5]), 0x01);
|
||||
const results = parser.push(invalidCompressedFrame);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].type).toBe('error');
|
||||
if (results[0].type === 'error') {
|
||||
expect(results[0].status).toBe(502);
|
||||
expect(results[0].errorType).toBe('server_error');
|
||||
expect(results[0].message).toContain('decompress');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject unknown ConnectRPC frame flag bits', () => {
|
||||
const parser = new StreamingFrameParser();
|
||||
const results = parser.push(buildFrame(new Uint8Array([0x01]), 0x04));
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].type).toBe('error');
|
||||
if (results[0].type === 'error') {
|
||||
expect(results[0].status).toBe(502);
|
||||
expect(results[0].errorType).toBe('server_error');
|
||||
expect(results[0].message).toContain('0x04');
|
||||
}
|
||||
});
|
||||
|
||||
it('should report hasPartial() correctly', () => {
|
||||
const parser = new StreamingFrameParser();
|
||||
expect(parser.hasPartial()).toBe(false);
|
||||
@@ -1481,6 +1669,20 @@ describe('StreamingFrameParser', () => {
|
||||
parser2.push(emptyFrame);
|
||||
expect(parser2.hasPartial()).toBe(false);
|
||||
});
|
||||
|
||||
it('should surface truncated trailing bytes when the stream finishes', () => {
|
||||
const parser = new StreamingFrameParser();
|
||||
parser.push(Buffer.from([0x00, 0x00, 0x00]));
|
||||
|
||||
const results = parser.finish();
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].type).toBe('error');
|
||||
if (results[0].type === 'error') {
|
||||
expect(results[0].status).toBe(502);
|
||||
expect(results[0].message).toContain('Truncated Cursor ConnectRPC frame');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decompressPayload', () => {
|
||||
@@ -1496,10 +1698,18 @@ describe('decompressPayload', () => {
|
||||
expect(result).toEqual(errorPayload);
|
||||
});
|
||||
|
||||
it('should return empty buffer on invalid gzip data', () => {
|
||||
it('should throw on invalid gzip data', () => {
|
||||
const invalidGzip = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
const result = decompressPayload(invalidGzip, 0x01);
|
||||
expect(result.length).toBe(0);
|
||||
expect(() => decompressPayload(invalidGzip, 0x01)).toThrow(
|
||||
'Failed to decompress Cursor ConnectRPC frame.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on unknown ConnectRPC frame flags', () => {
|
||||
const payload = Buffer.from('payload');
|
||||
expect(() => decompressPayload(payload, 0x04)).toThrow(
|
||||
'Unsupported ConnectRPC frame flags: 0x04'
|
||||
);
|
||||
});
|
||||
|
||||
it('should decompress valid gzip payload', () => {
|
||||
@@ -1511,12 +1721,12 @@ describe('decompressPayload', () => {
|
||||
expect(result.toString()).toBe('Hello compressed world');
|
||||
});
|
||||
|
||||
it('should handle GZIP_ALT and GZIP_BOTH flags', () => {
|
||||
it('should not decompress plain end-stream trailers and should handle compressed end-stream trailers', () => {
|
||||
const zlib = require('zlib');
|
||||
const original = Buffer.from('test data');
|
||||
const compressed = zlib.gzipSync(original);
|
||||
|
||||
expect(decompressPayload(compressed, 0x02).toString()).toBe('test data');
|
||||
expect(decompressPayload(original, 0x02)).toEqual(original);
|
||||
expect(decompressPayload(compressed, 0x03).toString()).toBe('test data');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>((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');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -12,6 +12,12 @@ import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const hookPath = join(process.cwd(), 'lib', 'hooks', 'websearch-transformer.cjs');
|
||||
|
||||
/**
|
||||
* Neutralise CCS_PROFILE_TYPE so shouldSkipHook() does not short-circuit
|
||||
* when tests run inside a CCS-managed Claude session (where it is 'account').
|
||||
*/
|
||||
const NEUTRAL_PROFILE_TYPE = '';
|
||||
type HookOutput = {
|
||||
hookSpecificOutput: {
|
||||
additionalContext: string;
|
||||
@@ -56,6 +62,12 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
|
||||
results: Array<{ title: string; url: string; description: string }>
|
||||
) => string;
|
||||
parseRetryAfterSeconds: (rawValue: string) => number | null;
|
||||
trySearxngSearch: (query: string, timeoutSec?: number) => Promise<{
|
||||
content?: string;
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
success: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'failure') {
|
||||
@@ -101,6 +113,7 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_WEBSEARCH_ENABLED: '1',
|
||||
CCS_WEBSEARCH_SKIP: '0',
|
||||
CCS_WEBSEARCH_BRAVE: '0',
|
||||
@@ -109,6 +122,7 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail
|
||||
CCS_WEBSEARCH_GEMINI: '0',
|
||||
CCS_WEBSEARCH_GROK: '0',
|
||||
CCS_WEBSEARCH_OPENCODE: '0',
|
||||
CCS_WEBSEARCH_SEARXNG: '0',
|
||||
CCS_WEBSEARCH_TAVILY: '0',
|
||||
},
|
||||
});
|
||||
@@ -154,6 +168,123 @@ describe('websearch-transformer hook helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('queries SearXNG JSON endpoint and formats structured results', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalEnv = {
|
||||
CCS_WEBSEARCH_SEARXNG_MAX_RESULTS: process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS,
|
||||
CCS_WEBSEARCH_SEARXNG_URL: process.env.CCS_WEBSEARCH_SEARXNG_URL,
|
||||
};
|
||||
const requests: string[] = [];
|
||||
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/search/';
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = '3';
|
||||
global.fetch = async (url) => {
|
||||
requests.push(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
results: [
|
||||
{
|
||||
title: 'SearXNG Result',
|
||||
url: 'https://example.com/searxng',
|
||||
content: 'Result snippet',
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await hook.trySearxngSearch('btc price', 1);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('Provider: SearXNG');
|
||||
expect(result.content).toContain('URL: https://example.com/searxng');
|
||||
expect(requests).toEqual(['https://search.example.com/search?q=btc+price&format=json']);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
if (originalEnv.CCS_WEBSEARCH_SEARXNG_URL === undefined) {
|
||||
delete process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
} else {
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = originalEnv.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
}
|
||||
|
||||
if (originalEnv.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS === undefined) {
|
||||
delete process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS;
|
||||
} else {
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS =
|
||||
originalEnv.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects SearXNG URLs that include query parameters', async () => {
|
||||
const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/search?format=json';
|
||||
|
||||
try {
|
||||
const result = await hook.trySearxngSearch('btc price', 1);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('invalid or not configured');
|
||||
} finally {
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
} else {
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = originalUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects credential-bearing SearXNG URLs', async () => {
|
||||
const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://user:pass@search.example.com';
|
||||
|
||||
try {
|
||||
const result = await hook.trySearxngSearch('btc price', 1);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('invalid or not configured');
|
||||
} finally {
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
} else {
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = originalUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('marks SearXNG format-disabled 403 responses as non-retryable failures', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com';
|
||||
global.fetch = async () =>
|
||||
({
|
||||
ok: false,
|
||||
status: 403,
|
||||
headers: { get: () => null },
|
||||
text: async () => 'format=json disabled by this instance',
|
||||
}) as Response;
|
||||
|
||||
try {
|
||||
const result = await hook.trySearxngSearch('btc price', 1);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(403);
|
||||
expect(result.error).toContain('format=json is disabled');
|
||||
expect(hook.classifyProviderFailure(result)).toMatchObject({
|
||||
kind: 'fail',
|
||||
reason: 'non_retryable',
|
||||
});
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
} else {
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = originalUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => {
|
||||
const html = `
|
||||
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
|
||||
@@ -281,6 +412,85 @@ describe('websearch-transformer hook helpers', () => {
|
||||
expect(output).not.toHaveProperty('additionalContext');
|
||||
});
|
||||
|
||||
it('falls back from SearXNG parse errors to DuckDuckGo in provider order', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-searxng-fallback-'));
|
||||
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
||||
const requestLogPath = join(tempDir, 'requests.json');
|
||||
const html = `
|
||||
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fddg">Duck title</a>
|
||||
<a class="result__snippet">Duck snippet</a>
|
||||
`.trim();
|
||||
|
||||
writeFileSync(
|
||||
preloadPath,
|
||||
`
|
||||
const fs = require('fs');
|
||||
const requestLogPath = ${JSON.stringify(requestLogPath)};
|
||||
const html = ${JSON.stringify(html)};
|
||||
function record(url) {
|
||||
const requests = fs.existsSync(requestLogPath)
|
||||
? JSON.parse(fs.readFileSync(requestLogPath, 'utf8'))
|
||||
: [];
|
||||
requests.push(String(url));
|
||||
fs.writeFileSync(requestLogPath, JSON.stringify(requests), 'utf8');
|
||||
}
|
||||
global.fetch = async (url) => {
|
||||
const resolved = String(url);
|
||||
record(resolved);
|
||||
if (resolved.includes('/search?')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new Error('invalid json');
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
text: async () => html,
|
||||
};
|
||||
};
|
||||
`.trimStart(),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = spawnSync('node', ['-r', preloadPath, hookPath], {
|
||||
encoding: 'utf8',
|
||||
input: JSON.stringify({
|
||||
tool_name: 'WebSearch',
|
||||
tool_input: { query: 'btc price' },
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_WEBSEARCH_ENABLED: '1',
|
||||
CCS_WEBSEARCH_SKIP: '0',
|
||||
CCS_WEBSEARCH_BRAVE: '0',
|
||||
CCS_WEBSEARCH_DUCKDUCKGO: '1',
|
||||
CCS_WEBSEARCH_EXA: '0',
|
||||
CCS_WEBSEARCH_GEMINI: '0',
|
||||
CCS_WEBSEARCH_GROK: '0',
|
||||
CCS_WEBSEARCH_OPENCODE: '0',
|
||||
CCS_WEBSEARCH_SEARXNG: '1',
|
||||
CCS_WEBSEARCH_SEARXNG_URL: 'https://search.example.com',
|
||||
CCS_WEBSEARCH_TAVILY: '0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const output = JSON.parse(result.stdout.trim()) as HookOutput;
|
||||
expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo');
|
||||
|
||||
const requests = JSON.parse(readFileSync(requestLogPath, 'utf8')) as string[];
|
||||
expect(requests[0]).toContain('search.example.com/search?');
|
||||
expect(requests[1]).toContain('duckduckgo.com');
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves genuine DuckDuckGo zero-result pages as successful empty searches', () => {
|
||||
const result = runHookWithMockedFetch('empty');
|
||||
|
||||
@@ -352,6 +562,7 @@ describe('websearch-transformer hook helpers', () => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'hook-trace-test',
|
||||
@@ -428,6 +639,7 @@ describe('websearch-transformer hook helpers', () => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_FILE: disallowedTracePath,
|
||||
@@ -509,6 +721,7 @@ global.fetch = async (url) => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'quota-fallback-test',
|
||||
@@ -623,6 +836,7 @@ global.fetch = async (url) => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'cooldown-skip-test',
|
||||
@@ -723,6 +937,7 @@ global.fetch = async (url) => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'transient-retry-test',
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { CodexAdapter } from '../../../src/targets/codex-adapter';
|
||||
import {
|
||||
buildCodexBrowserMcpOverrides,
|
||||
getCodexBrowserMcpServerName,
|
||||
} from '../../../src/utils/browser-codex-overrides';
|
||||
|
||||
describe('CodexAdapter', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
@@ -21,6 +25,16 @@ describe('CodexAdapter', () => {
|
||||
).toEqual(['--search']);
|
||||
});
|
||||
|
||||
test('builds browser MCP overrides with Codex-safe defaults', () => {
|
||||
const serverName = getCodexBrowserMcpServerName();
|
||||
expect(buildCodexBrowserMcpOverrides()).toEqual([
|
||||
`mcp_servers.${serverName}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`,
|
||||
`mcp_servers.${serverName}.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
|
||||
`mcp_servers.${serverName}.enabled=true`,
|
||||
`mcp_servers.${serverName}.tool_timeout_sec=30`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('translates default-mode reasoning overrides into transient codex config', () => {
|
||||
const args = adapter.buildArgs('default', ['--search'], {
|
||||
profileType: 'default',
|
||||
@@ -40,6 +54,29 @@ describe('CodexAdapter', () => {
|
||||
expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']);
|
||||
});
|
||||
|
||||
test('injects runtime config overrides for native Codex default launches', () => {
|
||||
const runtimeConfigOverrides = buildCodexBrowserMcpOverrides();
|
||||
const args = adapter.buildArgs('default', ['--search'], {
|
||||
profileType: 'default',
|
||||
creds: {
|
||||
profile: 'default',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
runtimeConfigOverrides,
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
features: ['config-overrides'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
...runtimeConfigOverrides.flatMap((override) => ['-c', override]),
|
||||
'--search',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects default-mode reasoning overrides when codex lacks config override support', () => {
|
||||
expect(() =>
|
||||
adapter.buildArgs('default', ['--search'], {
|
||||
@@ -61,6 +98,7 @@ describe('CodexAdapter', () => {
|
||||
});
|
||||
|
||||
test('injects transient config overrides for CCS-backed launches', () => {
|
||||
const runtimeConfigOverrides = buildCodexBrowserMcpOverrides();
|
||||
const args = adapter.buildArgs('codex', ['--search'], {
|
||||
profileType: 'cliproxy',
|
||||
creds: {
|
||||
@@ -69,6 +107,7 @@ describe('CodexAdapter', () => {
|
||||
apiKey: 'cliproxy-token',
|
||||
model: 'gpt-5.4',
|
||||
reasoningOverride: 'high',
|
||||
runtimeConfigOverrides,
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
@@ -81,8 +120,9 @@ describe('CodexAdapter', () => {
|
||||
expect(args).toContain('model_provider="ccs_runtime"');
|
||||
expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"');
|
||||
expect(args).toContain('model="gpt-5.4"');
|
||||
expect(args).toContain(`mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`);
|
||||
expect(args).toContain('model_reasoning_effort="high"');
|
||||
expect(args.at(-1)).toBe('--search');
|
||||
expect(args[args.length - 1]).toBe('--search');
|
||||
});
|
||||
|
||||
test('fails fast when Codex binary lacks config override support', () => {
|
||||
|
||||
@@ -162,7 +162,7 @@ process.exit(0);
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does not preflight native Codex default launches when no runtime overrides are needed', () => {
|
||||
it('injects browser MCP runtime overrides for native Codex default launches', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
|
||||
@@ -177,10 +177,23 @@ process.exit(0);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
||||
expect(calls).toEqual([['fix failing tests']]);
|
||||
expect(calls).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
[
|
||||
'-c',
|
||||
`mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`,
|
||||
'-c',
|
||||
`mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.enabled=true',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.tool_timeout_sec=30',
|
||||
'fix failing tests',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores off-style CCS_THINKING env overrides for native Codex default mode', () => {
|
||||
it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
|
||||
@@ -195,7 +208,20 @@ process.exit(0);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
||||
expect(calls).toEqual([['fix failing tests']]);
|
||||
expect(calls).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
[
|
||||
'-c',
|
||||
`mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`,
|
||||
'-c',
|
||||
`mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.enabled=true',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.tool_timeout_sec=30',
|
||||
'fix failing tests',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
for (const versionFlag of ['--version', '-v']) {
|
||||
@@ -291,7 +317,19 @@ process.exit(0);
|
||||
expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
['-c', 'model_reasoning_effort="high"', 'fix failing tests'],
|
||||
[
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.command="npx"',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.enabled=true',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.tool_timeout_sec=30',
|
||||
'-c',
|
||||
'model_reasoning_effort="high"',
|
||||
'fix failing tests',
|
||||
],
|
||||
]);
|
||||
const loggedEnv = readLoggedCodexEnv(codexEnvLogPath);
|
||||
expect(loggedEnv).toHaveLength(2);
|
||||
@@ -481,7 +519,19 @@ process.exit(0);
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
['-c', 'model_reasoning_effort="high"', 'fix failing tests'],
|
||||
[
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.command="npx"',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.enabled=true',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.tool_timeout_sec=30',
|
||||
'-c',
|
||||
'model_reasoning_effort="high"',
|
||||
'fix failing tests',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ exit 0
|
||||
expect(argsLog).toContain('model_provider="ccs_runtime"');
|
||||
expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"');
|
||||
expect(argsLog).toContain('model_reasoning_effort="high"');
|
||||
expect(argsLog).toContain('mcp_servers.ccs_browser.command=');
|
||||
expect(argsLog).toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]');
|
||||
expect(argsLog).toContain('smoke');
|
||||
expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { request as httpRequest } from 'http';
|
||||
import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
async function waitForMockDevtoolsPort(portFilePath: string, timeoutMs = 5000): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
try {
|
||||
const port = fs.readFileSync(portFilePath, 'utf8').trim();
|
||||
if (/^\d+$/.test(port)) {
|
||||
return port;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for mock DevTools server port to become ready');
|
||||
}
|
||||
|
||||
async function waitForDevtoolsVersionEndpoint(port: string, timeoutMs = 5000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: Number.parseInt(port, 10),
|
||||
path: '/json/version',
|
||||
method: 'GET',
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Unexpected status: ${res.statusCode ?? 'unknown'}`));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for mock DevTools endpoint to become ready');
|
||||
}
|
||||
|
||||
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
|
||||
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
describe('default profile browser launch', () => {
|
||||
let tmpHome = '';
|
||||
let fakeClaudePath = '';
|
||||
let claudeArgsLogPath = '';
|
||||
let claudeEnvLogPath = '';
|
||||
let browserProfileDir = '';
|
||||
let devtoolsServer: ChildProcess | undefined;
|
||||
let baseEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-default-browser-launch-'));
|
||||
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
|
||||
claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt');
|
||||
claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt');
|
||||
browserProfileDir = path.join(tmpHome, 'chrome-user-data');
|
||||
|
||||
fs.writeFileSync(
|
||||
fakeClaudePath,
|
||||
`#!/bin/sh
|
||||
printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
||||
{
|
||||
printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR"
|
||||
printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST"
|
||||
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
|
||||
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
|
||||
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
|
||||
} > "${claudeEnvLogPath}"
|
||||
exit 0
|
||||
`,
|
||||
{ encoding: 'utf8', mode: 0o755 }
|
||||
);
|
||||
fs.chmodSync(fakeClaudePath, 0o755);
|
||||
|
||||
baseEnv = {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CLAUDE_PATH: fakeClaudePath,
|
||||
CCS_DEBUG: '1',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (devtoolsServer) {
|
||||
devtoolsServer.kill();
|
||||
devtoolsServer = undefined;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does not consume an empty mock DevTools port file before the port is written', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const delayedPortFile = path.join(tmpHome, 'delayed-port.txt');
|
||||
fs.writeFileSync(delayedPortFile, '', 'utf8');
|
||||
setTimeout(() => {
|
||||
fs.writeFileSync(delayedPortFile, '43123', 'utf8');
|
||||
}, 50);
|
||||
|
||||
await expect(waitForMockDevtoolsPort(delayedPortFile, 500)).resolves.toBe('43123');
|
||||
});
|
||||
|
||||
it('passes browser runtime env through default Claude launches when reuse is configured', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js');
|
||||
const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt');
|
||||
fs.writeFileSync(
|
||||
mockServerScriptPath,
|
||||
`const { createServer } = require('http');
|
||||
const fs = require('fs');
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === '/json/version') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/default-target' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('not found');
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8');
|
||||
});
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
devtoolsServer = spawn(process.execPath, [mockServerScriptPath], {
|
||||
stdio: 'ignore',
|
||||
env: baseEnv,
|
||||
});
|
||||
|
||||
const port = await waitForMockDevtoolsPort(mockServerPortPath);
|
||||
await waitForDevtoolsVersionEndpoint(port);
|
||||
|
||||
fs.mkdirSync(browserProfileDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(browserProfileDir, 'DevToolsActivePort'),
|
||||
`${port}\n/devtools/browser/default-target`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = runCcs(['default', 'smoke'], {
|
||||
...baseEnv,
|
||||
CCS_BROWSER_PROFILE_DIR: browserProfileDir,
|
||||
});
|
||||
|
||||
expect(result.stderr).not.toContain('Browser MCP is enabled, but CCS could not prepare the local browser tool.');
|
||||
expect(result.stderr).not.toContain('could not sync the browser MCP config');
|
||||
expect(result.stderr).not.toContain('Chrome reuse metadata not found');
|
||||
expect(result.status).toBe(0);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).toContain('--append-system-prompt');
|
||||
expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET);
|
||||
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
|
||||
expect(launchedEnv).toContain(`port=${port}`);
|
||||
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`);
|
||||
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
|
||||
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 8000,
|
||||
});
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
describe('settings profile browser launch', () => {
|
||||
let tmpHome = '';
|
||||
let ccsDir = '';
|
||||
let settingsPath = '';
|
||||
let fakeClaudePath = '';
|
||||
let claudeArgsLogPath = '';
|
||||
let claudeEnvLogPath = '';
|
||||
let browserProfileDir = '';
|
||||
let devtoolsServer: ChildProcess | undefined;
|
||||
let baseEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-launch-'));
|
||||
ccsDir = path.join(tmpHome, '.ccs');
|
||||
settingsPath = path.join(ccsDir, 'glm.settings.json');
|
||||
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
|
||||
claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt');
|
||||
claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt');
|
||||
browserProfileDir = path.join(tmpHome, 'chrome-user-data');
|
||||
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
['version: 12', 'websearch:', ' enabled: false', 'image_analysis:', ' enabled: false', ''].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
|
||||
ANTHROPIC_AUTH_TOKEN: 'token',
|
||||
ANTHROPIC_MODEL: 'glm-5',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
fakeClaudePath,
|
||||
`#!/bin/sh
|
||||
printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
||||
{
|
||||
printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR"
|
||||
printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST"
|
||||
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
|
||||
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
|
||||
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
|
||||
} > "${claudeEnvLogPath}"
|
||||
exit 0
|
||||
`,
|
||||
{ encoding: 'utf8', mode: 0o755 }
|
||||
);
|
||||
fs.chmodSync(fakeClaudePath, 0o755);
|
||||
|
||||
baseEnv = {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CLAUDE_PATH: fakeClaudePath,
|
||||
CCS_DEBUG: '1',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (devtoolsServer) {
|
||||
devtoolsServer.kill();
|
||||
devtoolsServer = undefined;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fails before Claude launch when browser reuse cannot resolve DevToolsActivePort', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.mkdirSync(browserProfileDir, { recursive: true });
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], {
|
||||
...baseEnv,
|
||||
CCS_BROWSER_PROFILE_DIR: browserProfileDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('DevToolsActivePort');
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('passes browser reuse env and steering prompt into the Claude launch', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js');
|
||||
const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt');
|
||||
fs.writeFileSync(
|
||||
mockServerScriptPath,
|
||||
`const { createServer } = require('http');
|
||||
const fs = require('fs');
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === '/json/version') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/browser-target' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('not found');
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8');
|
||||
});
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
devtoolsServer = spawn(process.execPath, [mockServerScriptPath], {
|
||||
stdio: 'ignore',
|
||||
env: baseEnv,
|
||||
});
|
||||
|
||||
const startDeadline = Date.now() + 5000;
|
||||
while (!fs.existsSync(mockServerPortPath)) {
|
||||
if (Date.now() > startDeadline) {
|
||||
throw new Error('Timed out waiting for mock DevTools server to start');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const port = fs.readFileSync(mockServerPortPath, 'utf8').trim();
|
||||
|
||||
fs.mkdirSync(browserProfileDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(browserProfileDir, 'DevToolsActivePort'),
|
||||
`${port}\n/devtools/browser/browser-target`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], {
|
||||
...baseEnv,
|
||||
CCS_BROWSER_PROFILE_DIR: browserProfileDir,
|
||||
});
|
||||
|
||||
expect(result.stderr).not.toContain('Browser MCP is enabled, but CCS could not prepare the local browser tool.');
|
||||
expect(result.stderr).not.toContain('could not sync the browser MCP config');
|
||||
expect(result.stderr).not.toContain('Chrome reuse metadata not found');
|
||||
expect(result.status).toBe(0);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).toContain('--append-system-prompt');
|
||||
expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET);
|
||||
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
|
||||
expect(launchedEnv).toContain(`port=${port}`);
|
||||
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`);
|
||||
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target');
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,49 @@ describe('ClaudeAdapter', () => {
|
||||
expect(env['ANTHROPIC_MODEL']).toBe('claude-opus-4-6');
|
||||
});
|
||||
|
||||
it('should pass through args unchanged', () => {
|
||||
it('should append browser steering prompt when browser runtime env exists', () => {
|
||||
const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose'], {
|
||||
creds: {
|
||||
profile: 'gemini',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'test-key',
|
||||
browserRuntimeEnv: {
|
||||
CCS_BROWSER_USER_DATA_DIR: '/tmp/chrome',
|
||||
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
|
||||
CCS_BROWSER_DEVTOOLS_PORT: '9222',
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222',
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
|
||||
},
|
||||
},
|
||||
profileType: 'settings',
|
||||
});
|
||||
|
||||
expect(args).toContain('--append-system-prompt');
|
||||
expect(args.join(' ')).toContain('prefer the CCS MCP Browser tool');
|
||||
});
|
||||
|
||||
it('should merge browser runtime env into Claude launch env', () => {
|
||||
const env = adapter.buildEnv(
|
||||
{
|
||||
profile: 'gemini',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'test-key',
|
||||
browserRuntimeEnv: {
|
||||
CCS_BROWSER_USER_DATA_DIR: '/tmp/chrome',
|
||||
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
|
||||
CCS_BROWSER_DEVTOOLS_PORT: '9222',
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222',
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
|
||||
},
|
||||
},
|
||||
'settings'
|
||||
);
|
||||
|
||||
expect(env['CCS_BROWSER_USER_DATA_DIR']).toBe('/tmp/chrome');
|
||||
expect(env['CCS_BROWSER_DEVTOOLS_WS_URL']).toBe('ws://127.0.0.1/devtools/browser/test');
|
||||
});
|
||||
|
||||
it('should pass through args unchanged when browser runtime env is absent', () => {
|
||||
const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose']);
|
||||
expect(args).toEqual(['-p', 'hello', '--verbose']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
resolveBrowserRuntimeEnv,
|
||||
resolveDefaultChromeUserDataDir,
|
||||
} from '../../../../src/utils/browser/chrome-reuse';
|
||||
|
||||
describe('chrome reuse resolver', () => {
|
||||
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||
let tempDirs: string[] = [];
|
||||
let servers: Array<{ stop: () => void }> = [];
|
||||
|
||||
function createTempDir(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeDevToolsActivePort(profileDir: string, content: string): void {
|
||||
fs.mkdirSync(profileDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(profileDir, 'DevToolsActivePort'), content, 'utf8');
|
||||
}
|
||||
|
||||
async function startDevToolsServer(versionPayload: Record<string, unknown>) {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request: Request) {
|
||||
if (new URL(request.url).pathname === '/json/version') {
|
||||
return Response.json(versionPayload);
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
servers.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
async function startFailingDevToolsServer(status: number, body = 'error') {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request: Request) {
|
||||
if (new URL(request.url).pathname === '/json/version') {
|
||||
return new Response(body, { status });
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
servers.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
async function startMalformedJsonDevToolsServer() {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request: Request) {
|
||||
if (new URL(request.url).pathname === '/json/version') {
|
||||
return new Response('{invalid-json', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
servers.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
async function reserveClosedPort(): Promise<number> {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response('ok');
|
||||
},
|
||||
});
|
||||
const port = server.port;
|
||||
server.stop(true);
|
||||
return port;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop(true);
|
||||
}
|
||||
servers = [];
|
||||
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs = [];
|
||||
|
||||
if (originalLocalAppData === undefined) {
|
||||
delete process.env.LOCALAPPDATA;
|
||||
} else {
|
||||
process.env.LOCALAPPDATA = originalLocalAppData;
|
||||
}
|
||||
});
|
||||
|
||||
it('uses explicit profile-dir before the default path and resolves the websocket target', async () => {
|
||||
const explicitProfileDir = createTempDir('ccs-chrome-explicit-');
|
||||
const defaultProfileDir = createTempDir('ccs-chrome-default-');
|
||||
const server = await startDevToolsServer({
|
||||
Browser: 'Chrome/136.0.0.0',
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/target-1',
|
||||
});
|
||||
|
||||
writeDevToolsActivePort(explicitProfileDir, `${server.port}\n/devtools/browser/from-explicit`);
|
||||
|
||||
const runtimeEnv = await resolveBrowserRuntimeEnv({
|
||||
profileDir: explicitProfileDir,
|
||||
});
|
||||
|
||||
expect(runtimeEnv).toEqual({
|
||||
CCS_BROWSER_USER_DATA_DIR: explicitProfileDir,
|
||||
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
|
||||
CCS_BROWSER_DEVTOOLS_PORT: String(server.port),
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${server.port}`,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/target-1',
|
||||
});
|
||||
expect(fs.existsSync(path.join(defaultProfileDir, 'DevToolsActivePort'))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses an explicit devtools port override when metadata is missing', async () => {
|
||||
const profileDir = createTempDir('ccs-chrome-explicit-port-');
|
||||
const server = await startDevToolsServer({
|
||||
Browser: 'Chrome/136.0.0.0',
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/explicit-port',
|
||||
});
|
||||
|
||||
const runtimeEnv = await resolveBrowserRuntimeEnv({
|
||||
profileDir,
|
||||
devtoolsPort: String(server.port),
|
||||
});
|
||||
|
||||
expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_PORT).toBe(String(server.port));
|
||||
expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_WS_URL).toBe(
|
||||
'ws://127.0.0.1/devtools/browser/explicit-port'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error when DevToolsActivePort metadata is missing', async () => {
|
||||
const profileDir = createTempDir('ccs-chrome-missing-metadata-');
|
||||
|
||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||
`Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error when DevToolsActivePort metadata is invalid', async () => {
|
||||
const profileDir = createTempDir('ccs-chrome-invalid-metadata-');
|
||||
writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target');
|
||||
|
||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||
`Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => {
|
||||
const profileDir = createTempDir('ccs-chrome-unreachable-');
|
||||
const port = await reserveClosedPort();
|
||||
writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`);
|
||||
|
||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error when the DevTools endpoint is reachable without a websocket target', async () => {
|
||||
const profileDir = createTempDir('ccs-chrome-missing-ws-');
|
||||
const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' });
|
||||
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`);
|
||||
|
||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||
`Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error when the DevTools endpoint returns a non-200 response', async () => {
|
||||
const profileDir = createTempDir('ccs-chrome-bad-status-');
|
||||
const server = await startFailingDevToolsServer(500);
|
||||
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`);
|
||||
|
||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error when the DevTools endpoint returns malformed JSON', async () => {
|
||||
const profileDir = createTempDir('ccs-chrome-bad-json-');
|
||||
const server = await startMalformedJsonDevToolsServer();
|
||||
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`);
|
||||
|
||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves platform default Chrome user-data-dir paths', () => {
|
||||
const isolatedHome = createTempDir('ccs-chrome-home-');
|
||||
const env = {
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
LOCALAPPDATA: 'C:/Users/test/AppData/Local',
|
||||
};
|
||||
|
||||
expect(resolveDefaultChromeUserDataDir('darwin', env)).toBe(
|
||||
path.join(isolatedHome, 'Library', 'Application Support', 'Google', 'Chrome')
|
||||
);
|
||||
expect(resolveDefaultChromeUserDataDir('linux', env)).toBe(
|
||||
path.join(isolatedHome, '.config', 'google-chrome')
|
||||
);
|
||||
expect(resolveDefaultChromeUserDataDir('win32', env)).toBe(
|
||||
path.normalize('C:/Users/test/AppData/Local/Google/Chrome/User Data')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on win32 when LOCALAPPDATA is missing', () => {
|
||||
delete process.env.LOCALAPPDATA;
|
||||
|
||||
expect(() => resolveDefaultChromeUserDataDir('win32')).toThrow(
|
||||
'LOCALAPPDATA is required to resolve the default Chrome user-data-dir on Windows'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error when the resolved profile directory does not exist', async () => {
|
||||
const missingProfileDir = path.join(
|
||||
createTempDir('ccs-chrome-missing-dir-'),
|
||||
'missing-profile'
|
||||
);
|
||||
|
||||
await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow(
|
||||
`Chrome profile directory is invalid: ${missingProfileDir}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { appendBrowserToolArgs } from '../../../../src/utils/browser/claude-tool-args';
|
||||
|
||||
const BROWSER_STEERING_PROMPT =
|
||||
'For DOM/screenshots/elements/page actions, prefer the CCS MCP Browser tool, reuse the configured running Chrome context whenever possible, and if the tool or context is unavailable, explain that clearly instead of pretending page state is available.';
|
||||
|
||||
describe('appendBrowserToolArgs', () => {
|
||||
it('appends the browser steering prompt when it is missing', () => {
|
||||
expect(appendBrowserToolArgs(['navigate'])).toEqual([
|
||||
'navigate',
|
||||
'--append-system-prompt',
|
||||
BROWSER_STEERING_PROMPT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not append the prompt when it already exists in equals form', () => {
|
||||
expect(
|
||||
appendBrowserToolArgs([
|
||||
'navigate',
|
||||
`--append-system-prompt=${BROWSER_STEERING_PROMPT}`,
|
||||
])
|
||||
).toEqual(['navigate', `--append-system-prompt=${BROWSER_STEERING_PROMPT}`]);
|
||||
});
|
||||
|
||||
it('does not append the prompt when it already exists in split-flag form', () => {
|
||||
expect(
|
||||
appendBrowserToolArgs([
|
||||
'navigate',
|
||||
'--append-system-prompt',
|
||||
BROWSER_STEERING_PROMPT,
|
||||
])
|
||||
).toEqual(['navigate', '--append-system-prompt', BROWSER_STEERING_PROMPT]);
|
||||
});
|
||||
|
||||
it('inserts the prompt before the end-of-options terminator', () => {
|
||||
expect(appendBrowserToolArgs(['--', 'take screenshot'])).toEqual([
|
||||
'--append-system-prompt',
|
||||
BROWSER_STEERING_PROMPT,
|
||||
'--',
|
||||
'take screenshot',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as browserInstaller from '../../../../src/utils/browser/mcp-installer';
|
||||
import {
|
||||
ensureBrowserMcp,
|
||||
ensureBrowserMcpConfig,
|
||||
ensureBrowserMcpOrThrow,
|
||||
getBrowserMcpServerName,
|
||||
getBrowserMcpServerPath,
|
||||
syncBrowserMcpToConfigDir,
|
||||
uninstallBrowserMcp,
|
||||
} from '../../../../src/utils/browser';
|
||||
|
||||
describe('ensureBrowserMcp', () => {
|
||||
let tempHome: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
function setupTempHome(): string {
|
||||
const nextTempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-mcp-'));
|
||||
tempHome = nextTempHome;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = nextTempHome;
|
||||
return nextTempHome;
|
||||
}
|
||||
|
||||
function getManagedConfig() {
|
||||
return {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: [getBrowserMcpServerPath()],
|
||||
env: {
|
||||
NODE_PATH: path.join(process.cwd(), 'node_modules'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
tempHome = undefined;
|
||||
originalCcsHome = undefined;
|
||||
});
|
||||
|
||||
it('installs the bundled browser MCP server and preserves existing user mcpServers entries', () => {
|
||||
setupTempHome();
|
||||
|
||||
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
||||
fs.writeFileSync(
|
||||
claudeUserConfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
existing: { command: 'uvx', args: ['some-server'] },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(ensureBrowserMcp()).toBe(true);
|
||||
expect(fs.existsSync(getBrowserMcpServerPath())).toBe(true);
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] });
|
||||
expect(config.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig());
|
||||
});
|
||||
|
||||
it('preserves the existing ~/.claude.json permissions when provisioning browser MCP', () => {
|
||||
setupTempHome();
|
||||
|
||||
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
||||
fs.writeFileSync(claudeUserConfigPath, JSON.stringify({ existing: true }, null, 2) + '\n', {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.chmodSync(claudeUserConfigPath, 0o600);
|
||||
|
||||
expect(ensureBrowserMcpConfig()).toBe(true);
|
||||
expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('copies the bundled browser MCP artifact while preserving source permissions', () => {
|
||||
setupTempHome();
|
||||
|
||||
const bundledServerPath = path.join(
|
||||
process.cwd(),
|
||||
'lib',
|
||||
'mcp',
|
||||
'ccs-browser-server.cjs'
|
||||
);
|
||||
const originalMode = fs.statSync(bundledServerPath).mode & 0o777;
|
||||
|
||||
expect(ensureBrowserMcp()).toBe(true);
|
||||
expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode);
|
||||
});
|
||||
|
||||
it('reconciles installed browser MCP permissions when contents already match', () => {
|
||||
setupTempHome();
|
||||
|
||||
const bundledServerPath = path.join(
|
||||
process.cwd(),
|
||||
'lib',
|
||||
'mcp',
|
||||
'ccs-browser-server.cjs'
|
||||
);
|
||||
const originalMode = fs.statSync(bundledServerPath).mode & 0o777;
|
||||
|
||||
expect(ensureBrowserMcp()).toBe(true);
|
||||
fs.chmodSync(getBrowserMcpServerPath(), 0o600);
|
||||
|
||||
expect(ensureBrowserMcp()).toBe(true);
|
||||
expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode);
|
||||
});
|
||||
|
||||
it('removes the managed browser runtime while preserving unrelated server entries', () => {
|
||||
setupTempHome();
|
||||
|
||||
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
||||
fs.writeFileSync(
|
||||
claudeUserConfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
existing: { command: 'uvx', args: ['some-server'] },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(ensureBrowserMcp()).toBe(true);
|
||||
|
||||
const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(instancePath, '.claude.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
existing: { command: 'uvx', args: ['instance-server'] },
|
||||
[getBrowserMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] },
|
||||
},
|
||||
otherKey: 'keep-me',
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(uninstallBrowserMcp()).toBe(true);
|
||||
expect(fs.existsSync(getBrowserMcpServerPath())).toBe(false);
|
||||
|
||||
const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
|
||||
mcpServers: Record<string, { command: string; args: string[] }>;
|
||||
};
|
||||
expect(globalConfig.mcpServers).toEqual({
|
||||
existing: { command: 'uvx', args: ['some-server'] },
|
||||
});
|
||||
|
||||
const instanceConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
|
||||
) as {
|
||||
otherKey: string;
|
||||
mcpServers: Record<string, { command: string; args: string[] }>;
|
||||
};
|
||||
expect(instanceConfig.otherKey).toBe('keep-me');
|
||||
expect(instanceConfig.mcpServers).toEqual({
|
||||
existing: { command: 'uvx', args: ['instance-server'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs the managed browser MCP entry into an instance config dir', () => {
|
||||
setupTempHome();
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tempHome as string, '.claude.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[getBrowserMcpServerName()]: getManagedConfig(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
|
||||
expect(syncBrowserMcpToConfigDir(instancePath)).toBe(true);
|
||||
|
||||
const instanceConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
|
||||
) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(instanceConfig.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig());
|
||||
});
|
||||
|
||||
it('throws when the bundled browser MCP runtime cannot be prepared', () => {
|
||||
setupTempHome();
|
||||
|
||||
const ensureSpy = spyOn(browserInstaller, 'ensureBrowserMcp').mockReturnValue(false);
|
||||
|
||||
expect(() => ensureBrowserMcpOrThrow()).toThrow(
|
||||
'Browser MCP is enabled, but CCS could not prepare the local browser tool.'
|
||||
);
|
||||
expect(ensureSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ let baselineSighupListeners: Array<(...args: unknown[]) => void> = [];
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsClaudePath: string | undefined;
|
||||
let originalDisableAutoUpdater: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
const realSpawn = childProcess.spawn.bind(childProcess);
|
||||
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
|
||||
const realExecSync = childProcess.execSync.bind(childProcess);
|
||||
@@ -124,6 +125,20 @@ preferences:
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8');
|
||||
}
|
||||
|
||||
function writeConfigWithWebSearchSettings(yamlBody: string): void {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-env-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const yaml = `version: 8
|
||||
preferences:
|
||||
auto_update: true
|
||||
websearch:
|
||||
${yamlBody}
|
||||
`;
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8');
|
||||
}
|
||||
|
||||
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
|
||||
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
||||
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
|
||||
@@ -151,10 +166,17 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
process.env.CCS_QUIET = '1';
|
||||
|
||||
// Save original env values for restoration in afterEach
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsClaudePath = process.env.CCS_CLAUDE_PATH;
|
||||
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
// Clear CCS-managed env vars that leak from host sessions
|
||||
delete process.env.DISABLE_AUTOUPDATER;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
baselineSigintListeners = process.listeners('SIGINT');
|
||||
baselineSigtermListeners = process.listeners('SIGTERM');
|
||||
baselineSighupListeners = process.listeners('SIGHUP');
|
||||
@@ -175,6 +197,8 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
} else {
|
||||
delete process.env.DISABLE_AUTOUPDATER;
|
||||
}
|
||||
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
else delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
for (const listener of process.listeners('SIGINT')) {
|
||||
if (!baselineSigintListeners.includes(listener)) {
|
||||
@@ -220,7 +244,7 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(env).toBeDefined();
|
||||
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
|
||||
expect(env.CCS_WEBSEARCH_SKIP).toBe('1');
|
||||
expect(env.CCS_WEBSEARCH_ENABLED || env.CCS_WEBSEARCH_SKIP).toBeDefined();
|
||||
});
|
||||
|
||||
it('execClaude keeps behavior when CLAUDECODE is absent', () => {
|
||||
@@ -262,6 +286,29 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
expect(env.DISABLE_AUTOUPDATER).toBeUndefined();
|
||||
});
|
||||
|
||||
it('execClaude overrides stale inherited WebSearch provider flags with config-derived values', () => {
|
||||
writeConfigWithWebSearchSettings(` enabled: true
|
||||
providers:
|
||||
duckduckgo:
|
||||
enabled: true
|
||||
searxng:
|
||||
enabled: false
|
||||
url: ''
|
||||
`);
|
||||
process.env.CCS_WEBSEARCH_SEARXNG = '1';
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com';
|
||||
process.env.CCS_WEBSEARCH_SKIP = '1';
|
||||
|
||||
execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'settings' });
|
||||
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(env.CCS_WEBSEARCH_ENABLED).toBe('1');
|
||||
expect(env.CCS_WEBSEARCH_SKIP).toBe('0');
|
||||
expect(env.CCS_WEBSEARCH_DUCKDUCKGO).toBe('1');
|
||||
expect(env.CCS_WEBSEARCH_SEARXNG).toBe('0');
|
||||
});
|
||||
|
||||
it('execClaude normalizes shared plugin metadata before default-profile launch', () => {
|
||||
const normalizeSpy = spyOn(
|
||||
SharedManager.prototype,
|
||||
|
||||
@@ -36,4 +36,47 @@ describe('appendThirdPartyImageAnalysisToolArgs', () => {
|
||||
'extra',
|
||||
]);
|
||||
});
|
||||
|
||||
// File mode: --append-system-prompt-file when user passes --append-system-prompt-file
|
||||
|
||||
it('uses --append-system-prompt-file when user passes --append-system-prompt-file', () => {
|
||||
const result = appendThirdPartyImageAnalysisToolArgs([
|
||||
'-p',
|
||||
'describe',
|
||||
'--append-system-prompt-file',
|
||||
'/tmp/user-prompt.txt',
|
||||
]);
|
||||
|
||||
expect(result).toContain('--append-system-prompt-file');
|
||||
expect(result).not.toContain('--append-system-prompt');
|
||||
const fileFlags = result.filter((arg) => arg === '--append-system-prompt-file');
|
||||
expect(fileFlags.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('uses --append-system-prompt-file when user passes equals form', () => {
|
||||
const result = appendThirdPartyImageAnalysisToolArgs([
|
||||
'-p',
|
||||
'describe',
|
||||
'--append-system-prompt-file=/tmp/user-prompt.txt',
|
||||
]);
|
||||
|
||||
expect(result).not.toContain('--append-system-prompt');
|
||||
const fileFlags = result.filter(
|
||||
(arg) => arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=')
|
||||
);
|
||||
expect(fileFlags.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('does not treat unrelated user prompt files as the managed CCS steering prompt', () => {
|
||||
const result = appendThirdPartyImageAnalysisToolArgs([
|
||||
'-p',
|
||||
'describe',
|
||||
'--append-system-prompt-file',
|
||||
'/tmp/user-ccs-prompt-image-analysis-tool-notes.txt',
|
||||
]);
|
||||
|
||||
const filePaths = result.filter((arg, index) => result[index - 1] === '--append-system-prompt-file');
|
||||
expect(filePaths).toContain('/tmp/user-ccs-prompt-image-analysis-tool-notes.txt');
|
||||
expect(filePaths.some((filePath) => filePath.endsWith('/ccs-prompt-image-analysis-tool.txt'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
buildInlineSteeringArg,
|
||||
buildFileSteeringArg,
|
||||
buildSteeringArg,
|
||||
detectPromptInjectionMode,
|
||||
getManagedPromptFilePath,
|
||||
hasManagedPromptFileArg,
|
||||
PROMPT_FLAG_INLINE,
|
||||
PROMPT_FLAG_FILE,
|
||||
} from '../../../src/utils/prompt-injection-strategy';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-prompt-strategy-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('detectPromptInjectionMode', () => {
|
||||
it('returns inline when no prompt flags present', () => {
|
||||
expect(detectPromptInjectionMode(['-p', 'hello'])).toBe('inline');
|
||||
});
|
||||
|
||||
it('returns inline when only --append-system-prompt is present', () => {
|
||||
expect(detectPromptInjectionMode(['--append-system-prompt', 'test'])).toBe('inline');
|
||||
});
|
||||
|
||||
it('returns inline when --append-system-prompt equals form is present', () => {
|
||||
expect(detectPromptInjectionMode(['--append-system-prompt=test'])).toBe('inline');
|
||||
});
|
||||
|
||||
it('returns file when --append-system-prompt-file is present', () => {
|
||||
expect(detectPromptInjectionMode(['--append-system-prompt-file', '/tmp/p.txt'])).toBe('file');
|
||||
});
|
||||
|
||||
it('returns file when --append-system-prompt-file equals form is present', () => {
|
||||
expect(detectPromptInjectionMode(['--append-system-prompt-file=/tmp/p.txt'])).toBe('file');
|
||||
});
|
||||
|
||||
it('returns file even when --append-system-prompt is also present', () => {
|
||||
expect(
|
||||
detectPromptInjectionMode([
|
||||
'--append-system-prompt',
|
||||
'inline-text',
|
||||
'--append-system-prompt-file',
|
||||
'/tmp/p.txt',
|
||||
])
|
||||
).toBe('file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInlineSteeringArg', () => {
|
||||
it('returns inline flag and prompt text', () => {
|
||||
expect(buildInlineSteeringArg({promptContent: 'hello world'})).toEqual(['--append-system-prompt', 'hello world']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFileSteeringArg', () => {
|
||||
it('returns file flag and writes the prompt into the isolated CCS home', () => {
|
||||
const result = buildFileSteeringArg({
|
||||
promptFileName: 'ccs-test-prompt.txt',
|
||||
promptContent: 'hello world',
|
||||
});
|
||||
|
||||
expect(result[0]).toBe('--append-system-prompt-file');
|
||||
expect(result[1]).toBe(path.join(tempHome, '.ccs', 'prompts', 'ccs-test-prompt.txt'));
|
||||
expect(fs.readFileSync(result[1], 'utf8')).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasManagedPromptFileArg', () => {
|
||||
it('returns true for the exact CCS-managed prompt path', () => {
|
||||
expect(
|
||||
hasManagedPromptFileArg({
|
||||
args: [PROMPT_FLAG_FILE, getManagedPromptFilePath('ccs-test')],
|
||||
promptName: 'ccs-test',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unrelated user files that only contain the prompt name', () => {
|
||||
expect(
|
||||
hasManagedPromptFileArg({
|
||||
args: [PROMPT_FLAG_FILE, '/tmp/user-ccs-test-notes.txt'],
|
||||
promptName: 'ccs-test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSteeringArg', () => {
|
||||
it('delegates to inline in inline mode', () => {
|
||||
expect(
|
||||
buildSteeringArg({
|
||||
args: [PROMPT_FLAG_INLINE],
|
||||
promptName: 'ignored.txt',
|
||||
promptContent: 'hello',
|
||||
})
|
||||
).toEqual(['--append-system-prompt', 'hello']);
|
||||
});
|
||||
|
||||
it('delegates to file in file mode', () => {
|
||||
const result = buildSteeringArg({
|
||||
args: [PROMPT_FLAG_FILE],
|
||||
promptName: 'ccs-test',
|
||||
promptContent: 'hello',
|
||||
});
|
||||
expect(result[0]).toBe('--append-system-prompt-file');
|
||||
expect(result[1]).toBe(getManagedPromptFilePath('ccs-test'));
|
||||
});
|
||||
});
|
||||
+52
-27
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ const STEERING_PROMPT =
|
||||
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
|
||||
|
||||
describe('appendThirdPartyWebSearchToolArgs', () => {
|
||||
it('appends native WebSearch suppression and steering prompt when no tool flags are present', () => {
|
||||
it('appends native WebSearch suppression and inline steering prompt when no prompt flags are present', () => {
|
||||
expect(appendThirdPartyWebSearchToolArgs(['smoke'])).toEqual([
|
||||
'smoke',
|
||||
'--disallowedTools',
|
||||
@@ -129,4 +129,44 @@ describe('appendThirdPartyWebSearchToolArgs', () => {
|
||||
STEERING_PROMPT,
|
||||
]);
|
||||
});
|
||||
|
||||
// File mode: --append-system-prompt-file when user passes --append-system-prompt-file
|
||||
|
||||
it('uses --append-system-prompt-file when user passes --append-system-prompt-file', () => {
|
||||
const result = appendThirdPartyWebSearchToolArgs([
|
||||
'smoke',
|
||||
'--append-system-prompt-file',
|
||||
'/tmp/user-prompt.txt',
|
||||
]);
|
||||
expect(result).toContain('--disallowedTools');
|
||||
expect(result).toContain('WebSearch');
|
||||
const fileFlags = result.filter((arg) => arg === '--append-system-prompt-file');
|
||||
expect(fileFlags.length).toBeGreaterThanOrEqual(2);
|
||||
// No inline flag should be present
|
||||
expect(result).not.toContain('--append-system-prompt');
|
||||
});
|
||||
|
||||
it('uses --append-system-prompt-file when user passes --append-system-prompt-file= form', () => {
|
||||
const result = appendThirdPartyWebSearchToolArgs([
|
||||
'smoke',
|
||||
'--append-system-prompt-file=/tmp/user-prompt.txt',
|
||||
]);
|
||||
const fileFlags = result.filter(
|
||||
(arg) => arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=')
|
||||
);
|
||||
expect(fileFlags.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result).not.toContain('--append-system-prompt');
|
||||
});
|
||||
|
||||
it('does not treat unrelated user prompt files as the managed CCS steering prompt', () => {
|
||||
const result = appendThirdPartyWebSearchToolArgs([
|
||||
'smoke',
|
||||
'--append-system-prompt-file',
|
||||
'/tmp/user-ccs-prompt-websearch-tool-notes.txt',
|
||||
]);
|
||||
|
||||
const filePaths = result.filter((arg, index) => result[index - 1] === '--append-system-prompt-file');
|
||||
expect(filePaths).toContain('/tmp/user-ccs-prompt-websearch-tool-notes.txt');
|
||||
expect(filePaths.some((filePath) => filePath.endsWith('/ccs-prompt-websearch-tool.txt'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,6 +93,136 @@ describe('websearch readiness', () => {
|
||||
expect(readiness.message).toContain('Exa');
|
||||
});
|
||||
|
||||
it('treats SearXNG as ready when enabled with a valid URL', () => {
|
||||
const readiness = buildWebSearchReadiness(true, [
|
||||
provider({
|
||||
id: 'searxng',
|
||||
name: 'SearXNG',
|
||||
enabled: true,
|
||||
available: true,
|
||||
detail: 'Configured (5 results)',
|
||||
}),
|
||||
provider({
|
||||
id: 'duckduckgo',
|
||||
name: 'DuckDuckGo',
|
||||
enabled: false,
|
||||
available: false,
|
||||
detail: 'Built-in (5 results)',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(readiness.readiness).toBe('ready');
|
||||
expect(readiness.message).toContain('SearXNG');
|
||||
});
|
||||
|
||||
it('marks SearXNG as unavailable when config uses a query-bearing endpoint URL', () => {
|
||||
const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
providers: {
|
||||
exa: { enabled: false, max_results: 5 },
|
||||
tavily: { enabled: false, max_results: 5 },
|
||||
brave: { enabled: false, max_results: 5 },
|
||||
searxng: {
|
||||
enabled: true,
|
||||
url: 'https://search.example.com/search?format=json',
|
||||
max_results: 5,
|
||||
},
|
||||
duckduckgo: { enabled: false, max_results: 5 },
|
||||
gemini: { enabled: false },
|
||||
grok: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
},
|
||||
} as any);
|
||||
const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({
|
||||
exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' },
|
||||
tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' },
|
||||
brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' },
|
||||
});
|
||||
const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
} as any);
|
||||
const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false);
|
||||
const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
} as any);
|
||||
const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
} as any);
|
||||
|
||||
try {
|
||||
const providers = getWebSearchCliProviders();
|
||||
const searxng = providers.find((entry) => entry.id === 'searxng');
|
||||
|
||||
expect(searxng?.enabled).toBe(true);
|
||||
expect(searxng?.available).toBe(false);
|
||||
expect(searxng?.detail).toContain('Set a valid SearXNG base URL');
|
||||
} finally {
|
||||
getConfigSpy.mockRestore();
|
||||
apiKeySpy.mockRestore();
|
||||
geminiStatusSpy.mockRestore();
|
||||
geminiAuthSpy.mockRestore();
|
||||
grokStatusSpy.mockRestore();
|
||||
opencodeStatusSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('marks SearXNG as unavailable when enabled with a blank URL', () => {
|
||||
const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
providers: {
|
||||
exa: { enabled: false, max_results: 5 },
|
||||
tavily: { enabled: false, max_results: 5 },
|
||||
brave: { enabled: false, max_results: 5 },
|
||||
searxng: {
|
||||
enabled: true,
|
||||
url: '',
|
||||
max_results: 5,
|
||||
},
|
||||
duckduckgo: { enabled: false, max_results: 5 },
|
||||
gemini: { enabled: false },
|
||||
grok: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
},
|
||||
} as any);
|
||||
const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({
|
||||
exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' },
|
||||
tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' },
|
||||
brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' },
|
||||
});
|
||||
const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
} as any);
|
||||
const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false);
|
||||
const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
} as any);
|
||||
const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
} as any);
|
||||
|
||||
try {
|
||||
const providers = getWebSearchCliProviders();
|
||||
const searxng = providers.find((entry) => entry.id === 'searxng');
|
||||
|
||||
expect(searxng?.enabled).toBe(true);
|
||||
expect(searxng?.available).toBe(false);
|
||||
expect(searxng?.detail).toContain('Set a valid SearXNG base URL');
|
||||
} finally {
|
||||
getConfigSpy.mockRestore();
|
||||
apiKeySpy.mockRestore();
|
||||
geminiStatusSpy.mockRestore();
|
||||
geminiAuthSpy.mockRestore();
|
||||
grokStatusSpy.mockRestore();
|
||||
opencodeStatusSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats cooled-down providers as temporarily unavailable in readiness status', () => {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-'));
|
||||
const statePath = join(tempHome, '.ccs', 'cache', 'websearch-provider-state.json');
|
||||
@@ -122,8 +252,9 @@ describe('websearch readiness', () => {
|
||||
providers: {
|
||||
exa: { enabled: true, max_results: 5 },
|
||||
tavily: { enabled: false, max_results: 5 },
|
||||
duckduckgo: { enabled: false, max_results: 5 },
|
||||
brave: { enabled: false, max_results: 5 },
|
||||
searxng: { enabled: false, url: '', max_results: 5 },
|
||||
duckduckgo: { enabled: false, max_results: 5 },
|
||||
gemini: { enabled: false },
|
||||
grok: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
|
||||
@@ -55,6 +55,15 @@ let getTokenStoragePath: () => string;
|
||||
let getDaemonStartPreconditionError: (
|
||||
input: { enabled: boolean; authenticated: boolean; tokenExpired?: boolean }
|
||||
) => { status: number; error: string } | null;
|
||||
let getAutoDetectFailureStatus: (
|
||||
reason?:
|
||||
| 'db_not_found'
|
||||
| 'sqlite_unavailable'
|
||||
| 'db_query_failed'
|
||||
| 'access_token_not_found'
|
||||
| 'machine_id_not_found'
|
||||
| 'invalid_token_format'
|
||||
) => number;
|
||||
|
||||
function seedCursorConfig(overrides: {
|
||||
enabled?: boolean;
|
||||
@@ -107,6 +116,7 @@ beforeAll(async () => {
|
||||
|
||||
const cursorRoutesModule = await import('../../../src/web-server/routes/cursor-routes');
|
||||
getDaemonStartPreconditionError = cursorRoutesModule.getDaemonStartPreconditionError;
|
||||
getAutoDetectFailureStatus = cursorRoutesModule.getAutoDetectFailureStatus;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -207,6 +217,10 @@ describe('Cursor Routes Logic', () => {
|
||||
});
|
||||
|
||||
describe('HTTP contracts', () => {
|
||||
it('maps invalid_token_format auto-detect failures to HTTP 400', () => {
|
||||
expect(getAutoDetectFailureStatus('invalid_token_format')).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /api/cursor/status returns current state', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/cursor/status`);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -280,9 +294,11 @@ describe('Cursor Routes Logic', () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const json = (await res.json()) as { error?: string };
|
||||
const json = (await res.json()) as { error?: string; reason?: string };
|
||||
expect(typeof json.error).toBe('string');
|
||||
expect(json.error?.length).toBeGreaterThan(0);
|
||||
expect(json.reason).toBe('db_not_found');
|
||||
expect(json.error).not.toContain(isolatedHome);
|
||||
} finally {
|
||||
if (originalHome !== undefined) {
|
||||
process.env.HOME = originalHome;
|
||||
@@ -344,6 +360,87 @@ describe('Cursor Routes Logic', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('POST /api/cursor/auth/auto-detect returns 503 when sqlite3 is unavailable', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalHome = process.env.HOME;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalSqliteBin = process.env.CCS_CURSOR_SQLITE_BIN;
|
||||
const fakeHome = path.join(tempDir, 'sqlite-missing-home');
|
||||
process.env.HOME = fakeHome;
|
||||
process.env.CCS_CURSOR_SQLITE_BIN = 'definitely-missing-sqlite3';
|
||||
|
||||
try {
|
||||
const dbPath = getTokenStoragePath();
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
fs.writeFileSync(dbPath, '');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
const json = (await res.json()) as { reason?: string; db_path?: string };
|
||||
expect(json.reason).toBe('sqlite_unavailable');
|
||||
expect(json.db_path).toBeUndefined();
|
||||
} finally {
|
||||
if (originalHome !== undefined) {
|
||||
process.env.HOME = originalHome;
|
||||
} else {
|
||||
delete process.env.HOME;
|
||||
}
|
||||
|
||||
if (originalPath !== undefined) {
|
||||
process.env.PATH = originalPath;
|
||||
} else {
|
||||
delete process.env.PATH;
|
||||
}
|
||||
if (originalSqliteBin !== undefined) {
|
||||
process.env.CCS_CURSOR_SQLITE_BIN = originalSqliteBin;
|
||||
} else {
|
||||
delete process.env.CCS_CURSOR_SQLITE_BIN;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('POST /api/cursor/auth/auto-detect returns 500 when the database exists but cannot be queried', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sqliteCheck = spawnSync('sqlite3', ['--version'], { stdio: 'ignore' });
|
||||
if (sqliteCheck.status !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalHome = process.env.HOME;
|
||||
const fakeHome = path.join(tempDir, 'query-failed-home');
|
||||
process.env.HOME = fakeHome;
|
||||
|
||||
try {
|
||||
const dbPath = getTokenStoragePath();
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
fs.writeFileSync(dbPath, 'not-a-sqlite-database');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const json = (await res.json()) as { reason?: string; db_path?: string };
|
||||
expect(json.reason).toBe('db_query_failed');
|
||||
expect(json.db_path).toBeUndefined();
|
||||
} finally {
|
||||
if (originalHome !== undefined) {
|
||||
process.env.HOME = originalHome;
|
||||
} else {
|
||||
delete process.env.HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('POST /api/cursor/daemon/start returns 400 when integration is disabled', async () => {
|
||||
seedCursorConfig({ enabled: false });
|
||||
seedCredentials(false);
|
||||
@@ -412,5 +509,28 @@ describe('Cursor Routes Logic', () => {
|
||||
expect(json.models.length).toBeGreaterThan(0);
|
||||
expect(json.current).toBe('gpt-5.3-codex');
|
||||
});
|
||||
|
||||
it('POST /api/cursor/probe returns auth failure when credentials are missing', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' });
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const json = (await res.json()) as { ok?: boolean; stage?: string; error_type?: string };
|
||||
expect(json.ok).toBe(false);
|
||||
expect(json.stage).toBe('auth');
|
||||
expect(json.error_type).toBe('authentication_error');
|
||||
});
|
||||
|
||||
it('POST /api/cursor/probe returns daemon failure when daemon is down and auto_start is false', async () => {
|
||||
seedCursorConfig({ enabled: true, auto_start: false });
|
||||
seedCredentials(false);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' });
|
||||
expect(res.status).toBe(503);
|
||||
|
||||
const json = (await res.json()) as { ok?: boolean; stage?: string; error_type?: string };
|
||||
expect(json.ok).toBe(false);
|
||||
expect(json.stage).toBe('daemon');
|
||||
expect(json.error_type).toBe('daemon_not_running');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,6 +288,113 @@ describe('websearch routes', () => {
|
||||
expect(config.global_env?.env.EXA_API_KEY).toBe('exa-secret-12345678');
|
||||
});
|
||||
|
||||
it('persists searxng provider settings and clamps max_results', async () => {
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
enabled: true,
|
||||
url: 'https://search.example.com',
|
||||
max_results: 99,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json();
|
||||
expect(payload.websearch.providers.searxng).toEqual({
|
||||
enabled: true,
|
||||
url: 'https://search.example.com',
|
||||
max_results: 10,
|
||||
});
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.websearch?.providers?.searxng).toEqual({
|
||||
enabled: true,
|
||||
url: 'https://search.example.com',
|
||||
max_results: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes searxng endpoint-style urls to the instance base URL', async () => {
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
enabled: true,
|
||||
url: 'https://search.example.com/custom/search/',
|
||||
max_results: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json();
|
||||
expect(payload.websearch.providers.searxng).toEqual({
|
||||
enabled: true,
|
||||
url: 'https://search.example.com/custom',
|
||||
max_results: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows clearing a searxng url back to blank', async () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.websearch = {
|
||||
enabled: true,
|
||||
providers: {
|
||||
...config.websearch?.providers,
|
||||
searxng: {
|
||||
enabled: false,
|
||||
url: 'https://search.example.com/custom',
|
||||
max_results: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
enabled: false,
|
||||
url: '',
|
||||
max_results: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json();
|
||||
expect(payload.websearch.providers.searxng).toEqual({
|
||||
enabled: false,
|
||||
url: '',
|
||||
max_results: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes credential-bearing searxng urls out of the GET response', async () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.websearch = {
|
||||
enabled: true,
|
||||
providers: {
|
||||
...config.websearch?.providers,
|
||||
searxng: {
|
||||
enabled: true,
|
||||
url: 'https://user:pass@search.example.com/search',
|
||||
max_results: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/websearch`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json();
|
||||
expect(payload.providers.searxng).toEqual({
|
||||
enabled: true,
|
||||
url: '',
|
||||
max_results: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-object request bodies', async () => {
|
||||
const response = await putWebsearch('[]');
|
||||
|
||||
@@ -357,4 +464,66 @@ describe('websearch routes', () => {
|
||||
expect(await response.json()).toEqual({ error: invalidPayload.error });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-string searxng url values', async () => {
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
url: 123,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Invalid value for providers.searxng.url. Must be a string.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects searxng urls with query parameters', async () => {
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
url: 'https://search.example.com/search?format=json',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error:
|
||||
'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects credential-bearing searxng urls', async () => {
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
url: 'https://user:pass@search.example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error:
|
||||
'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-number searxng max_results values', async () => {
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
max_results: '5',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Invalid value for providers.searxng.max_results. Must be a number.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
-1
@@ -42,7 +42,10 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
// Performance
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
|
||||
// Runtime type declarations
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
|
||||
@@ -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<SVGSVGElement>(null);
|
||||
const [hoveredAccount, setHoveredAccount] = useState<number | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showPausedAccounts, setShowPausedAccounts] = useState(true);
|
||||
const [showPausedAccounts, setShowPausedAccounts] = useState<boolean>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem(SHOW_PAUSED_STORAGE_KEY);
|
||||
return saved !== null ? saved === 'true' : true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const [paths, setPaths] = useState<string[]>([]);
|
||||
|
||||
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}
|
||||
|
||||
@@ -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 (
|
||||
<div className="text-xs space-y-1.5">
|
||||
@@ -317,28 +382,53 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
<span className="font-mono">{quota.creditBalance.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="font-medium">Buckets:</p>
|
||||
{quota.buckets.map((b) => (
|
||||
<div key={b.id} className="space-y-0.5">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className={cn(b.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||
{b.label}
|
||||
{b.tokenType ? ` (${b.tokenType})` : ''}
|
||||
</span>
|
||||
<span className="font-mono">{b.remainingPercent}%</span>
|
||||
</div>
|
||||
{((b.remainingAmount !== null && b.remainingAmount !== undefined) || b.resetTime) && (
|
||||
<div className="flex justify-between gap-4 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{b.remainingAmount !== null && b.remainingAmount !== undefined
|
||||
? `${b.remainingAmount.toLocaleString()} remaining`
|
||||
: ''}
|
||||
</span>
|
||||
<span>{formatAbsoluteResetTime(b.resetTime) ?? ''}</span>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Model quotas:</p>
|
||||
{sharedTokenType && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
All buckets report {sharedTokenType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{quota.buckets.map((bucket) => {
|
||||
const bucketTokenType = sharedTokenType ? null : formatGeminiTokenType(bucket.tokenType);
|
||||
const bucketModels = formatGeminiBucketModels(bucket.modelIds);
|
||||
const remainingAmountLabel = formatGeminiRemainingAmount(
|
||||
bucket.remainingAmount,
|
||||
bucket.tokenType
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={bucket.id} className="space-y-0.5">
|
||||
<div className="flex justify-between gap-4">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(bucket.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||
{formatGeminiBucketLabel(bucket.label)}
|
||||
</span>
|
||||
{bucketTokenType && (
|
||||
<span className="rounded border border-border/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{bucketTokenType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{bucketModels && (
|
||||
<div className="break-words text-[11px] text-muted-foreground">
|
||||
{bucketModels}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 font-mono">{bucket.remainingPercent}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(remainingAmountLabel || bucket.resetTime) && (
|
||||
<div className="flex justify-between gap-4 text-[11px] text-muted-foreground">
|
||||
<span>{remainingAmountLabel ?? ''}</span>
|
||||
<span>{formatAbsoluteResetTime(bucket.resetTime) ?? ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!hasBucketResetTime && <ResetTimeIndicator resetTime={resetTime} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<CursorProbeResult>;
|
||||
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<CursorStatus> {
|
||||
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<CursorProbeResult> {
|
||||
const res = await fetch(withApiBase('/cursor/probe'), { method: 'POST' });
|
||||
const payload = await res.json().catch(() => null);
|
||||
|
||||
if (isCursorProbeResult(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'runtime',
|
||||
status: res.status,
|
||||
duration_ms: 0,
|
||||
error_type: 'runtime_error',
|
||||
message: getProbeErrorMessage(payload) ?? 'Failed to run live probe',
|
||||
};
|
||||
}
|
||||
|
||||
export function useCursor() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -205,6 +252,14 @@ export function useCursor() {
|
||||
onSuccess: invalidateCursorQueries,
|
||||
});
|
||||
|
||||
const probeMutation = useMutation({
|
||||
mutationFn: probeCursorRuntime,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cursor-status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cursor-models'] });
|
||||
},
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
status: statusQuery.data,
|
||||
@@ -214,6 +269,7 @@ export function useCursor() {
|
||||
|
||||
config: configQuery.data,
|
||||
configLoading: configQuery.isLoading,
|
||||
refetchConfig: configQuery.refetch,
|
||||
|
||||
models: modelsQuery.data?.models ?? [],
|
||||
currentModel: modelsQuery.data?.current ?? null,
|
||||
@@ -248,6 +304,12 @@ export function useCursor() {
|
||||
stopDaemon: stopDaemonMutation.mutate,
|
||||
stopDaemonAsync: stopDaemonMutation.mutateAsync,
|
||||
isStoppingDaemon: stopDaemonMutation.isPending,
|
||||
|
||||
runProbe: probeMutation.mutate,
|
||||
runProbeAsync: probeMutation.mutateAsync,
|
||||
isRunningProbe: probeMutation.isPending,
|
||||
probeResult: probeMutation.data,
|
||||
resetProbe: probeMutation.reset,
|
||||
}),
|
||||
[
|
||||
statusQuery.data,
|
||||
@@ -256,6 +318,7 @@ export function useCursor() {
|
||||
statusQuery.refetch,
|
||||
configQuery.data,
|
||||
configQuery.isLoading,
|
||||
configQuery.refetch,
|
||||
modelsQuery.data,
|
||||
modelsQuery.isLoading,
|
||||
rawSettingsQuery.data,
|
||||
@@ -281,6 +344,11 @@ export function useCursor() {
|
||||
stopDaemonMutation.mutate,
|
||||
stopDaemonMutation.mutateAsync,
|
||||
stopDaemonMutation.isPending,
|
||||
probeMutation.mutate,
|
||||
probeMutation.mutateAsync,
|
||||
probeMutation.isPending,
|
||||
probeMutation.data,
|
||||
probeMutation.reset,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1325,6 +1325,23 @@ const resources = {
|
||||
rawChanged: 'Raw settings changed externally. Refresh and retry.',
|
||||
failedSaveRaw: 'Failed to save raw settings',
|
||||
savedAll: 'Cursor configuration saved',
|
||||
liveProbe: 'Live Probe',
|
||||
runLiveProbe: 'Run Live Probe',
|
||||
rerunLiveProbe: 'Re-run Live Probe',
|
||||
probing: 'Running probe...',
|
||||
probeNotRun: 'Probe not run yet',
|
||||
probeLocalReadinessHint:
|
||||
'Local readiness checks can pass while live runtime access still fails. Use the live probe to verify the full Cursor path.',
|
||||
probeStage: 'Stage',
|
||||
probeHttpStatus: 'HTTP Status',
|
||||
probeDuration: 'Duration',
|
||||
probeModel: 'Model',
|
||||
probeMessage: 'Message',
|
||||
probeSucceeded: 'Probe succeeded',
|
||||
probeFailed: 'Probe failed',
|
||||
probeSaveFirst: 'Save or discard Cursor changes before running the live probe',
|
||||
refreshStatus: 'Refresh Cursor status',
|
||||
refreshConfiguration: 'Refresh Cursor configuration',
|
||||
},
|
||||
droidPage: {
|
||||
loadingDiagnostics: 'Loading Droid diagnostics...',
|
||||
@@ -2658,6 +2675,23 @@ const resources = {
|
||||
rawChanged: '原始设置已被外部修改,请刷新后重试。',
|
||||
failedSaveRaw: '保存原始设置失败',
|
||||
savedAll: 'Cursor 配置已保存',
|
||||
liveProbe: '实时探测',
|
||||
runLiveProbe: '运行实时探测',
|
||||
rerunLiveProbe: '重新运行实时探测',
|
||||
probing: '正在运行探测...',
|
||||
probeNotRun: '尚未运行探测',
|
||||
probeLocalReadinessHint:
|
||||
'本地就绪检查可能通过,但实时运行链路仍可能失败。请使用实时探测验证完整的 Cursor 路径。',
|
||||
probeStage: '阶段',
|
||||
probeHttpStatus: 'HTTP 状态',
|
||||
probeDuration: '耗时',
|
||||
probeModel: '模型',
|
||||
probeMessage: '消息',
|
||||
probeSucceeded: '探测成功',
|
||||
probeFailed: '探测失败',
|
||||
probeSaveFirst: '请先保存或放弃 Cursor 更改,再运行实时探测',
|
||||
refreshStatus: '刷新 Cursor 状态',
|
||||
refreshConfiguration: '刷新 Cursor 配置',
|
||||
},
|
||||
droidPage: {
|
||||
loadingDiagnostics: '加载 Droid 诊断中...',
|
||||
@@ -4085,6 +4119,23 @@ const resources = {
|
||||
rawChanged: 'Cài đặt thô đã thay đổi bên ngoài. Làm mới và thử lại.',
|
||||
failedSaveRaw: 'Không lưu được cài đặt thô',
|
||||
savedAll: 'Đã lưu cấu hình Cursor',
|
||||
liveProbe: 'Kiểm tra trực tiếp',
|
||||
runLiveProbe: 'Chạy kiểm tra trực tiếp',
|
||||
rerunLiveProbe: 'Chạy lại kiểm tra trực tiếp',
|
||||
probing: 'Đang kiểm tra...',
|
||||
probeNotRun: 'Chưa chạy kiểm tra',
|
||||
probeLocalReadinessHint:
|
||||
'Các kiểm tra sẵn sàng cục bộ có thể đạt, nhưng luồng chạy thực tế vẫn có thể lỗi. Hãy dùng kiểm tra trực tiếp để xác minh toàn bộ đường đi của Cursor.',
|
||||
probeStage: 'Giai đoạn',
|
||||
probeHttpStatus: 'Trạng thái HTTP',
|
||||
probeDuration: 'Thời lượng',
|
||||
probeModel: 'Mô hình',
|
||||
probeMessage: 'Thông điệp',
|
||||
probeSucceeded: 'Kiểm tra thành công',
|
||||
probeFailed: 'Kiểm tra thất bại',
|
||||
probeSaveFirst: 'Hãy lưu hoặc bỏ các thay đổi Cursor trước khi chạy kiểm tra trực tiếp',
|
||||
refreshStatus: 'Làm mới trạng thái Cursor',
|
||||
refreshConfiguration: 'Làm mới cấu hình Cursor',
|
||||
},
|
||||
droidPage: {
|
||||
loadingDiagnostics: 'Đang tải chẩn đoán Droid...',
|
||||
@@ -5520,6 +5571,23 @@ const resources = {
|
||||
rawChanged: '生の設定が外部で変更されました。更新して再試行してください。',
|
||||
failedSaveRaw: '生の設定の保存に失敗しました',
|
||||
savedAll: 'Cursor設定を保存しました',
|
||||
liveProbe: 'ライブプローブ',
|
||||
runLiveProbe: 'ライブプローブを実行',
|
||||
rerunLiveProbe: 'ライブプローブを再実行',
|
||||
probing: 'プローブを実行中...',
|
||||
probeNotRun: 'まだプローブを実行していません',
|
||||
probeLocalReadinessHint:
|
||||
'ローカルの準備チェックが通っても、実際のランタイム経路は失敗する場合があります。ライブプローブで Cursor の完全な経路を確認してください。',
|
||||
probeStage: '段階',
|
||||
probeHttpStatus: 'HTTP ステータス',
|
||||
probeDuration: '所要時間',
|
||||
probeModel: 'モデル',
|
||||
probeMessage: 'メッセージ',
|
||||
probeSucceeded: 'プローブ成功',
|
||||
probeFailed: 'プローブ失敗',
|
||||
probeSaveFirst: 'ライブプローブを実行する前に Cursor の変更を保存するか破棄してください',
|
||||
refreshStatus: 'Cursor の状態を更新',
|
||||
refreshConfiguration: 'Cursor の設定を更新',
|
||||
},
|
||||
droidPage: {
|
||||
loadingDiagnostics: 'Droid診断を読み込み中...',
|
||||
|
||||
+177
-3
@@ -62,6 +62,32 @@ interface RawSettingsParseResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function buildProbeSnapshotKey(
|
||||
status?: {
|
||||
enabled?: boolean;
|
||||
authenticated?: boolean;
|
||||
token_expired?: boolean;
|
||||
daemon_running?: boolean;
|
||||
port?: number;
|
||||
ghost_mode?: boolean;
|
||||
},
|
||||
config?: {
|
||||
model?: string;
|
||||
auto_start?: boolean;
|
||||
}
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
enabled: status?.enabled ?? null,
|
||||
authenticated: status?.authenticated ?? null,
|
||||
token_expired: status?.token_expired ?? null,
|
||||
daemon_running: status?.daemon_running ?? null,
|
||||
port: status?.port ?? null,
|
||||
ghost_mode: status?.ghost_mode ?? null,
|
||||
auto_start: config?.auto_start ?? null,
|
||||
model: config?.model ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function buildConfigDraft(config?: {
|
||||
port?: number;
|
||||
auto_start?: boolean;
|
||||
@@ -266,6 +292,7 @@ export function CursorPage() {
|
||||
statusLoading,
|
||||
refetchStatus,
|
||||
config,
|
||||
refetchConfig,
|
||||
updateConfigAsync,
|
||||
isUpdatingConfig,
|
||||
models,
|
||||
@@ -284,6 +311,10 @@ export function CursorPage() {
|
||||
isStartingDaemon,
|
||||
stopDaemonAsync,
|
||||
isStoppingDaemon,
|
||||
runProbeAsync,
|
||||
isRunningProbe,
|
||||
probeResult,
|
||||
resetProbe,
|
||||
} = useCursor();
|
||||
|
||||
const [configDraft, setConfigDraft] = useState<CursorConfigDraft>(() => buildConfigDraft());
|
||||
@@ -293,6 +324,9 @@ export function CursorPage() {
|
||||
const [manualAuthOpen, setManualAuthOpen] = useState(false);
|
||||
const [manualToken, setManualToken] = useState('');
|
||||
const [manualMachineId, setManualMachineId] = useState('');
|
||||
const [probeSnapshotKey, setProbeSnapshotKey] = useState<string | null>(() =>
|
||||
probeResult ? buildProbeSnapshotKey(status, config) : null
|
||||
);
|
||||
|
||||
const pristineConfigDraft = buildConfigDraft(config);
|
||||
|
||||
@@ -318,6 +352,14 @@ export function CursorPage() {
|
||||
const isRawJsonValid = rawParseResult.isValid;
|
||||
const hasChanges = configDirty || rawConfigDirty;
|
||||
const canSave = !rawConfigDirty || (rawSettingsReady && isRawJsonValid);
|
||||
const currentProbeSnapshotKey = buildProbeSnapshotKey(status, config);
|
||||
const visibleProbeResult =
|
||||
probeResult &&
|
||||
!hasChanges &&
|
||||
probeSnapshotKey !== null &&
|
||||
probeSnapshotKey === currentProbeSnapshotKey
|
||||
? probeResult
|
||||
: null;
|
||||
const orderedModels = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const sorted = [...models].sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -349,6 +391,16 @@ export function CursorPage() {
|
||||
setConfigDirty(true);
|
||||
};
|
||||
|
||||
const clearProbeState = () => {
|
||||
resetProbe();
|
||||
setProbeSnapshotKey(null);
|
||||
};
|
||||
|
||||
const resetConfigDraft = (nextConfig = config) => {
|
||||
setConfigDraft(buildConfigDraft(nextConfig));
|
||||
setConfigDirty(false);
|
||||
};
|
||||
|
||||
const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired);
|
||||
const integrationBadge = useMemo(
|
||||
() =>
|
||||
@@ -395,6 +447,7 @@ export function CursorPage() {
|
||||
haiku_model: effectiveHaikuModel || undefined,
|
||||
})
|
||||
);
|
||||
clearProbeState();
|
||||
if (!suppressSuccessToast) {
|
||||
toast.success(t('cursorPage.savedConfig'));
|
||||
}
|
||||
@@ -500,6 +553,7 @@ export function CursorPage() {
|
||||
const handleToggleEnabled = async (enabled: boolean) => {
|
||||
try {
|
||||
await updateConfigAsync({ enabled });
|
||||
clearProbeState();
|
||||
toast.success(
|
||||
enabled ? t('cursorPage.integrationEnabled') : t('cursorPage.integrationDisabled')
|
||||
);
|
||||
@@ -511,6 +565,7 @@ export function CursorPage() {
|
||||
const handleAutoDetectAuth = async () => {
|
||||
try {
|
||||
await autoDetectAuthAsync();
|
||||
clearProbeState();
|
||||
toast.success(t('cursorPage.credentialsImported'));
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || t('cursorPage.autoDetectFailed'));
|
||||
@@ -528,6 +583,7 @@ export function CursorPage() {
|
||||
accessToken: manualToken.trim(),
|
||||
machineId: manualMachineId.trim(),
|
||||
});
|
||||
clearProbeState();
|
||||
toast.success(t('cursorPage.credentialsImported'));
|
||||
setManualAuthOpen(false);
|
||||
setManualToken('');
|
||||
@@ -544,6 +600,7 @@ export function CursorPage() {
|
||||
toast.error(result.error || t('cursorPage.failedStartDaemon'));
|
||||
return;
|
||||
}
|
||||
clearProbeState();
|
||||
toast.success(
|
||||
result.pid
|
||||
? t('cursorPage.daemonStartedWithPid', { pid: result.pid })
|
||||
@@ -561,12 +618,34 @@ export function CursorPage() {
|
||||
toast.error(result.error || t('cursorPage.failedStopDaemon'));
|
||||
return;
|
||||
}
|
||||
clearProbeState();
|
||||
toast.success(t('cursorPage.daemonStopped'));
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || t('cursorPage.failedStopDaemon'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunProbe = async () => {
|
||||
if (hasChanges) {
|
||||
toast.error(t('cursorPage.probeSaveFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runProbeAsync();
|
||||
const refreshedStatus = await refetchStatus();
|
||||
setProbeSnapshotKey(buildProbeSnapshotKey(refreshedStatus.data ?? status, config));
|
||||
if (result.ok) {
|
||||
toast.success(t('cursorPage.probeSucceeded'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.message || t('cursorPage.probeFailed'));
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || t('cursorPage.probeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRawSettings = async ({
|
||||
suppressSuccessToast = false,
|
||||
}: { suppressSuccessToast?: boolean } = {}) => {
|
||||
@@ -586,6 +665,7 @@ export function CursorPage() {
|
||||
expectedMtime: rawSettings?.mtime,
|
||||
});
|
||||
setRawConfigDirty(false);
|
||||
clearProbeState();
|
||||
if (!suppressSuccessToast) {
|
||||
toast.success(t('cursorPage.rawSaved'));
|
||||
}
|
||||
@@ -625,10 +705,15 @@ export function CursorPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeaderRefresh = () => {
|
||||
const handleHeaderRefresh = async () => {
|
||||
setRawConfigDirty(false);
|
||||
refetchStatus();
|
||||
refetchRawSettings();
|
||||
clearProbeState();
|
||||
const [, refreshedConfig] = await Promise.all([
|
||||
refetchStatus(),
|
||||
refetchConfig(),
|
||||
refetchRawSettings(),
|
||||
]);
|
||||
resetConfigDraft(refreshedConfig.data ?? config);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -658,6 +743,8 @@ export function CursorPage() {
|
||||
className="h-8 w-8"
|
||||
onClick={() => refetchStatus()}
|
||||
disabled={statusLoading}
|
||||
aria-label={t('cursorPage.refreshStatus')}
|
||||
title={t('cursorPage.refreshStatus')}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
@@ -710,6 +797,72 @@ export function CursorPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background/80 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Code2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('cursorPage.liveProbe')}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={visibleProbeResult ? 'outline' : 'secondary'}
|
||||
className={cn(
|
||||
visibleProbeResult?.ok &&
|
||||
'border-green-500/40 text-green-600 dark:text-green-300',
|
||||
visibleProbeResult &&
|
||||
!visibleProbeResult.ok &&
|
||||
'border-red-500/40 text-red-600 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{visibleProbeResult
|
||||
? visibleProbeResult.ok
|
||||
? t('cursorPage.probeSucceeded')
|
||||
: t('cursorPage.probeFailed')
|
||||
: t('cursorPage.probeNotRun')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{visibleProbeResult ? (
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t('cursorPage.probeStage')}</span>
|
||||
<span className="font-mono uppercase">{visibleProbeResult.stage}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{t('cursorPage.probeHttpStatus')}
|
||||
</span>
|
||||
<span className="font-mono">{visibleProbeResult.status}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t('cursorPage.probeDuration')}</span>
|
||||
<span className="font-mono">{visibleProbeResult.duration_ms} ms</span>
|
||||
</div>
|
||||
{visibleProbeResult.model ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t('cursorPage.probeModel')}</span>
|
||||
<span className="font-mono text-[11px] text-right break-all">
|
||||
{visibleProbeResult.model}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1 pt-1">
|
||||
<span className="text-muted-foreground">{t('cursorPage.probeMessage')}</span>
|
||||
<p className="text-[11px] leading-relaxed break-words">
|
||||
{visibleProbeResult.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t('cursorPage.probeNotRun')}</p>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('cursorPage.probeLocalReadinessHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('cursorPage.actions')}
|
||||
@@ -763,6 +916,25 @@ export function CursorPage() {
|
||||
{t('cursorPage.manualAuthImport')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleRunProbe}
|
||||
disabled={isRunningProbe}
|
||||
>
|
||||
{isRunningProbe ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Code2 className="w-3.5 h-3.5 mr-1.5" />
|
||||
)}
|
||||
{isRunningProbe
|
||||
? t('cursorPage.probing')
|
||||
: visibleProbeResult
|
||||
? t('cursorPage.rerunLiveProbe')
|
||||
: t('cursorPage.runLiveProbe')}
|
||||
</Button>
|
||||
|
||||
{status?.daemon_running ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -834,6 +1006,8 @@ export function CursorPage() {
|
||||
size="sm"
|
||||
onClick={handleHeaderRefresh}
|
||||
disabled={statusLoading || rawSettingsLoading}
|
||||
aria-label={t('cursorPage.refreshConfiguration')}
|
||||
title={t('cursorPage.refreshConfiguration')}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
|
||||
@@ -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,37 @@ 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:
|
||||
'Paste the instance base URL only. CCS appends /search?format=json for you and rejects embedded credentials.',
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import { AllProviders } from '../../setup/test-utils';
|
||||
import { useCursor } from '@/hooks/use-cursor';
|
||||
|
||||
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <AllProviders>{children}</AllProviders>;
|
||||
|
||||
describe('useCursor', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('preserves structured probe failures and refreshes live status queries', async () => {
|
||||
const probeFailure = {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
status: 503,
|
||||
duration_ms: 321,
|
||||
error_type: 'daemon_not_running',
|
||||
message: 'Cursor daemon is not running.',
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.endsWith('/api/cursor/status')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
auth_method: 'manual',
|
||||
token_age: 1,
|
||||
token_expired: false,
|
||||
daemon_running: false,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/settings')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
enabled: true,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
model: 'gpt-5.3-codex',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/models')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }],
|
||||
current: 'gpt-5.3-codex',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/settings/raw')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
settings: {},
|
||||
mtime: 100,
|
||||
path: '/tmp/cursor.settings.json',
|
||||
exists: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/probe') && init?.method === 'POST') {
|
||||
return Promise.resolve(createJsonResponse(probeFailure, 503));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useCursor(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.status?.enabled).toBe(true));
|
||||
await waitFor(() => expect(result.current.models.length).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
const probeResult = await result.current.runProbeAsync();
|
||||
expect(probeResult).toMatchObject(probeFailure);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.probeResult).toMatchObject(probeFailure));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/status'))
|
||||
.length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/models'))
|
||||
.length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useCursor: vi.fn(),
|
||||
runProbeAsync: vi.fn(),
|
||||
resetProbe: vi.fn(),
|
||||
refetchStatus: vi.fn(),
|
||||
refetchConfig: vi.fn(),
|
||||
refetchRawSettings: vi.fn(),
|
||||
updateConfigAsync: vi.fn(),
|
||||
saveRawSettingsAsync: vi.fn(),
|
||||
autoDetectAuthAsync: vi.fn(),
|
||||
importManualAuthAsync: vi.fn(),
|
||||
startDaemonAsync: vi.fn(),
|
||||
stopDaemonAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cursor', () => ({
|
||||
useCursor: mocks.useCursor,
|
||||
}));
|
||||
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: toastMocks,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/copilot/config-form/raw-editor-section', () => ({
|
||||
RawEditorSection: () => <div>Raw Editor</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/searchable-select', () => ({
|
||||
SearchableSelect: ({ value }: { value?: string }) => (
|
||||
<div data-testid="searchable-select">{value ?? 'select'}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { CursorPage } from '@/pages/cursor';
|
||||
|
||||
const probeFailure = {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
status: 503,
|
||||
duration_ms: 321,
|
||||
error_type: 'daemon_not_running',
|
||||
model: 'gpt-5.3-codex',
|
||||
message: 'Cursor daemon is not running.',
|
||||
};
|
||||
|
||||
function buildUseCursorResult(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
status: {
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
auth_method: 'manual',
|
||||
token_age: 1,
|
||||
token_expired: false,
|
||||
daemon_running: false,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
},
|
||||
statusLoading: false,
|
||||
refetchStatus: mocks.refetchStatus,
|
||||
config: {
|
||||
enabled: true,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
model: 'gpt-5.3-codex',
|
||||
opus_model: 'gpt-5.1-codex-max',
|
||||
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' }],
|
||||
modelsLoading: false,
|
||||
currentModel: 'gpt-5.3-codex',
|
||||
rawSettings: {
|
||||
settings: {},
|
||||
mtime: 100,
|
||||
path: '/tmp/cursor.settings.json',
|
||||
exists: true,
|
||||
},
|
||||
rawSettingsLoading: false,
|
||||
refetchRawSettings: mocks.refetchRawSettings,
|
||||
saveRawSettingsAsync: mocks.saveRawSettingsAsync,
|
||||
isSavingRawSettings: false,
|
||||
autoDetectAuthAsync: mocks.autoDetectAuthAsync,
|
||||
isAutoDetectingAuth: false,
|
||||
importManualAuthAsync: mocks.importManualAuthAsync,
|
||||
isImportingManualAuth: false,
|
||||
startDaemonAsync: mocks.startDaemonAsync,
|
||||
isStartingDaemon: false,
|
||||
stopDaemonAsync: mocks.stopDaemonAsync,
|
||||
isStoppingDaemon: false,
|
||||
runProbeAsync: mocks.runProbeAsync,
|
||||
isRunningProbe: false,
|
||||
probeResult: undefined,
|
||||
resetProbe: mocks.resetProbe,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
toastMocks.success.mockReset();
|
||||
});
|
||||
|
||||
it('renders persistent live probe results in the sidebar', async () => {
|
||||
mocks.useCursor.mockReturnValue(
|
||||
buildUseCursorResult({
|
||||
probeResult: probeFailure,
|
||||
})
|
||||
);
|
||||
|
||||
render(<CursorPage />);
|
||||
|
||||
expect(screen.getByText('Live Probe')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('Probe failed')).toBeInTheDocument());
|
||||
expect(screen.getByText('503')).toBeInTheDocument();
|
||||
expect(screen.getByText('321 ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cursor daemon is not running.')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-5.3-codex').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('runs the live probe from the sidebar action', async () => {
|
||||
render(<CursorPage />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Run Live Probe' }));
|
||||
|
||||
await waitFor(() => expect(mocks.runProbeAsync).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('blocks live probe runs while local edits are unsaved', async () => {
|
||||
render(<CursorPage />);
|
||||
|
||||
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: 'Run Live Probe' }));
|
||||
|
||||
expect(mocks.runProbeAsync).not.toHaveBeenCalled();
|
||||
expect(toastMocks.error).toHaveBeenCalledWith(
|
||||
'Save or discard Cursor changes before running the live probe'
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes config state with accessible refresh controls', async () => {
|
||||
render(<CursorPage />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user