Merge pull request #873 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-01 14:08:19 -04:00
commit b1077e07ae
87 files changed
+7404 -1191

No files matched your search

+10 -3
View File
@@ -147,6 +147,9 @@ The dashboard provides visual management for all account types:
**Ollama Integration**: Run local open-source models (qwen3-coder, gpt-oss:20b) with full privacy. Use `ccs api create --preset ollama` - requires [Ollama v0.14.0+](https://ollama.com) installed. For cloud models, use `ccs api create --preset ollama-cloud`.
> **Third-party WebSearch steering:** Claude-backed third-party launches keep Anthropic's native `WebSearch` disabled, provision `ccs-websearch.WebSearch` when the managed runtime is available, and append a short system hint so Claude prefers that managed tool over ad hoc Bash or `curl` lookups whenever current web information is needed.
> Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly.
> **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration.
**llama.cpp Integration**: Run a local llama.cpp OpenAI-compatible server and create a profile with `ccs api create --preset llamacpp`. CCS defaults to `http://127.0.0.1:8080`, matching the standard llama.cpp server port.
@@ -630,18 +633,18 @@ Without Developer Mode, CCS falls back to copying directories.
## WebSearch
Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS intercepts those requests and resolves them through real local search backends instead of depending on another model CLI to do the search.
Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS now provisions a first-class local `ccs-websearch` MCP tool when the managed runtime is available, disables native `WebSearch` on third-party launches, and steers Claude toward real local providers instead of surfacing a denied native-tool call.
### How It Works
| Profile Type | WebSearch Method |
|--------------|------------------|
| Claude (native) | Anthropic WebSearch API |
| Third-party profiles | Local Search Backend Chain |
| Third-party profiles | CCS local MCP `WebSearch` tool when available; otherwise Bash/network fallback |
### Local Search Backend Chain
CCS intercepts WebSearch requests and routes them through deterministic search providers:
For third-party profiles, CCS steers Claude toward the managed `ccs-websearch.WebSearch` MCP tool when it is available. The tool is intentionally named to match the native `WebSearch` concept, which helps Claude prefer it over ad hoc Bash or `curl` fetches, but Bash/network fallback can still happen if the tool is unavailable or ignored. When the tool is used, CCS routes that request through deterministic search providers in this order:
| Priority | Provider | Setup | Notes |
|----------|----------|-------|-------|
@@ -673,6 +676,10 @@ websearch:
> [!TIP]
> **DuckDuckGo** still works out of the box. Add **Exa**, **Tavily**, or **Brave Search** if you want API-backed results, then keep Gemini/OpenCode/Grok only if you explicitly want legacy fallback behavior.
> CCS manages the user-scope MCP entry in `~/.claude.json` and syncs it into isolated account configs when needed.
> [!NOTE]
> Set `CCS_WEBSEARCH_TRACE=1` to write correlated launch, MCP, provider, and headless summary records to `~/.ccs/logs/websearch-trace.jsonl`. That trace is designed to answer whether CCS exposed the managed tool, whether Claude called it, which provider won, and when a headless run likely bypassed it via `Bash` or `WebFetch`.
See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting.
+3 -2
View File
@@ -35,7 +35,7 @@ CCS provides:
3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families
4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API
5. **Visual Dashboard**: React SPA for configuration management
6. **Automatic WebSearch**: Real backend fallback chain for third-party providers
6. **Automatic WebSearch**: First-class local WebSearch tool with deterministic provider chain for third-party providers
7. **Usage Analytics**: Token tracking, cost analysis, model breakdown
8. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage
@@ -93,7 +93,8 @@ CCS provides:
- Validate symlinks and permissions
### FR-007: WebSearch Fallback
- Intercept WebSearch for third-party profiles that cannot reach Anthropic's native tool
- Expose a CCS-managed local WebSearch tool for third-party profiles that cannot reach Anthropic's native tool
- Suppress native `WebSearch` on third-party launches and steer Claude toward the CCS-owned path when it is available
- Support Exa, Tavily, Brave, and DuckDuckGo real search backends
- Keep Gemini CLI, OpenCode, and Grok as optional legacy fallback
- Graceful fallback chain
+2 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-03-28
Last Updated: 2026-03-30
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-03-30**: **#862** Third-party WebSearch now uses a first-class CCS-managed MCP tool path instead of relying on a denied native Anthropic `WebSearch` call as the normal UX. CCS provisions `ccs-websearch` into `~/.claude.json`, syncs it into isolated account configs when needed, suppresses native `WebSearch` on third-party launches, preserves the provider order `Exa -> Tavily -> Brave -> DuckDuckGo -> legacy CLI fallback`, and keeps the old hook runtime only as shared provider plumbing plus compatibility fallback. Uninstall cleanup now also removes the managed WebSearch MCP runtime.
- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, refresh/discard recovery for stale raw drafts, dirty raw-editor guarding for structured controls, project-trust path validation, read-only handling for unreadable config files, preservation of unsupported upstream values such as granular `approval_policy`, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file.
- **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow.
- **2026-03-27**: **#812** CCS now includes a first-class `ccs docker` command suite for self-hosting the integrated Dashboard + CLIProxy stack. The CLI can stage bundled Docker assets locally or to a remote `--host` over SSH, report compose/supervisor status, stream CCS or CLIProxy logs, and run in-container update flows without relying on ad-hoc deployment scripts.
+13 -1
View File
@@ -1,6 +1,6 @@
# Provider Integration Flows
Last Updated: 2026-02-16
Last Updated: 2026-03-30
Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication.
@@ -76,6 +76,18 @@ CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with
| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus |
| GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus |
### Codex Duplicate-Email Account Identity
Codex can legitimately produce multiple auth files for the same email when the user has both a team/business login and a personal/free login. CCS now treats those as separate accounts instead of collapsing them by email.
- Internal account IDs stay duplicate-aware for Codex only: `email#variant`
- Variant keys are derived from the auth filename, for example `kaidu.kd@gmail.com#04a0f049-team` and `kaidu.kd@gmail.com#free`
- Dashboard surfaces continue to show the canonical email, with a compact variant badge such as `Team` or `Free`
- Quota fetch resolves the exact registry `tokenFile` for the selected account instead of scanning by email and taking the first match
- Live usage/account monitor stats key by `provider + account identity`, so duplicate Codex emails no longer merge into one runtime bucket
This preserves the user-visible distinction between business and personal Codex sessions while keeping other providers on their existing email-backed identity model.
### Hardcoded Provider Detection
CCS detects hardcoded providers via `profile-detector.ts` and routes through `execClaudeWithCLIProxy()`.
+51 -15
View File
@@ -1,6 +1,6 @@
# WebSearch Configuration Guide
Last Updated: 2026-03-27
Last Updated: 2026-03-30
CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API.
@@ -12,7 +12,7 @@ Native Claude subscription accounts still use Anthropic's server-side WebSearch
### Third-Party Profiles
Third-party profiles cannot execute Anthropic's server-side WebSearch because the tool never reaches their backend. CCS now solves that by intercepting WebSearch and running real local search providers directly.
Third-party profiles cannot execute Anthropic's server-side WebSearch because the tool never reaches their backend. CCS now handles that by provisioning a first-class local MCP tool when the managed runtime is available, suppressing native `WebSearch` for those launches, appending a short launch-time steering hint, and running real local search providers directly.
## Architecture
@@ -20,30 +20,45 @@ Third-party profiles cannot execute Anthropic's server-side WebSearch because th
┌──────────────────────────────────────────────────────────────┐
│ Claude Code CLI │
│ │
│ WebSearch Tool Request │
│ Search Request │
│ │ │
│ ├── Native Claude Account? → Anthropic WebSearch API │
│ │ │
│ └── Third-party Profile? → PreToolUse Hook │
│ └── Third-party Profile? → native WebSearch disabled │
│ │ │
│ ├── 1. Exa Search API │
│ ├── 2. Tavily Search API │
│ ├── 3. Brave Search API │
│ ├── 4. DuckDuckGo HTML │
│ └── 5. Legacy CLI fallback │
│ (Gemini/OpenCode/Grok) │
│ ├── 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 │
└──────────────────────────────────────────────────────────────┘
```
## Why This Changed
The previous design asked another model CLI to perform web search and summarize the answer. That was brittle:
The previous design asked another model CLI to perform web search and summarize the answer. A later compatibility path also depended on a denied native-tool hook. Both were brittle:
- CLI syntax changed upstream
- auth state varied per tool
- prompt/tool behavior drifted across releases
- hook-shaped denial output produced awkward host UX
The new flow matches the `goclaw` model more closely: web search is treated as a first-class deterministic capability, not an LLM-to-LLM workaround.
The new flow matches the `goclaw` model more closely: web search is treated as a first-class deterministic capability, not an LLM-to-LLM workaround or a denied native tool call.
When provisioned, the managed MCP tool is exposed as `ccs-websearch.WebSearch`, not a generic `search` helper. That naming is deliberate: it gives Claude a tool that matches the native `WebSearch` concept more directly, which should reduce cases where the model reaches for ad hoc Bash or `curl` fetches instead.
CCS also appends a third-party-only `--append-system-prompt` hint telling Claude to prefer that managed `WebSearch` tool for web lookups and current-information requests. This is soft steering only: if the user explicitly asks for shell commands, or the tool is unavailable, Claude can still fall back to Bash/network tools.
That shared launch helper applies to normal third-party settings profiles, CLIProxy/Copilot-backed Claude launches, and CCS headless/delegation runs that execute through a settings profile.
`websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party profiles. That native tool cannot be satisfied by Exa, Tavily, Brave, DuckDuckGo, or other non-Anthropic backends, so CCS avoids sending a broken native-tool request and lets Claude fall back to normal shell/network tools instead.
## Providers
@@ -101,6 +116,8 @@ websearch:
timeout: 55
```
Note: `enabled: false` stops provisioning the managed local `ccs-websearch.WebSearch` runtime. It does not re-enable Anthropic's native `WebSearch` for third-party backends.
## Environment Variables
| Variable | Description |
@@ -109,8 +126,16 @@ websearch:
| `TAVILY_API_KEY` | Enables Tavily when `providers.tavily.enabled: true` |
| `BRAVE_API_KEY` | Enables Brave Search when `providers.brave.enabled: true` |
| `GROK_API_KEY` | Required only for legacy Grok CLI fallback |
| `CCS_WEBSEARCH_SKIP` | Skip hook entirely |
| `CCS_DEBUG` | Verbose hook logging |
| `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 |
| `CCS_WEBSEARCH_TRACE` | Write opt-in JSONL trace records under `~/.ccs/logs/websearch-trace.jsonl` |
| `CCS_WEBSEARCH_TRACE_FILE` | Override the trace file path (must stay inside `~/.ccs/`, your system temp directory, or `/var/log`) |
## Managed Runtime Files
- `~/.claude.json` → CCS manages `mcpServers.ccs-websearch`
- `~/.ccs/mcp/ccs-websearch-server.cjs` → local MCP server binary
- `~/.ccs/hooks/websearch-transformer.cjs` → shared provider runtime plus legacy compatibility fallback
## Troubleshooting
@@ -135,12 +160,23 @@ If the dashboard says the key is stored but still not ready, check whether `Sett
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.
### I need to see whether CCS exposed WebSearch or the model bypassed it
Run the launch with `CCS_WEBSEARCH_TRACE=1` (or `CCS_DEBUG=1`). CCS writes a JSONL trace to `~/.ccs/logs/websearch-trace.jsonl` with:
1. source-side launch records from CCS (`ccs_websearch_launch`)
2. MCP exposure and call records (`mcp_initialize`, `mcp_tools_list`, `mcp_tool_call_*`)
3. provider attempt and winner records (`websearch_provider_attempt`, `websearch_provider_success`)
4. session summaries (`mcp_session_summary`, and headless `headless_websearch_summary` when applicable)
Queries are fingerprinted (`queryHash`, `queryLength`) instead of logged raw by default. For headless/delegation runs, `headless_websearch_summary.likelyBypassed=true` means the MCP tool was exposed, no WebSearch call occurred, and Claude fell back to `Bash` or `WebFetch`.
### WebSearch returns no results
1. Check `websearch.enabled: true`
2. Keep DuckDuckGo enabled unless you have a strong reason to disable it
3. If using Exa, Tavily, or Brave, verify the matching API key
4. Run with `CCS_DEBUG=1` for hook logs
4. Run with `CCS_DEBUG=1` for runtime logs, or `CCS_WEBSEARCH_TRACE=1` for correlated launch/MCP/provider traces
## Security Considerations
+662 -98
View File
@@ -15,6 +15,10 @@
*/
const { spawnSync } = require('child_process');
const { createHash } = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const isWindows = process.platform === 'win32';
const DEFAULT_TIMEOUT_SEC = 55;
@@ -26,6 +30,13 @@ const DDG_URL = 'https://html.duckduckgo.com/html/';
const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search';
const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
const SHORT_RETRY_AFTER_MAX_SEC = 3;
const TRANSIENT_RETRY_DELAY_MS = 750;
const TRANSIENT_RETRY_ATTEMPTS = 1;
const DEFAULT_RATE_LIMIT_COOLDOWN_SEC = 120;
const DEFAULT_QUOTA_COOLDOWN_SEC = 900;
const MAX_PROVIDER_COOLDOWN_SEC = 60 * 60;
const SHARED_INSTRUCTIONS = `Instructions:
1. Search the web for current, up-to-date information
@@ -64,12 +75,246 @@ function debug(message) {
}
}
function shouldSkipHook() {
if (process.env.CCS_WEBSEARCH_SKIP === '1') return true;
function getCcsDirPath() {
if ((process.env.CCS_DIR || '').trim()) {
return path.resolve(process.env.CCS_DIR.trim());
}
if ((process.env.CCS_HOME || '').trim()) {
return path.join(path.resolve(process.env.CCS_HOME.trim()), '.ccs');
}
const home = (process.env.HOME || process.env.USERPROFILE || '').trim();
if (home) {
return path.join(home, '.ccs');
}
return path.join(process.cwd(), '.ccs');
}
function isTraceEnabled() {
return process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1';
}
function normalizeSafePrefix(inputPath) {
return `${path.resolve(inputPath)}${path.sep}`;
}
function getSafeTracePrefixes() {
return [
normalizeSafePrefix(path.join(getCcsDirPath(), 'logs')),
normalizeSafePrefix(os.tmpdir()),
normalizeSafePrefix('/var/log'),
];
}
function getProviderStatePath() {
return path.join(getCcsDirPath(), 'cache', PROVIDER_STATE_FILE);
}
function readProviderState() {
try {
const statePath = getProviderStatePath();
if (!fs.existsSync(statePath)) {
return { cooldowns: {} };
}
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
const cooldowns =
parsed && typeof parsed === 'object' && parsed.cooldowns && typeof parsed.cooldowns === 'object'
? parsed.cooldowns
: {};
return { cooldowns };
} catch {
return { cooldowns: {} };
}
}
function writeProviderState(state) {
try {
const statePath = getProviderStatePath();
fs.mkdirSync(path.dirname(statePath), { recursive: true });
const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(state, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, statePath);
} catch {
// Best-effort only.
}
}
function sanitizeProviderState(state) {
const now = Date.now();
const nextCooldowns = {};
let changed = false;
for (const [providerId, entry] of Object.entries(state.cooldowns || {})) {
if (!entry || typeof entry !== 'object') {
changed = true;
continue;
}
const until = Number.parseInt(String(entry.until || ''), 10);
if (!Number.isFinite(until) || until <= now) {
changed = true;
continue;
}
nextCooldowns[providerId] = {
until,
reason: typeof entry.reason === 'string' ? entry.reason : 'rate_limited',
updatedAt: Number.parseInt(String(entry.updatedAt || ''), 10) || now,
sourceError: typeof entry.sourceError === 'string' ? entry.sourceError : '',
};
}
return {
state: { cooldowns: nextCooldowns },
changed,
};
}
function getProviderCooldown(providerId) {
const { state, changed } = sanitizeProviderState(readProviderState());
if (changed) {
writeProviderState(state);
}
return state.cooldowns[providerId] || null;
}
function clearProviderCooldown(providerId) {
const { state } = sanitizeProviderState(readProviderState());
if (!(providerId in state.cooldowns)) {
return;
}
delete state.cooldowns[providerId];
writeProviderState(state);
}
function applyProviderCooldown(providerId, cooldownSec, reason, sourceError) {
const clampedCooldownSec = Math.max(
1,
Math.min(MAX_PROVIDER_COOLDOWN_SEC, Math.floor(cooldownSec))
);
const { state } = sanitizeProviderState(readProviderState());
const until = Date.now() + clampedCooldownSec * 1000;
state.cooldowns[providerId] = {
until,
reason,
updatedAt: Date.now(),
sourceError: sourceError || '',
};
writeProviderState(state);
return until;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getAllowedTraceFileOverride() {
const configured = (process.env.CCS_WEBSEARCH_TRACE_FILE || '').trim();
if (!configured) {
return null;
}
const resolved = path.resolve(configured);
if (getSafeTracePrefixes().some((prefix) => resolved.startsWith(prefix))) {
return resolved;
}
return null;
}
function getTraceFilePath() {
const fallback = path.join(getCcsDirPath(), 'logs', 'websearch-trace.jsonl');
return getAllowedTraceFileOverride() || fallback;
}
function traceWebSearchEvent(event, payload = {}) {
if (!isTraceEnabled()) {
return;
}
try {
const traceFilePath = getTraceFilePath();
fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
fs.appendFileSync(
traceFilePath,
JSON.stringify({
at: new Date().toISOString(),
event,
launchId: process.env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null,
launcher: process.env.CCS_WEBSEARCH_TRACE_LAUNCHER || null,
profileType: process.env.CCS_PROFILE_TYPE || null,
pid: process.pid,
...payload,
}) + '\n',
'utf8'
);
} catch {
// Best-effort only.
}
}
function readHeaderValue(headers, headerName) {
if (!headers) {
return '';
}
if (typeof headers.get === 'function') {
return headers.get(headerName) || '';
}
const direct = headers[headerName] ?? headers[String(headerName).toLowerCase()];
if (Array.isArray(direct)) {
return direct[0] || '';
}
return typeof direct === 'string' ? direct : '';
}
function parseRetryAfterSeconds(rawValue) {
const value = String(rawValue || '').trim();
if (!value) {
return null;
}
const asSeconds = Number.parseInt(value, 10);
if (Number.isFinite(asSeconds) && asSeconds > 0) {
return asSeconds;
}
const asDate = Date.parse(value);
if (Number.isFinite(asDate)) {
const deltaSec = Math.ceil((asDate - Date.now()) / 1000);
return deltaSec > 0 ? deltaSec : null;
}
return null;
}
function getQueryFingerprint(query) {
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
return {
queryHash: normalizedQuery
? createHash('sha256').update(normalizedQuery).digest('hex').slice(0, 16)
: null,
queryLength: normalizedQuery.length,
};
}
function getSkipReason() {
if (process.env.CCS_WEBSEARCH_SKIP === '1') return 'skip_flag';
const profileType = process.env.CCS_PROFILE_TYPE;
if (profileType === 'account' || profileType === 'default') return true;
if (process.env.CCS_WEBSEARCH_ENABLED === '0') return true;
return false;
if (profileType === 'account') return 'native_account_profile';
if (profileType === 'default') return 'native_default_profile';
if (process.env.CCS_WEBSEARCH_ENABLED === '0') return 'disabled';
return null;
}
function shouldSkipHook() {
return getSkipReason() !== null;
}
function isCliAvailable(cmd) {
@@ -183,23 +428,58 @@ function extractDuckDuckGoResults(html, count) {
}
function formatStructuredSearchResults(query, providerName, results) {
const lines = [
'CCS local WebSearch evidence',
`Provider: ${providerName}`,
`Query: "${query}"`,
`Result count: ${results.length}`,
'',
];
if (!results.length) {
return `No search results found for "${query}" via ${providerName}.`;
lines.push('No results found.');
return lines.join('\n');
}
const lines = [`Search results for "${query}" via ${providerName}:`, ''];
for (const [index, result] of results.entries()) {
lines.push(`${index + 1}. ${result.title}`);
lines.push(` ${result.url}`);
lines.push(` URL: ${result.url}`);
if (result.description) {
lines.push(` ${result.description}`);
lines.push(` Snippet: ${result.description}`);
}
lines.push('');
}
lines.push('Use these results to answer the user directly.');
return lines.join('\n');
}
function buildSuccessHookOutput(query, providerName, content) {
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: `CCS already retrieved WebSearch results locally via ${providerName}. Use the provided context instead of calling native WebSearch for "${query}".`,
additionalContext: content,
},
};
}
function buildFailureHookOutput(query, errors) {
const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: `CCS could not complete local WebSearch for "${query}". Native WebSearch is unavailable for this profile.`,
additionalContext: `CCS local WebSearch failed for "${query}". Attempted providers: ${detail}`,
},
};
}
function emitHookOutput(output) {
console.log(JSON.stringify(output));
process.exit(0);
}
async function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -239,6 +519,8 @@ async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
return {
success: false,
error: `Brave Search returned ${response.status}: ${body.slice(0, 160)}`,
statusCode: response.status,
retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
};
}
@@ -290,7 +572,12 @@ async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
if (!response.ok) {
const body = await response.text();
return { success: false, error: `Exa returned ${response.status}: ${body.slice(0, 160)}` };
return {
success: false,
error: `Exa returned ${response.status}: ${body.slice(0, 160)}`,
statusCode: response.status,
retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
};
}
const body = await response.json();
@@ -342,7 +629,12 @@ async function tryTavilySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
if (!response.ok) {
const body = await response.text();
return { success: false, error: `Tavily returned ${response.status}: ${body.slice(0, 160)}` };
return {
success: false,
error: `Tavily returned ${response.status}: ${body.slice(0, 160)}`,
statusCode: response.status,
retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
};
}
const body = await response.json();
@@ -379,7 +671,12 @@ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
);
if (!response.ok) {
return { success: false, error: `DuckDuckGo returned ${response.status}` };
return {
success: false,
error: `DuckDuckGo returned ${response.status}`,
statusCode: response.status,
retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
};
}
const html = await response.text();
@@ -534,39 +831,325 @@ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
}
function outputSuccess(query, content, providerName) {
const output = {
decision: 'block',
reason: `WebSearch handled via ${providerName}`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: `[WebSearch Result via ${providerName}]\n\nQuery: "${query}"\n\n${content}`,
},
};
console.log(JSON.stringify(output));
process.exit(2);
emitHookOutput(buildSuccessHookOutput(query, providerName, content));
}
function outputAllFailedMessage(query, errors) {
const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
const output = {
decision: 'block',
reason: 'WebSearch fallback failed',
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: `WebSearch could not be completed for "${query}". ${detail}`,
},
};
emitHookOutput(buildFailureHookOutput(query, errors));
}
console.log(JSON.stringify(output));
process.exit(2);
function getConfiguredProviders() {
return [
{
name: 'Exa',
id: 'exa',
available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')),
fn: tryExaSearch,
},
{
name: 'Tavily',
id: 'tavily',
available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')),
fn: tryTavilySearch,
},
{
name: 'Brave Search',
id: 'brave',
available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')),
fn: tryBraveSearch,
},
{
name: 'DuckDuckGo',
id: 'duckduckgo',
available: () => isProviderEnabled('duckduckgo'),
fn: tryDuckDuckGoSearch,
},
{
name: 'Gemini CLI',
id: 'gemini',
available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'),
fn: tryGeminiSearch,
},
{
name: 'OpenCode',
id: 'opencode',
available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'),
fn: tryOpenCodeSearch,
},
{
name: 'Grok CLI',
id: 'grok',
available: () => isProviderEnabled('grok') && isCliAvailable('grok'),
fn: tryGrokSearch,
},
];
}
function looksLikeQuotaExhaustion(errorMessage) {
const lower = String(errorMessage || '').toLowerCase();
return (
(lower.includes('quota') &&
(lower.includes('exceed') ||
lower.includes('exhaust') ||
lower.includes('deplet') ||
lower.includes('limit') ||
lower.includes('used up'))) ||
lower.includes('insufficient credits') ||
lower.includes('credit balance') ||
lower.includes('out of credits') ||
lower.includes('billing hard limit') ||
lower.includes('monthly usage cap')
);
}
function looksLikeTransientFailure(errorMessage) {
const lower = String(errorMessage || '').toLowerCase();
return (
lower.includes('timed out') ||
lower.includes('timeout') ||
lower.includes('temporarily unavailable') ||
lower.includes('service unavailable') ||
lower.includes('bad gateway') ||
lower.includes('gateway timeout') ||
lower.includes('socket hang up') ||
lower.includes('econnreset') ||
lower.includes('fetch failed') ||
lower.includes('network')
);
}
function classifyProviderFailure(result) {
const errorMessage = String(result.error || '');
const statusCode =
Number.isFinite(result.statusCode) && result.statusCode > 0 ? result.statusCode : null;
const retryAfterSec = Number.isFinite(result.retryAfterSec) ? result.retryAfterSec : null;
if (looksLikeQuotaExhaustion(errorMessage)) {
return {
kind: 'cooldown',
reason: 'quota_exhausted',
cooldownSec: retryAfterSec || DEFAULT_QUOTA_COOLDOWN_SEC,
retryAfterSec,
};
}
if (statusCode === 429 || /too many requests|rate limit/i.test(errorMessage)) {
if (retryAfterSec && retryAfterSec <= SHORT_RETRY_AFTER_MAX_SEC) {
return {
kind: 'retry',
delayMs: retryAfterSec * 1000,
reason: 'rate_limited_short_backoff',
retryAfterSec,
};
}
return {
kind: 'cooldown',
reason: 'rate_limited',
cooldownSec: retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC,
retryAfterSec,
};
}
if (
(statusCode && [502, 503, 504].includes(statusCode)) ||
looksLikeTransientFailure(errorMessage)
) {
return {
kind: 'retry',
delayMs: TRANSIENT_RETRY_DELAY_MS,
reason: 'transient_failure',
retryAfterSec,
};
}
return {
kind: 'fail',
reason: 'non_retryable',
retryAfterSec,
};
}
async function runProviderWithPolicy(provider, query, timeoutSec, fingerprint) {
for (let attempt = 0; attempt <= TRANSIENT_RETRY_ATTEMPTS; attempt += 1) {
traceWebSearchEvent('websearch_provider_attempt', {
source: 'provider',
providerId: provider.id,
providerName: provider.name,
attempt: attempt + 1,
...fingerprint,
});
const result = await provider.fn(query, timeoutSec);
if (result.success) {
clearProviderCooldown(provider.id);
return result;
}
const policy = classifyProviderFailure(result);
if (policy.kind === 'retry' && attempt < TRANSIENT_RETRY_ATTEMPTS) {
traceWebSearchEvent('websearch_provider_retry_scheduled', {
source: 'provider',
providerId: provider.id,
providerName: provider.name,
attempt: attempt + 1,
delayMs: policy.delayMs,
reason: policy.reason,
retryAfterSec: policy.retryAfterSec,
...fingerprint,
});
await sleep(policy.delayMs);
continue;
}
if (policy.kind === 'retry' && policy.reason === 'rate_limited_short_backoff') {
const cooldownSec = policy.retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC;
const until = applyProviderCooldown(provider.id, cooldownSec, 'rate_limited', result.error);
traceWebSearchEvent('websearch_provider_cooldown_applied', {
source: 'provider',
providerId: provider.id,
providerName: provider.name,
cooldownUntil: until,
cooldownSec,
reason: 'rate_limited',
retryAfterSec: policy.retryAfterSec,
afterRetryExhausted: true,
...fingerprint,
});
return {
...result,
error: `${result.error} (cooldown ${cooldownSec}s)`,
};
}
if (policy.kind === 'cooldown') {
const until = applyProviderCooldown(
provider.id,
policy.cooldownSec,
policy.reason,
result.error
);
traceWebSearchEvent('websearch_provider_cooldown_applied', {
source: 'provider',
providerId: provider.id,
providerName: provider.name,
cooldownUntil: until,
cooldownSec: policy.cooldownSec,
reason: policy.reason,
retryAfterSec: policy.retryAfterSec,
...fingerprint,
});
return {
...result,
error: `${result.error} (cooldown ${policy.cooldownSec}s)`,
};
}
return result;
}
return { success: false, error: 'Provider retry policy exhausted' };
}
function getActiveProviders() {
return getConfiguredProviders().filter((provider) => !getProviderCooldown(provider.id) && provider.available());
}
function getActiveProviderIds() {
return getActiveProviders().map((provider) => provider.id);
}
function hasAnyActiveProviders() {
return getActiveProviders().length > 0;
}
async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
const fingerprint = getQueryFingerprint(query);
const configuredProviders = getConfiguredProviders();
const activeProviders = [];
for (const provider of configuredProviders) {
const cooldown = getProviderCooldown(provider.id);
if (cooldown) {
traceWebSearchEvent('websearch_provider_cooldown_skip', {
source: 'provider',
providerId: provider.id,
providerName: provider.name,
cooldownUntil: cooldown.until,
cooldownReason: cooldown.reason,
remainingMs: Math.max(0, cooldown.until - Date.now()),
...fingerprint,
});
continue;
}
if (provider.available()) {
activeProviders.push(provider);
}
}
debug(
`Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
);
traceWebSearchEvent('websearch_provider_run_started', {
source: 'provider',
activeProviderIds: activeProviders.map((provider) => provider.id),
...fingerprint,
});
if (activeProviders.length === 0) {
traceWebSearchEvent('websearch_provider_run_unavailable', {
source: 'provider',
activeProviderIds: [],
...fingerprint,
});
return { success: false, noActiveProviders: true, errors: [] };
}
const errors = [];
for (const provider of activeProviders) {
debug(`Trying ${provider.name}`);
const result = await runProviderWithPolicy(provider, query, timeoutSec, fingerprint);
if (result.success) {
traceWebSearchEvent('websearch_provider_success', {
source: 'provider',
providerId: provider.id,
providerName: provider.name,
...fingerprint,
});
return {
success: true,
providerId: provider.id,
providerName: provider.name,
content: result.content,
};
}
traceWebSearchEvent('websearch_provider_failure', {
source: 'provider',
providerId: provider.id,
providerName: provider.name,
error: result.error,
...fingerprint,
});
errors.push({ provider: provider.name, error: result.error });
}
traceWebSearchEvent('websearch_provider_run_failed', {
source: 'provider',
errorCount: errors.length,
activeProviderIds: activeProviders.map((provider) => provider.id),
...fingerprint,
});
return { success: false, noActiveProviders: false, errors };
}
async function processHook(input) {
try {
if (shouldSkipHook()) {
traceWebSearchEvent('websearch_hook_skipped', {
source: 'hook',
reason: getSkipReason(),
});
process.exit(0);
}
@@ -580,78 +1163,47 @@ async function processHook(input) {
process.exit(0);
}
traceWebSearchEvent('websearch_hook_invoked', {
source: 'hook',
...getQueryFingerprint(query),
});
const timeout = Number.parseInt(
process.env.CCS_WEBSEARCH_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`,
10
);
const providers = [
{
name: 'Exa',
id: 'exa',
available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')),
fn: tryExaSearch,
},
{
name: 'Tavily',
id: 'tavily',
available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')),
fn: tryTavilySearch,
},
{
name: 'Brave Search',
id: 'brave',
available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')),
fn: tryBraveSearch,
},
{
name: 'DuckDuckGo',
id: 'duckduckgo',
available: () => isProviderEnabled('duckduckgo'),
fn: tryDuckDuckGoSearch,
},
{
name: 'Gemini CLI',
id: 'gemini',
available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'),
fn: tryGeminiSearch,
},
{
name: 'OpenCode',
id: 'opencode',
available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'),
fn: tryOpenCodeSearch,
},
{
name: 'Grok CLI',
id: 'grok',
available: () => isProviderEnabled('grok') && isCliAvailable('grok'),
fn: tryGrokSearch,
},
];
const activeProviders = providers.filter((provider) => provider.available());
debug(
`Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
);
if (activeProviders.length === 0) {
const result = await runLocalWebSearch(query, timeout);
if (result.noActiveProviders) {
traceWebSearchEvent('websearch_hook_no_active_providers', {
source: 'hook',
...getQueryFingerprint(query),
});
process.exit(0);
}
const errors = [];
for (const provider of activeProviders) {
debug(`Trying ${provider.name}`);
const result = await provider.fn(query, timeout);
if (result.success) {
outputSuccess(query, result.content, provider.name);
return;
}
errors.push({ provider: provider.name, error: result.error });
if (result.success) {
traceWebSearchEvent('websearch_hook_success', {
source: 'hook',
providerId: result.providerId,
providerName: result.providerName,
...getQueryFingerprint(query),
});
outputSuccess(query, result.content, result.providerName);
return;
}
outputAllFailedMessage(query, errors);
traceWebSearchEvent('websearch_hook_failure', {
source: 'hook',
errorCount: result.errors.length,
...getQueryFingerprint(query),
});
outputAllFailedMessage(query, result.errors);
} catch (error) {
debug(`Hook error: ${error.message}`);
traceWebSearchEvent('websearch_hook_error', {
source: 'hook',
error: error.message,
});
process.exit(0);
}
}
@@ -675,8 +1227,20 @@ if (require.main === module) {
}
module.exports = {
buildFailureHookOutput,
buildSuccessHookOutput,
extractDuckDuckGoResults,
formatStructuredSearchResults,
getActiveProviders,
hasAnyActiveProviders,
runLocalWebSearch,
shouldSkipHook,
getActiveProviderIds,
classifyProviderFailure,
getQueryFingerprint,
getSkipReason,
parseRetryAfterSeconds,
traceWebSearchEvent,
tryExaSearch,
tryTavilySearch,
tryDuckDuckGoSearch,
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env node
const {
getActiveProviderIds,
getQueryFingerprint,
getSkipReason,
hasAnyActiveProviders,
runLocalWebSearch,
shouldSkipHook,
traceWebSearchEvent,
} = require('../hooks/websearch-transformer.cjs');
const PROTOCOL_VERSION = '2024-11-05';
const SERVER_NAME = 'ccs-websearch';
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.';
function isSupportedToolName(name) {
return name === TOOL_NAME || TOOL_ALIASES.includes(name);
}
let inputBuffer = Buffer.alloc(0);
const sessionState = {
initializeCount: 0,
toolsListCount: 0,
exposed: false,
toolCalls: 0,
};
let sessionSummaryWritten = false;
function shouldExposeTools() {
return !shouldSkipHook() && hasAnyActiveProviders();
}
function getTools() {
if (!shouldExposeTools()) {
return [];
}
return [
{
name: TOOL_NAME,
description: TOOL_DESCRIPTION,
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'Web query to resolve through CCS providers. Prefer this tool over ad hoc Bash/curl lookups when you need current web information.',
},
},
required: ['query'],
additionalProperties: false,
},
},
];
}
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,
},
});
}
async function handleToolCall(message) {
const id = message.id;
const params = message.params || {};
const toolArgs = params.arguments || {};
const toolName = params.name || '<missing>';
const query = typeof toolArgs.query === 'string' ? toolArgs.query.trim() : '';
const fingerprint = getQueryFingerprint(query);
if (!isSupportedToolName(toolName)) {
traceWebSearchEvent('mcp_tool_call_rejected', {
source: 'mcp',
reason: 'unknown_tool',
toolName,
});
writeError(id, -32602, `Unknown tool: ${toolName}`);
return;
}
sessionState.toolCalls += 1;
traceWebSearchEvent('mcp_tool_call_received', {
source: 'mcp',
toolName,
...fingerprint,
});
if (!shouldExposeTools()) {
traceWebSearchEvent('mcp_tool_call_unavailable', {
source: 'mcp',
toolName,
exposed: false,
skipReason: getSkipReason(),
activeProviderIds: getActiveProviderIds(),
...fingerprint,
});
writeResponse(id, {
content: [
{
type: 'text',
text: 'CCS WebSearch is unavailable for this profile or no providers are ready.',
},
],
isError: true,
});
return;
}
if (!query) {
traceWebSearchEvent('mcp_tool_call_rejected', {
source: 'mcp',
reason: 'empty_query',
toolName,
});
writeError(id, -32602, `Tool "${TOOL_NAME}" requires a non-empty string query.`);
return;
}
const result = await runLocalWebSearch(query);
if (result.success) {
traceWebSearchEvent('mcp_tool_call_result', {
source: 'mcp',
toolName,
success: true,
providerId: result.providerId,
providerName: result.providerName,
...fingerprint,
});
writeResponse(id, {
content: [{ type: 'text', text: result.content }],
});
return;
}
traceWebSearchEvent('mcp_tool_call_result', {
source: 'mcp',
toolName,
success: false,
noActiveProviders: Boolean(result.noActiveProviders),
errorCount: result.errors.length,
...fingerprint,
});
const errorDetail =
result.noActiveProviders || result.errors.length === 0
? 'No active WebSearch providers are ready.'
: result.errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
writeResponse(id, {
content: [
{
type: 'text',
text: `CCS local WebSearch failed for "${query}". ${errorDetail}`,
},
],
isError: true,
});
}
async function handleMessage(message) {
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
return;
}
switch (message.method) {
case 'initialize':
sessionState.initializeCount += 1;
sessionState.exposed = sessionState.exposed || shouldExposeTools();
traceWebSearchEvent('mcp_initialize', {
source: 'mcp',
exposed: shouldExposeTools(),
skipReason: getSkipReason(),
activeProviderIds: getActiveProviderIds(),
});
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':
sessionState.toolsListCount += 1;
{
const tools = getTools();
const exposed = tools.length > 0;
sessionState.exposed = sessionState.exposed || exposed;
traceWebSearchEvent('mcp_tools_list', {
source: 'mcp',
exposed,
toolNames: tools.map((tool) => tool.name),
activeProviderIds: getActiveProviderIds(),
skipReason: getSkipReason(),
});
writeResponse(message.id, { tools });
}
return;
case 'tools/call':
await handleToolCall(message);
return;
default:
if (message.id !== undefined) {
writeError(message.id, -32601, `Method not found: ${message.method}`);
}
}
}
function writeSessionSummary(exitCodeOrSignal) {
if (sessionSummaryWritten) {
return;
}
sessionSummaryWritten = true;
traceWebSearchEvent('mcp_session_summary', {
source: 'mcp',
initializeCount: sessionState.initializeCount,
toolsListCount: sessionState.toolsListCount,
exposed: sessionState.exposed,
toolCalls: sessionState.toolCalls,
calledWebSearch: sessionState.toolCalls > 0,
likelyBypassed: sessionState.exposed && sessionState.toolCalls === 0 ? 'unknown' : false,
activeProviderIds: getActiveProviderIds(),
skipReason: getSkipReason(),
exitCode: typeof exitCodeOrSignal === 'number' ? exitCodeOrSignal : null,
exitSignal: typeof exitCodeOrSignal === 'string' ? exitCodeOrSignal : null,
});
}
function parseMessages() {
while (true) {
let body;
const startsWithLegacyHeaders = inputBuffer
.slice(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.slice(0, headerEnd).toString('utf8');
const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i);
if (!contentLengthMatch) {
inputBuffer = Buffer.alloc(0);
return;
}
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
const messageEnd = headerEnd + 4 + contentLength;
if (inputBuffer.length < messageEnd) {
return;
}
body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8');
inputBuffer = inputBuffer.slice(messageEnd);
} else {
const newlineIndex = inputBuffer.indexOf('\n');
if (newlineIndex === -1) {
return;
}
body = inputBuffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
inputBuffer = inputBuffer.slice(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);
});
process.on('exit', (code) => {
writeSessionSummary(code);
});
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) => {
process.on(signal, () => {
writeSessionSummary(signal);
process.exit(0);
});
});
process.stdin.resume();
@@ -10,7 +10,7 @@ import type { Config, Settings } from '../../types';
import type { TargetType } from '../../targets/target-adapter';
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { isSensitiveKey } from '../../utils/sensitive-keys';
import { isReservedName } from '../../config/reserved-names';
import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader';
@@ -218,7 +218,7 @@ export function registerApiProfileOrphans(options?: {
try {
if (orphan.validation.valid) {
ensureProfileHooksOrThrow(orphan.name);
ensureWebSearchMcpOrThrow();
}
registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false);
result.registered.push(orphan.name);
@@ -268,7 +268,7 @@ export function copyApiProfile(
writeJsonObjectAtomically(destinationSettingsPath, sourceSettings);
try {
ensureProfileHooksOrThrow(destination);
ensureWebSearchMcpOrThrow();
} catch (hookError) {
rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted);
throw hookError;
@@ -393,7 +393,7 @@ export function importApiProfileBundle(
writeJsonObjectAtomically(settingsPath, settings);
try {
ensureProfileHooksOrThrow(name);
ensureWebSearchMcpOrThrow();
} catch (hookError) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw hookError;
+3 -5
View File
@@ -8,7 +8,7 @@ import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-man
import { expandPath } from '../../utils/helpers';
import { validateApiName } from './validation-service';
import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import type { TargetType } from '../../targets/target-adapter';
import { resolveDroidProvider } from '../../targets/droid-provider';
import { isReservedName } from '../../config/reserved-names';
@@ -126,8 +126,7 @@ function createSettingsFile(
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
try {
// Inject WebSearch hooks into profile settings
ensureProfileHooksOrThrow(name);
ensureWebSearchMcpOrThrow();
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
@@ -216,8 +215,7 @@ function createApiProfileUnified(
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
try {
// Inject WebSearch hooks into profile settings
ensureProfileHooksOrThrow(name);
ensureWebSearchMcpOrThrow();
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
+25 -12
View File
@@ -24,10 +24,12 @@ import {
import { getEffectiveEnvVars, getCompositeEnvVars } from './cliproxy/config/env-builder';
import { CLIPROXY_DEFAULT_PORT } from './cliproxy/config/port-manager';
import {
ensureMcpWebSearch,
ensureWebSearchMcpOrThrow,
displayWebSearchStatus,
getWebSearchHookEnv,
ensureProfileHooksOrThrow,
syncWebSearchMcpToConfigDir,
appendThirdPartyWebSearchToolArgs,
createWebSearchTraceContext,
} from './utils/websearch-manager';
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
@@ -677,8 +679,9 @@ async function main(): Promise<void> {
if (profileInfo.type === 'cliproxy') {
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
// Inject WebSearch hook into profile settings before launch
ensureProfileHooksOrThrow(profileInfo.name);
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
}
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks(profileInfo.name);
@@ -835,8 +838,7 @@ async function main(): Promise<void> {
});
} else if (profileInfo.type === 'copilot') {
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
// Inject WebSearch hook into profile settings before launch
ensureProfileHooksOrThrow(profileInfo.name);
ensureWebSearchMcpOrThrow();
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks(profileInfo.name);
@@ -867,14 +869,12 @@ async function main(): Promise<void> {
process.exit(exitCode);
} else if (profileInfo.type === 'settings') {
// Settings-based profiles (glm, glmt) are third-party providers
// WebSearch is server-side tool - third-party providers have no access
// Inject WebSearch hook into profile settings before launch
ensureProfileHooksOrThrow(profileInfo.name);
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
}
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks(profileInfo.name);
ensureMcpWebSearch();
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
@@ -894,6 +894,7 @@ async function main(): Promise<void> {
);
}
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
const expandedSettingsPath =
resolvedSettingsPath ??
(profileInfo.settingsPath
@@ -1053,7 +1054,19 @@ async function main(): Promise<void> {
return;
}
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars);
const launchArgs = [
'--settings',
expandedSettingsPath,
...appendThirdPartyWebSearchToolArgs(remainingArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile',
args: launchArgs,
profile: profileInfo.name,
profileType: profileInfo.type,
settingsPath: expandedSettingsPath,
});
execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv });
} else if (profileInfo.type === 'account') {
// NEW FLOW: Account-based profile (work, personal)
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
@@ -0,0 +1,152 @@
import type { CLIProxyProvider } from '../types';
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
// Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is
// separate because the browser bundle cannot import this server module directly.
function normalizeProvider(provider: CLIProxyProvider | string): string {
return provider.trim().toLowerCase();
}
function cleanVariantTokenPart(value: string): string {
return value
.trim()
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/-+/g, '-')
.toLowerCase();
}
function baseNicknameFromEmail(email?: string): string {
if (!email) return 'default';
return email.split('@')[0].replace(/\s+/g, '').slice(0, 50) || 'default';
}
function formatVariantPart(value: string): string {
const normalized = value.trim().toLowerCase();
if (!normalized) {
return '';
}
switch (normalized) {
case 'team':
return 'Team';
case 'free':
return 'Free';
case 'plus':
return 'Plus';
case 'pro':
return 'Pro';
default:
return /^[a-f0-9]{8}$/i.test(normalized)
? normalized
: normalized
.split(/[._-]+/)
.filter(Boolean)
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(' ');
}
}
export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean {
return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider));
}
export function extractCanonicalEmailFromAccountId(accountId: string): string | null {
const canonical = accountId.split('#')[0]?.trim();
return canonical && canonical.includes('@') ? canonical : null;
}
export function extractEmailAccountVariantKey(
provider: CLIProxyProvider | string,
tokenFile: string,
email?: string
): string | null {
if (!email || !supportsDuplicateEmailAccounts(provider)) {
return null;
}
const normalizedProvider = normalizeProvider(provider);
const baseName = tokenFile.replace(/\.json$/i, '');
const providerPrefix = `${normalizedProvider}-`;
const candidate = baseName.toLowerCase().startsWith(providerPrefix)
? baseName.slice(providerPrefix.length)
: baseName;
const emailIndex = candidate.toLowerCase().indexOf(email.toLowerCase());
if (emailIndex === -1) {
const fallback = cleanVariantTokenPart(candidate);
return fallback && fallback !== cleanVariantTokenPart(email) ? fallback : null;
}
const before = cleanVariantTokenPart(candidate.slice(0, emailIndex));
const after = cleanVariantTokenPart(candidate.slice(emailIndex + email.length));
const parts = [before, after].filter(Boolean);
return parts.length > 0 ? parts.join('-') : null;
}
export function buildEmailBackedAccountId(
provider: CLIProxyProvider | string,
tokenFile: string,
email?: string,
duplicateEmailCount = 1
): string {
if (!email) {
return 'default';
}
if (!supportsDuplicateEmailAccounts(provider) || duplicateEmailCount <= 1) {
return email;
}
const variantKey = extractEmailAccountVariantKey(provider, tokenFile, email);
return variantKey ? `${email}#${variantKey}` : email;
}
export function buildEmailBackedNickname(
provider: CLIProxyProvider | string,
tokenFile: string,
email?: string,
duplicateEmailCount = 1
): string {
const base = baseNicknameFromEmail(email);
if (!supportsDuplicateEmailAccounts(provider) || duplicateEmailCount <= 1) {
return base;
}
const variantKey = extractEmailAccountVariantKey(provider, tokenFile, email);
if (!variantKey) {
return base;
}
return `${base}-${variantKey}`.slice(0, 50);
}
export function formatAccountVariantLabel(accountId: string, email?: string): string | null {
const variantKey =
extractCanonicalEmailFromAccountId(accountId) === email ? accountId.split('#')[1] : null;
if (!variantKey) {
return null;
}
const parts = variantKey.split('-').filter(Boolean);
if (parts.length === 0) {
return null;
}
const suffix = parts[parts.length - 1]?.toLowerCase();
if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) {
return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
.filter(Boolean)
.join(' · ');
}
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
}
export function formatAccountDisplayName(account: { id: string; email?: string }): string {
const base = account.email || account.id;
const variantLabel = formatAccountVariantLabel(account.id, account.email);
return variantLabel ? `${base} (${variantLabel})` : base;
}
+13 -11
View File
@@ -6,7 +6,7 @@
import { CLIProxyProvider } from '../types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { AccountInfo } from './types';
import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
import { hydrateRegistryFromTokenFiles, loadAccountsRegistry } from './registry';
/**
* Get all accounts for a provider
@@ -14,8 +14,8 @@ import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
const registry = loadAccountsRegistry();
// Sync in-memory view with actual token files without mutating disk on read.
syncRegistryWithTokenFiles(registry);
// Hydrate the in-memory view from token files without mutating disk on read.
hydrateRegistryFromTokenFiles(registry);
const providerAccounts = registry.providers[provider];
@@ -55,14 +55,16 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
const accounts = getProviderAccounts(provider);
const lowerQuery = query.toLowerCase();
// Exact match first (id, email, nickname)
const exactMatch = accounts.find(
(a) =>
a.id === query ||
a.email?.toLowerCase() === lowerQuery ||
a.nickname?.toLowerCase() === lowerQuery
);
if (exactMatch) return exactMatch;
const exactIdMatch = accounts.find((a) => a.id === query);
if (exactIdMatch) return exactIdMatch;
const emailMatches = accounts.filter((a) => a.email?.toLowerCase() === lowerQuery);
if (emailMatches.length === 1) return emailMatches[0];
if (emailMatches.length > 1) return null;
const nicknameMatches = accounts.filter((a) => a.nickname?.toLowerCase() === lowerQuery);
if (nicknameMatches.length === 1) return nicknameMatches[0];
if (nicknameMatches.length > 1) return null;
// Partial match on nickname or email prefix
const partialMatches = accounts.filter(
+199 -55
View File
@@ -23,6 +23,7 @@ import {
deleteTokenFile,
listRecoverableTokenFiles,
} from './token-file-ops';
import { buildEmailBackedAccountId, buildEmailBackedNickname } from './email-account-identity';
/** Default registry structure */
function createDefaultRegistry(): AccountsRegistry {
@@ -90,6 +91,16 @@ interface RegistryPopulationIssue {
reason: string;
}
interface ParsedRecoverableTokenFile {
tokenFile: string;
filePath: string;
paused: boolean;
provider: CLIProxyProvider;
email?: string;
projectId: string | null;
stats: fs.Stats;
}
function describeRegistryPopulationIssue(issue: RegistryPopulationIssue): string {
const sourceDir = issue.paused ? 'auth-paused' : 'auth';
return `${sourceDir}/${issue.tokenFile} (${issue.reason})`;
@@ -105,10 +116,15 @@ function getRegistryPopulationIssueReason(error: unknown): string {
return 'unreadable token file';
}
function populateRegistryFromTokenFiles(
registry: AccountsRegistry,
options: { includePaused?: boolean } = {}
): RegistryPopulationIssue[] {
function buildProviderEmailCountKey(provider: CLIProxyProvider, email: string): string {
return `${provider}:${email.trim().toLowerCase()}`;
}
function readRecoverableTokenFiles(options: { includePaused?: boolean } = {}): {
tokens: ParsedRecoverableTokenFile[];
issues: RegistryPopulationIssue[];
} {
const tokens: ParsedRecoverableTokenFile[] = [];
const issues: RegistryPopulationIssue[] = [];
for (const token of listRecoverableTokenFiles(options)) {
@@ -133,69 +149,134 @@ function populateRegistryFromTokenFiles(
continue;
}
const providerAccounts = ensureProviderRegistry(registry, provider);
const projectId =
typeof data.project_id === 'string' && data.project_id.trim()
? data.project_id.trim()
: null;
const email =
typeof data.email === 'string' && data.email.trim()
? data.email.trim()
: inferEmailFromTokenFileName(token.tokenFile, provider);
const projectId =
typeof data.project_id === 'string' && data.project_id.trim()
? data.project_id.trim()
: null;
const existingEntry = Object.entries(providerAccounts.accounts).find(
([, account]) => account.tokenFile === token.tokenFile
);
if (existingEntry) {
existingEntry[1].paused = token.paused || undefined;
if (!token.paused) {
existingEntry[1].pausedAt = undefined;
}
if (provider === 'agy' && projectId) {
existingEntry[1].projectId = projectId;
}
continue;
}
const accountId =
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
? deriveNoEmailProviderAccountId(provider, token.tokenFile, providerAccounts.accounts)
: extractAccountIdFromTokenFile(token.tokenFile, email);
if (providerAccounts.accounts[accountId]) {
continue;
}
if (Object.keys(providerAccounts.accounts).length === 0) {
providerAccounts.default = accountId;
}
const stats = fs.statSync(token.filePath);
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: email ? generateNickname(email) : accountId,
tokens.push({
tokenFile: token.tokenFile,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: (stats.mtime || stats.birthtime || new Date()).toISOString(),
};
if (token.paused) {
accountMeta.paused = true;
}
if (provider === 'agy' && projectId) {
accountMeta.projectId = projectId;
}
providerAccounts.accounts[accountId] = accountMeta;
filePath: token.filePath,
paused: token.paused,
provider,
email,
projectId,
stats: fs.statSync(token.filePath),
});
} catch (error) {
issues.push({
tokenFile: token.tokenFile,
paused: token.paused,
reason: getRegistryPopulationIssueReason(error),
});
}
}
return { tokens, issues };
}
function buildDuplicateEmailCounts(
tokens: ParsedRecoverableTokenFile[]
): ReadonlyMap<string, number> {
const counts = new Map<string, number>();
for (const token of tokens) {
if (!token.email) {
continue;
}
const key = buildProviderEmailCountKey(token.provider, token.email);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}
function populateRegistryFromTokenFiles(
registry: AccountsRegistry,
options: { includePaused?: boolean } = {}
): RegistryPopulationIssue[] {
const { tokens, issues } = readRecoverableTokenFiles(options);
const duplicateEmailCounts = buildDuplicateEmailCounts(tokens);
for (const token of tokens) {
const providerAccounts = ensureProviderRegistry(registry, token.provider);
const existingEntry = Object.entries(providerAccounts.accounts).find(
([, account]) => account.tokenFile === token.tokenFile
);
const existingAccountId = existingEntry?.[0];
const existingAccount = existingEntry?.[1];
const resolvedEmail = token.email ?? existingAccount?.email;
const duplicateEmailCount = resolvedEmail
? (duplicateEmailCounts.get(buildProviderEmailCountKey(token.provider, resolvedEmail)) ?? 1)
: 1;
const desiredAccountId =
PROVIDERS_WITHOUT_EMAIL.includes(token.provider) && !resolvedEmail
? deriveNoEmailProviderAccountId(token.provider, token.tokenFile, providerAccounts.accounts)
: !token.email && existingAccountId
? existingAccountId
: buildEmailBackedAccountId(
token.provider,
token.tokenFile,
resolvedEmail,
duplicateEmailCount
);
if (existingEntry && existingEntry[0] !== desiredAccountId) {
if (!providerAccounts.accounts[desiredAccountId]) {
providerAccounts.accounts[desiredAccountId] = existingEntry[1];
}
if (providerAccounts.default === existingEntry[0]) {
providerAccounts.default = desiredAccountId;
}
delete providerAccounts.accounts[existingEntry[0]];
}
if (Object.keys(providerAccounts.accounts).length === 0) {
providerAccounts.default = desiredAccountId;
}
const hydratedAccount = providerAccounts.accounts[desiredAccountId];
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email: resolvedEmail,
nickname:
hydratedAccount?.nickname ||
(resolvedEmail
? buildEmailBackedNickname(
token.provider,
token.tokenFile,
resolvedEmail,
duplicateEmailCount
)
: desiredAccountId),
tokenFile: token.tokenFile,
createdAt:
hydratedAccount?.createdAt ||
token.stats.birthtime?.toISOString() ||
new Date().toISOString(),
lastUsedAt:
hydratedAccount?.lastUsedAt ||
(token.stats.mtime || token.stats.birthtime || new Date()).toISOString(),
};
if (token.paused) {
accountMeta.paused = true;
accountMeta.pausedAt = hydratedAccount?.pausedAt || new Date().toISOString();
} else {
accountMeta.paused = undefined;
accountMeta.pausedAt = undefined;
}
if (token.provider === 'agy') {
accountMeta.projectId = token.projectId || hydratedAccount?.projectId;
}
providerAccounts.accounts[desiredAccountId] = accountMeta;
}
return issues;
@@ -384,6 +465,17 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean
return modified;
}
/**
* Build an in-memory view that includes both stale-entry cleanup and any token
* files not yet persisted into accounts.json. Used by read paths so duplicate
* email accounts stay visible without forcing a disk write.
*/
export function hydrateRegistryFromTokenFiles(registry: AccountsRegistry): boolean {
const removedStaleEntries = syncRegistryWithTokenFiles(registry);
const populationIssues = populateRegistryFromTokenFiles(registry);
return removedStaleEntries || populationIssues.length > 0;
}
/**
* Register a new account
* Called after successful OAuth to record the account
@@ -448,8 +540,60 @@ export function registerAccount(
accountNickname =
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
} else {
accountId = extractAccountIdFromTokenFile(tokenFile, email);
accountNickname = nickname || generateNickname(email);
const sameEmailEntries = email
? Object.entries(providerAccounts.accounts).filter(
([, account]) => account.email?.toLowerCase() === email.toLowerCase()
)
: [];
const duplicateEmailCount = email
? new Set([...sameEmailEntries.map(([, account]) => account.tokenFile), tokenFile]).size
: 1;
if (email && duplicateEmailCount > 1) {
for (const [existingId, existingMeta] of sameEmailEntries) {
const migratedId = buildEmailBackedAccountId(
provider,
existingMeta.tokenFile,
email,
duplicateEmailCount
);
if (migratedId === existingId || providerAccounts.accounts[migratedId]) {
continue;
}
providerAccounts.accounts[migratedId] = existingMeta;
if (providerAccounts.default === existingId) {
providerAccounts.default = migratedId;
}
delete providerAccounts.accounts[existingId];
}
}
accountId = buildEmailBackedAccountId(provider, tokenFile, email, duplicateEmailCount);
const existingAccount = providerAccounts.accounts[accountId];
if (nickname) {
const validationError = validateNickname(nickname);
if (validationError) {
throw new Error(validationError);
}
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
id,
nickname: account.nickname,
}));
if (hasAccountNameConflict(existingAccounts, nickname, accountId)) {
throw new Error(
`An account with nickname "${nickname}" already exists for ${provider}. ` +
`Choose a different nickname.`
);
}
}
accountNickname =
nickname ||
existingAccount?.nickname ||
buildEmailBackedNickname(provider, tokenFile, email, duplicateEmailCount);
}
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
+53 -13
View File
@@ -12,6 +12,7 @@ import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { getProviderAuthDir } from '../config-generator';
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
import { buildEmailBackedAccountId } from '../accounts/email-account-identity';
import {
AuthStatus,
PROVIDER_AUTH_PREFIXES,
@@ -215,6 +216,7 @@ export function registerAccountFromToken(
mtimeMs: number;
alreadyRegistered: boolean;
};
type RawTokenCandidate = Omit<TokenCandidate, 'accountId'>;
const { registerAccount } = require('../account-manager');
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
@@ -222,33 +224,71 @@ export function registerAccountFromToken(
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
const existingAccounts = getProviderAccounts(provider);
const candidates: TokenCandidate[] = jsonFiles
.map((file): TokenCandidate | null => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) return null;
const rawCandidates: RawTokenCandidate[] = jsonFiles.flatMap((file) => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) return [];
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as { email?: string; project_id?: string };
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const accountId = extractAccountIdFromTokenFile(file, email);
const stats = fs.statSync(filePath);
return {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as { email?: string; project_id?: string };
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const stats = fs.statSync(filePath);
return [
{
file,
filePath,
email,
projectId,
accountId,
mtimeMs: stats.mtimeMs,
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
},
];
});
const duplicateEmailCounts = new Map<string, number>();
const duplicateEmailTokenSets = new Map<string, Set<string>>();
for (const account of existingAccounts) {
if (!account.email) continue;
const key = account.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(account.tokenFile);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const candidate of rawCandidates) {
if (!candidate.email) continue;
const key = candidate.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(candidate.file);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const [key, tokenSet] of duplicateEmailTokenSets) {
duplicateEmailCounts.set(key, tokenSet.size);
}
const candidates: TokenCandidate[] = rawCandidates
.map((rawCandidate) => {
const duplicateEmailCount = rawCandidate.email
? (duplicateEmailCounts.get(rawCandidate.email.toLowerCase()) ?? 1)
: 1;
const accountId = rawCandidate.email
? buildEmailBackedAccountId(
provider,
rawCandidate.file,
rawCandidate.email,
duplicateEmailCount
)
: extractAccountIdFromTokenFile(rawCandidate.file, rawCandidate.email);
return {
...rawCandidate,
accountId,
};
})
.filter((candidate): candidate is TokenCandidate => candidate !== null)
.sort((a, b) => b.mtimeMs - a.mtimeMs);
if (expectedAccountId) {
selectedCandidate =
candidates.find((candidate) => candidate.accountId === expectedAccountId) ||
candidates.find((candidate) => candidate.file === expectedAccountId) ||
candidates.find((candidate) => {
const existingAccount = existingAccounts.find(
(account) => account.id === expectedAccountId
+28 -14
View File
@@ -51,10 +51,12 @@ import {
renameAccount,
getDefaultAccount,
} from '../account-manager';
import { formatAccountDisplayName } from '../accounts/email-account-identity';
import {
ensureMcpWebSearch,
installWebSearchHook,
ensureWebSearchMcpOrThrow,
displayWebSearchStatus,
appendThirdPartyWebSearchToolArgs,
createWebSearchTraceContext,
} from '../../utils/websearch-manager';
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
import { installImageAnalyzerHook } from '../../utils/hooks';
@@ -196,9 +198,8 @@ export async function execClaudeWithCLIProxy(
log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`);
}
// Setup WebSearch hooks
ensureMcpWebSearch();
installWebSearchHook();
// Setup first-class CCS WebSearch runtime
ensureWebSearchMcpOrThrow();
displayWebSearchStatus();
// Sync image analyzer hook from npm package to ~/.ccs/hooks/
@@ -422,7 +423,7 @@ export async function execClaudeWithCLIProxy(
for (const acct of accounts) {
const defaultMark = acct.isDefault ? ' (default)' : '';
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`);
console.log(` ${nickname.padEnd(12)} ${formatAccountDisplayName(acct)}${defaultMark}`);
}
console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" to switch accounts`);
}
@@ -438,14 +439,19 @@ export async function execClaudeWithCLIProxy(
if (accounts.length > 0) {
console.error(` Available accounts:`);
for (const acct of accounts) {
console.error(` - ${acct.nickname || acct.id} (${acct.email || 'no email'})`);
const displayName = formatAccountDisplayName(acct);
const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName;
console.error(` - ${label}`);
}
}
process.exit(1);
}
setDefaultAccount(provider, account.id);
touchAccount(provider, account.id);
console.log(ok(`Switched to account: ${account.nickname || account.email || account.id}`));
const switchedLabel = account.nickname
? `${account.nickname} (${formatAccountDisplayName(account)})`
: formatAccountDisplayName(account);
console.log(ok(`Switched to account: ${switchedLabel}`));
}
// Handle --nickname (rename account)
@@ -1029,21 +1035,29 @@ export async function execClaudeWithCLIProxy(
: getProviderSettingsPath(provider);
let claude: ChildProcess;
const launchArgs = ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(claudeArgs)];
const traceEnv = createWebSearchTraceContext({
launcher: 'cliproxy.executor',
args: launchArgs,
profile: cfg.profileName || provider,
profileType: 'cliproxy',
settingsPath,
claudeConfigDir: inheritedClaudeConfigDir,
});
const tracedEnv = { ...env, ...traceEnv };
if (needsShell) {
const cmdString = [claudeCli, '--settings', settingsPath, ...claudeArgs]
.map(escapeShellArg)
.join(' ');
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
claude = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env,
env: tracedEnv,
});
} else {
claude = spawn(claudeCli, ['--settings', settingsPath, ...claudeArgs], {
claude = spawn(claudeCli, launchArgs, {
stdio: 'inherit',
windowsHide: true,
env,
env: tracedEnv,
});
}
+51 -17
View File
@@ -8,9 +8,10 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getAuthDir } from './config-generator';
import { getProviderAccounts, getPausedDir } from './account-manager';
import { getAccount, getProviderAccounts, getPausedDir } from './account-manager';
import { sanitizeEmail, isTokenExpired } from './auth-utils';
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
import { extractCanonicalEmailFromAccountId } from './accounts/email-account-identity';
/** ChatGPT backend API base URL */
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
@@ -174,9 +175,46 @@ export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCo
/**
* Read auth data from Codex auth file
*/
function readCodexAuthFile(filePath: string): CodexAuthData | null {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
if (!data.access_token) {
return null;
}
return {
accessToken: data.access_token,
accountId: data.account_id || data.accountId || '',
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
};
} catch {
return null;
}
}
function readCodexAuthData(accountId: string): CodexAuthData | null {
const authDirs = [getAuthDir(), getPausedDir()];
const sanitizedId = sanitizeEmail(accountId);
const registryAccount = getAccount('codex', accountId);
const canonicalEmail = extractCanonicalEmailFromAccountId(accountId);
const hasExplicitVariant = canonicalEmail !== null && canonicalEmail !== accountId;
if (registryAccount?.tokenFile) {
for (const authDir of authDirs) {
const filePath = path.join(authDir, registryAccount.tokenFile);
if (!fs.existsSync(filePath)) {
continue;
}
const authData = readCodexAuthFile(filePath);
if (authData) {
return authData;
}
}
}
const legacyEmail = canonicalEmail ?? accountId;
const sanitizedId = sanitizeEmail(legacyEmail);
const expectedFile = `codex-${sanitizedId}.json`;
for (const authDir of authDirs) {
@@ -184,23 +222,19 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
const filePath = path.join(authDir, expectedFile);
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
if (!data.access_token) continue;
return {
accessToken: data.access_token,
accountId: data.account_id || data.accountId || '',
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
};
} catch {
continue;
const authData = readCodexAuthFile(filePath);
if (authData) {
return authData;
}
}
// Fallback: scan directory for matching email in file content
// Fallback is only safe for legacy email-only IDs. Variant-backed IDs must resolve
// through the registry-backed token file so duplicate-email accounts stay deterministic.
if (hasExplicitVariant) {
continue;
}
// Fallback: scan directory for matching email in file content.
const files = fs.readdirSync(authDir);
for (const file of files) {
if (file.startsWith('codex-') && file.endsWith('.json')) {
@@ -208,7 +242,7 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
try {
const content = fs.readFileSync(candidatePath, 'utf-8');
const data = JSON.parse(content);
if (data.email === accountId && data.access_token) {
if (data.email === legacyEmail && data.access_token) {
return {
accessToken: data.access_token,
accountId: data.account_id || data.accountId || '',
+4 -6
View File
@@ -14,7 +14,7 @@ import { expandPath } from '../../utils/helpers';
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
import { CLIProxyProvider } from '../types';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { getEffectiveApiKey } from '../auth-token-manager';
import { warn } from '../../utils/ui';
@@ -154,8 +154,7 @@ export function createSettingsFile(
writeSettings(settingsPath, settings);
try {
// Inject WebSearch hooks into variant settings
ensureProfileHooksOrThrow(`${provider}-${name}`);
ensureWebSearchMcpOrThrow();
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
@@ -189,8 +188,7 @@ export function createSettingsFileUnified(
writeSettings(settingsPath, settings);
try {
// Inject WebSearch hooks into variant settings
ensureProfileHooksOrThrow(`${provider}-${name}`);
ensureWebSearchMcpOrThrow();
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
@@ -289,7 +287,7 @@ export function createCompositeSettingsFile(
// Hook injectors target ~/.ccs/<profile>.settings.json; only run for default path.
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
try {
ensureProfileHooksOrThrow(`composite-${name}`);
ensureWebSearchMcpOrThrow();
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
+38 -2
View File
@@ -1,5 +1,6 @@
import { buildQualifiedAccountStatsKey } from './account-stats-key';
import { mapExternalProviderName } from './provider-capabilities';
import { buildEmailBackedAccountId } from './accounts/email-account-identity';
import type {
AccountUsageStats,
CliproxyManagementAuthFile,
@@ -15,6 +16,9 @@ interface BuildCliproxyStatsOptions {
interface ResolvedAuthFile {
provider?: string;
source?: string;
email?: string;
name?: string;
duplicateEmailCount?: number;
}
function normalizeProvider(provider: string): string {
@@ -30,6 +34,16 @@ function buildAuthIndexLookup(
authFiles: CliproxyManagementAuthFile[] | undefined
): ReadonlyMap<string, ResolvedAuthFile> {
const lookup = new Map<string, ResolvedAuthFile>();
const duplicateEmailCounts = new Map<string, number>();
for (const authFile of authFiles ?? []) {
if (!authFile.provider || !authFile.email) {
continue;
}
const key = `${normalizeProvider(authFile.provider)}:${authFile.email.trim().toLowerCase()}`;
duplicateEmailCounts.set(key, (duplicateEmailCounts.get(key) ?? 0) + 1);
}
for (const authFile of authFiles ?? []) {
if (authFile.auth_index === undefined || authFile.auth_index === null) {
@@ -37,6 +51,8 @@ function buildAuthIndexLookup(
}
const provider = authFile.provider ? normalizeProvider(authFile.provider) : undefined;
const email = authFile.email?.trim() || undefined;
const name = authFile.name?.trim() || undefined;
const source = authFile.email?.trim() || authFile.name?.trim() || undefined;
if (!provider && !source) {
continue;
@@ -45,6 +61,12 @@ function buildAuthIndexLookup(
lookup.set(String(authFile.auth_index), {
provider,
source,
email,
name,
duplicateEmailCount:
provider && email
? (duplicateEmailCounts.get(`${provider}:${email.toLowerCase()}`) ?? 1)
: 1,
});
}
@@ -65,15 +87,29 @@ function resolveProviderForDetail(
}
function resolveSourceForDetail(
resolvedProvider: string,
detail: CliproxyRequestDetail,
authIndexLookup: ReadonlyMap<string, ResolvedAuthFile>
): string {
const resolvedAuthFile = authIndexLookup.get(String(detail.auth_index));
if (resolvedAuthFile?.email && resolvedAuthFile?.name) {
const derivedSource = buildEmailBackedAccountId(
resolvedProvider,
resolvedAuthFile.name,
resolvedAuthFile.email,
resolvedAuthFile.duplicateEmailCount ?? 1
);
if (derivedSource) {
return derivedSource;
}
}
const source = detail.source?.trim();
if (source) {
return source;
}
return authIndexLookup.get(String(detail.auth_index))?.source ?? 'unknown';
return resolvedAuthFile?.source ?? 'unknown';
}
export function buildCliproxyStatsFromUsageResponse(
@@ -110,8 +146,8 @@ export function buildCliproxyStatsFromUsageResponse(
for (const detail of modelData.details) {
sawAnyDetail = true;
sawProviderDetail = true;
const source = resolveSourceForDetail(detail, authIndexLookup);
const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup);
const source = resolveSourceForDetail(resolvedProvider, detail, authIndexLookup);
const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source);
requestsByProvider[resolvedProvider] = (requestsByProvider[resolvedProvider] ?? 0) + 1;
+26 -22
View File
@@ -35,6 +35,7 @@ import {
QUOTA_SUPPORTED_PROVIDER_IDS,
type QuotaSupportedProvider,
} from '../../cliproxy/provider-capabilities';
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
interface CliproxyProfileArgs {
@@ -97,6 +98,11 @@ function formatResetTimeISO(isoTime: string): string {
return formatResetTime(seconds);
}
function formatCliAccountLabel(account: { id: string; email?: string; nickname?: string }): string {
const displayName = formatAccountDisplayName(account);
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
}
interface QuotaFailureDisplayEntry {
tone: 'error' | 'info' | 'dim';
text: string;
@@ -360,13 +366,7 @@ function displayAntigravityQuotaSection(
const tier = account.tier || 'unknown';
const status = statusParts.join(', ');
rows.push([
defaultMark,
account.nickname || account.email || account.id,
tier,
avgQuota,
status,
]);
rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]);
}
console.log(
@@ -384,10 +384,11 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('codex', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(account)}${defaultMark}`);
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
@@ -406,7 +407,7 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
const coreUsageSummary = quota.coreUsage ?? {
fiveHour: fiveHourWindow
@@ -539,10 +540,11 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('claude', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(account)}${defaultMark}`);
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
@@ -562,7 +564,7 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
const statusIcon =
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
console.log(` ${statusIcon}${account}${defaultMark}`);
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
const resetParts: string[] = [];
if (fiveHourWindow?.resetAt)
@@ -616,10 +618,11 @@ function displayGeminiCliQuotaSection(
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('gemini', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(account)}${defaultMark}`);
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
@@ -631,7 +634,7 @@ function displayGeminiCliQuotaSection(
: 0;
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
console.log(` ${statusIcon}${account}${defaultMark}`);
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
if (quota.projectId) {
console.log(` Project: ${dim(quota.projectId)}`);
}
@@ -667,10 +670,11 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('ghcp', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(account)}${defaultMark}`);
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
@@ -685,7 +689,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
const statusIcon = minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
if (quota.quotaResetDate) {
console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`);
}
@@ -840,7 +844,7 @@ export async function handleDoctor(verbose = false): Promise<void> {
const quotaResult = await fetchAllProviderQuotas(provider, verbose);
for (const { account, quota } of quotaResult.accounts) {
const accountLabel = account.email || account.id || 'Unknown Account';
const accountLabel = formatCliAccountLabel(account);
const defaultBadge = account.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
@@ -933,7 +937,7 @@ export async function handleSetDefault(args: string[]): Promise<void> {
console.log('Available accounts:');
for (const acc of accounts) {
const badge = acc.isDefault ? color(' (current default)', 'info') : '';
console.log(` - ${acc.email || acc.id}${badge}`);
console.log(` - ${formatCliAccountLabel(acc)}${badge}`);
}
} else {
console.log(`No accounts found for provider: ${provider}`);
@@ -945,7 +949,7 @@ export async function handleSetDefault(args: string[]): Promise<void> {
const success = setDefaultAccount(provider, account.id);
if (success) {
console.log(ok(`Default account set to: ${account.email || account.id}`));
console.log(ok(`Default account set to: ${formatCliAccountLabel(account)}`));
console.log(info(`Provider: ${provider}`));
} else {
console.log(fail('Failed to set default account'));
@@ -973,7 +977,7 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
}
if (account.paused) {
console.log(warn(`Account already paused: ${account.email || account.id}`));
console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`));
console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`));
return;
}
@@ -981,7 +985,7 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
const success = pauseAccount(provider, account.id);
if (success) {
console.log(ok(`Account paused: ${account.email || account.id}`));
console.log(ok(`Account paused: ${formatCliAccountLabel(account)}`));
console.log(info('Account will be skipped in quota rotation'));
} else {
console.log(fail('Failed to pause account'));
@@ -1009,14 +1013,14 @@ export async function handleResumeAccount(args: string[]): Promise<void> {
}
if (!account.paused) {
console.log(warn(`Account is not paused: ${account.email || account.id}`));
console.log(warn(`Account is not paused: ${formatCliAccountLabel(account)}`));
return;
}
const success = resumeAccount(provider, account.id);
if (success) {
console.log(ok(`Account resumed: ${account.email || account.id}`));
console.log(ok(`Account resumed: ${formatCliAccountLabel(account)}`));
console.log(info('Account is now active in quota rotation'));
} else {
console.log(fail('Failed to resume account'));
+20 -6
View File
@@ -28,6 +28,7 @@ import {
} from '../../cliproxy/services';
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
interface CliproxyProfileArgs {
name?: string;
@@ -118,6 +119,15 @@ function getBackendLabel(backend: CLIProxyBackend): string {
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
}
function formatVariantAccountLabel(account: {
id: string;
email?: string;
nickname?: string;
}): string {
const displayName = formatAccountDisplayName(account);
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
}
/**
* Interactive prompt to select provider + model for a single tier.
* Returns a CompositeTierConfig, or null if user cancelled auth.
@@ -158,7 +168,7 @@ async function selectTierConfig(
console.log(fail('Authentication failed'));
process.exit(1);
}
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
}
// Select model
@@ -364,7 +374,7 @@ export async function handleCreate(
}
account = newAccount.id;
console.log('');
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
} else if (providerAccounts.length === 1) {
account = providerAccounts[0].id;
} else {
@@ -372,7 +382,7 @@ export async function handleCreate(
const accountOptions = [
...providerAccounts.map((acc) => ({
id: acc.id,
label: `${acc.email || acc.id}${acc.isDefault ? ' (default)' : ''}`,
label: `${formatVariantAccountLabel(acc)}${acc.isDefault ? ' (default)' : ''}`,
})),
{ id: ADD_NEW_ID, label: color('[+ Add new account...]', 'info') },
];
@@ -394,7 +404,7 @@ export async function handleCreate(
}
account = newAccount.id;
console.log('');
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
} else {
account = selectedAccount;
}
@@ -406,7 +416,7 @@ export async function handleCreate(
console.log('');
console.log('Available accounts:');
providerAccounts.forEach((a) =>
console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`)
console.log(` - ${formatVariantAccountLabel(a)}${a.isDefault ? ' (default)' : ''}`)
);
process.exit(1);
}
@@ -450,9 +460,13 @@ export async function handleCreate(
? '~/.ccs/config.yaml'
: `~/.ccs/${path.basename(result.settingsPath || '')}`;
const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : '';
const selectedAccount =
account && provider
? getProviderAccounts(provider as CLIProxyProvider).find((acc) => acc.id === account)
: null;
console.log(
infoBox(
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${selectedAccount ? `Account: ${formatVariantAccountLabel(selectedAccount)}\n` : account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
configType
)
);
+10 -3
View File
@@ -5,7 +5,7 @@
*/
import { info, ok, color, box, initUI } from '../utils/ui';
import { uninstallWebSearchHook } from '../utils/websearch';
import { uninstallWebSearchHook, uninstallWebSearchMcp } from '../utils/websearch';
import { ClaudeSymlinkManager } from '../utils/claude-symlink-manager';
/**
@@ -42,12 +42,19 @@ export async function handleUninstallCommand(): Promise<void> {
removed += 1; // Count as 1 item (the hook file)
}
// 2. Remove symlinks from ~/.claude/
// 2. Remove managed WebSearch MCP runtime/config
const mcpRemoved = uninstallWebSearchMcp();
if (mcpRemoved) {
console.log(ok('Removed WebSearch MCP runtime'));
removed += 1;
}
// 3. Remove symlinks from ~/.claude/
const symlinkManager = new ClaudeSymlinkManager();
const symlinksRemoved = symlinkManager.uninstall();
removed += symlinksRemoved; // Add actual count of symlinks removed
// 3. Summary
// 4. Summary
console.log('');
if (removed > 0) {
console.log(ok('Uninstall complete!'));
+19 -3
View File
@@ -14,7 +14,12 @@ import { ensureCopilotApi } from './copilot-package-manager';
import { normalizeCopilotConfigWithWarnings } from './copilot-model-normalizer';
import { CopilotStatus } from './types';
import { fail, info, ok, warn } from '../utils/ui';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import {
getWebSearchHookEnv,
appendThirdPartyWebSearchToolArgs,
createWebSearchTraceContext,
syncWebSearchMcpToConfigDir,
} from '../utils/websearch-manager';
import { getImageAnalysisHookEnv } from '../utils/hooks';
import { stripClaudeCodeEnv } from '../utils/shell-executor';
@@ -173,11 +178,22 @@ export async function executeCopilotProfile(
console.log(info(`Using GitHub Copilot proxy (model: ${normalizedConfig.model})`));
console.log('');
syncWebSearchMcpToConfigDir(claudeConfigDir);
// Spawn Claude CLI
return new Promise((resolve) => {
const proc = spawn(claudeCliPath, claudeArgs, {
const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs);
const traceEnv = createWebSearchTraceContext({
launcher: 'copilot.executor',
args: launchArgs,
profile: 'copilot',
profileType: 'copilot',
claudeConfigDir,
});
const proc = spawn(claudeCliPath, launchArgs, {
stdio: 'inherit',
env,
env: { ...env, ...traceEnv },
shell: process.platform === 'win32',
});
+26 -1
View File
@@ -2,9 +2,33 @@
* Result aggregation utilities for headless executor
*/
import type { ExecutionResult, StreamMessage } from './types';
import type { ExecutionResult, StreamMessage, ToolUsageSummary } from './types';
import { warn } from '../../utils/ui';
const WEBSEARCH_FALLBACK_TOOLS = new Set(['Bash', 'WebFetch']);
export function summarizeToolUsage(messages: StreamMessage[]): ToolUsageSummary {
const toolNames = new Set<string>();
for (const message of messages) {
const content = message.message?.content || [];
for (const entry of content) {
if (entry.type === 'tool_use' && entry.name) {
toolNames.add(entry.name);
}
}
}
const orderedToolNames = [...toolNames];
return {
toolNames: orderedToolNames,
calledWebSearch: toolNames.has('WebSearch') || toolNames.has('search'),
fallbackToolsUsed: orderedToolNames.filter((toolName) =>
WEBSEARCH_FALLBACK_TOOLS.has(toolName)
),
};
}
/**
* Build execution result from stream messages
* @param params - Parameters for building result
@@ -32,6 +56,7 @@ export function buildExecutionResult(params: {
timedOut,
success: exitCode === 0 && !timedOut,
messages,
toolUsageSummary: summarizeToolUsage(messages),
};
// Extract metadata from final 'result' message in stream-json
+7
View File
@@ -44,6 +44,12 @@ export interface ExecutionError {
[key: string]: unknown;
}
export interface ToolUsageSummary {
toolNames: string[];
calledWebSearch: boolean;
fallbackToolsUsed: string[];
}
/**
* Options for headless execution
*/
@@ -86,6 +92,7 @@ export interface ExecutionResult {
permissionDenials?: PermissionDenial[];
errors?: ExecutionError[];
content?: string;
toolUsageSummary?: ToolUsageSummary;
}
/**
+104 -4
View File
@@ -18,6 +18,16 @@ import { buildExecutionResult } from './executor/result-aggregator';
import { getCcsDir, getModelDisplayName } from '../utils/config-manager';
import { getProfileLookupCandidates } from '../utils/profile-compat';
import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
import {
appendThirdPartyWebSearchToolArgs,
appendWebSearchTrace,
createWebSearchTraceContext,
ensureWebSearchMcpOrThrow,
getWebSearchHookEnv,
readWebSearchTraceRecords,
syncWebSearchMcpToConfigDir,
} from '../utils/websearch-manager';
// Re-export types for consumers
export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types';
@@ -80,6 +90,23 @@ export class HeadlessExecutor {
);
}
const continuityInheritance = await resolveProfileContinuityInheritance({
profileName: profile,
profileType: 'settings',
target: 'claude',
});
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
console.error(
info(
`Continuity inheritance active: profile "${profile}" -> account "${continuityInheritance.sourceAccount}"`
)
);
}
ensureWebSearchMcpOrThrow();
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
// Smart slash command detection and preservation
const processedPrompt = this._processSlashCommand(enhancedPrompt);
@@ -125,7 +152,7 @@ export class HeadlessExecutor {
args.push('--allowedTools', ...toolRestrictions.allowedTools);
}
if (toolRestrictions.disallowedTools.length > 0) {
args.push('--disallowedTools', ...toolRestrictions.disallowedTools);
args.push('--disallowedTools', toolRestrictions.disallowedTools.join(','));
}
// Claude Code CLI passthrough flags (explicit, validated)
@@ -163,21 +190,34 @@ export class HeadlessExecutor {
}
}
const launchArgs = appendThirdPartyWebSearchToolArgs(args);
const traceEnv = createWebSearchTraceContext({
launcher: 'delegation.headless-executor',
args: launchArgs,
cwd,
profile,
profileType: 'settings',
settingsPath,
claudeConfigDir: inheritedClaudeConfigDir,
});
if (process.env.CCS_DEBUG) {
console.error(info(`Claude CLI args: ${args.join(' ')}`));
console.error(info(`Claude CLI args: ${launchArgs.join(' ')}`));
}
// Initialize UI before spawning
await ui.init();
// Execute with spawn
return this._spawnAndExecute(claudeCli, args, {
return this._spawnAndExecute(claudeCli, launchArgs, {
cwd,
profile,
timeout,
resumeSession,
sessionId,
sessionMgr,
claudeConfigDir: inheritedClaudeConfigDir,
traceEnv,
});
}
@@ -194,9 +234,20 @@ export class HeadlessExecutor {
resumeSession: boolean;
sessionId: string | null;
sessionMgr: SessionManager;
claudeConfigDir?: string;
traceEnv?: Record<string, string>;
}
): Promise<ExecutionResult> {
const { cwd, profile, timeout, resumeSession, sessionId, sessionMgr } = ctx;
const {
cwd,
profile,
timeout,
resumeSession,
sessionId,
sessionMgr,
claudeConfigDir,
traceEnv = {},
} = ctx;
return new Promise((resolve, reject) => {
const startTime = Date.now();
@@ -213,6 +264,10 @@ export class HeadlessExecutor {
const cleanEnv = stripClaudeCodeEnv({
...process.env,
...getClaudeLaunchEnvOverrides(),
...getWebSearchHookEnv(),
...traceEnv,
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
CCS_PROFILE_TYPE: 'settings',
});
const proc = spawn(claudeCli, args, {
@@ -313,6 +368,51 @@ export class HeadlessExecutor {
messages,
});
const launchId = traceEnv.CCS_WEBSEARCH_TRACE_LAUNCH_ID;
if (launchId) {
const launchTraceRecords = readWebSearchTraceRecords(launchId, {
...process.env,
...traceEnv,
CCS_PROFILE_TYPE: 'settings',
});
const mcpSessionSummary = [...launchTraceRecords]
.reverse()
.find((record) => record.event === 'mcp_session_summary');
const providerSuccess = [...launchTraceRecords]
.reverse()
.find((record) => record.event === 'websearch_provider_success');
const exposed = mcpSessionSummary?.exposed === true;
const calledWebSearch = result.toolUsageSummary?.calledWebSearch === true;
const fallbackToolsUsed = result.toolUsageSummary?.fallbackToolsUsed || [];
appendWebSearchTrace(
'headless_websearch_summary',
{
profile,
sessionId: result.sessionId || null,
calledWebSearch,
fallbackToolsUsed,
providerUsed:
typeof providerSuccess?.providerName === 'string'
? providerSuccess.providerName
: null,
exposed,
likelyBypassed:
exposed && !calledWebSearch
? fallbackToolsUsed.length > 0
? true
: 'unknown'
: false,
},
{
...process.env,
...traceEnv,
CCS_PROFILE_TYPE: 'settings',
}
);
}
// Store session
if (result.sessionId) {
if (resumeSession || sessionId) {
+13 -3
View File
@@ -10,9 +10,12 @@ import * as fs from 'fs';
import * as path from 'path';
import SharedManager from './shared-manager';
import ProfileContextSyncLock from './profile-context-sync-lock';
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
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']);
/** Options for instance creation */
export interface InstanceOptions {
/** Skip shared symlinks (commands, skills, agents, settings.json) */
@@ -234,13 +237,20 @@ class InstanceManager {
}
}
// Merge: global MCP servers as base, instance-specific overrides on top
// Merge: global MCP servers as base, instance-specific overrides on top,
// except for CCS-managed entries which must stay aligned with the global runtime.
const rawExistingMcp = instanceContent.mcpServers;
const existingMcp =
rawExistingMcp && typeof rawExistingMcp === 'object' && !Array.isArray(rawExistingMcp)
? (rawExistingMcp as Record<string, unknown>)
: {};
instanceContent.mcpServers = { ...mcpServers, ...existingMcp };
const mergedMcpServers = { ...mcpServers, ...existingMcp };
for (const managedName of MANAGED_MCP_SERVER_NAMES) {
if (managedName in mcpServers) {
mergedMcpServers[managedName] = mcpServers[managedName];
}
}
instanceContent.mcpServers = mergedMcpServers;
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), {
encoding: 'utf8',
+91 -3
View File
@@ -2,6 +2,7 @@ import { ChildProcess, spawn } from 'child_process';
import * as fs from 'fs';
import type { ProfileType } from '../types/profile';
import { runCleanup } from '../errors';
import { expandPath } from '../utils/helpers';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { escapeShellArg, stripAnthropicEnv, stripCodexSessionEnv } from '../utils/shell-executor';
import type {
@@ -20,6 +21,7 @@ import {
const CODEX_RUNTIME_PROVIDER_ID = 'ccs_runtime';
const CODEX_RUNTIME_ENV_KEY = 'CCS_CODEX_API_KEY';
const CODEX_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
const CODEX_INFO_FLAGS = new Set(['--help', '-h', '--version', '-v']);
function formatTomlString(value: string): string {
return JSON.stringify(value);
@@ -83,6 +85,85 @@ function normalizeCodexReasoningOverride(value: string | number | undefined): st
);
}
function isInformationalCodexInvocation(args: string[]): boolean {
if (args.length === 1) {
return CODEX_INFO_FLAGS.has(args[0] || '');
}
if (args.length === 2) {
return CODEX_INFO_FLAGS.has(args[1] || '');
}
return false;
}
function normalizeExplicitCodexHomeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const rawCodexHome = env.CODEX_HOME;
if (rawCodexHome === undefined) {
return env;
}
const trimmedCodexHome = rawCodexHome.trim();
if (!trimmedCodexHome) {
const nextEnv = { ...env };
delete nextEnv.CODEX_HOME;
return nextEnv;
}
const normalizedCodexHome = expandPath(trimmedCodexHome);
if (normalizedCodexHome === rawCodexHome) {
return env;
}
return {
...env,
CODEX_HOME: normalizedCodexHome,
};
}
function prepareExplicitCodexHome(
env: NodeJS.ProcessEnv,
args: string[]
): { env: NodeJS.ProcessEnv; error?: string } {
const normalizedEnv = normalizeExplicitCodexHomeEnv(env);
const codexHome = normalizedEnv.CODEX_HOME;
if (!codexHome) {
return { env: normalizedEnv };
}
if (isInformationalCodexInvocation(args)) {
return { env: normalizedEnv };
}
try {
fs.mkdirSync(codexHome, { recursive: true });
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code !== 'EEXIST') {
return {
env: normalizedEnv,
error: `[X] Unable to initialize CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`,
};
}
}
try {
if (!fs.statSync(codexHome).isDirectory()) {
return {
env: normalizedEnv,
error: `[X] CODEX_HOME path is not a directory: ${codexHome}`,
};
}
return { env: normalizedEnv };
} catch (err) {
const error = err as NodeJS.ErrnoException;
return {
env: normalizedEnv,
error: `[X] Unable to access CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`,
};
}
}
export class CodexAdapter implements TargetAdapter {
readonly type: TargetType = 'codex';
readonly displayName = 'Codex CLI';
@@ -207,6 +288,13 @@ export class CodexAdapter implements TargetAdapter {
return exitWithCleanup(1);
}
const codexHomePreparation = prepareExplicitCodexHome(env, args);
if (codexHomePreparation.error) {
console.error(codexHomePreparation.error);
return exitWithCleanup(1);
}
const launchEnv = codexHomePreparation.env;
const isWindows = process.platform === 'win32';
const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath);
@@ -216,7 +304,7 @@ export class CodexAdapter implements TargetAdapter {
child = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args],
{ stdio: 'inherit', windowsHide: true, env }
{ stdio: 'inherit', windowsHide: true, env: launchEnv }
);
} else if (needsShell) {
const cmdString = [codexPath, ...args].map(escapeShellArg).join(' ');
@@ -224,10 +312,10 @@ export class CodexAdapter implements TargetAdapter {
stdio: 'inherit',
windowsHide: true,
shell: true,
env,
env: launchEnv,
});
} else {
child = spawn(codexPath, args, { stdio: 'inherit', windowsHide: true, env });
child = spawn(codexPath, args, { stdio: 'inherit', windowsHide: true, env: launchEnv });
}
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
+35 -7
View File
@@ -5,6 +5,23 @@ import { escapeShellArg } from '../utils/shell-executor';
import type { TargetBinaryInfo } from './target-adapter';
const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides';
const CODEX_CONFIG_OVERRIDE_PROBE_ARGS = ['-c', 'model="gpt-5"', '--version'];
function buildWindowsCodexCandidates(matches: string[]): string[] {
const shellCandidates = matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry));
const bareCandidates = matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry));
const prioritized: string[] = [];
for (const entry of shellCandidates) {
if (/\.(cmd|bat)$/i.test(entry)) {
prioritized.push(entry.replace(/\.(cmd|bat)$/i, '.ps1'));
}
prioritized.push(entry);
}
prioritized.push(...bareCandidates);
return [...new Set(prioritized)];
}
function runCodexProbe(codexPath: string, args: string[]): string | undefined {
const isWindows = process.platform === 'win32';
@@ -49,9 +66,25 @@ export function readCodexVersion(codexPath: string): string | undefined {
return runCodexProbe(codexPath, ['--version'])?.trim();
}
function codexHelpAdvertisesConfigOverrides(helpText: string | undefined): boolean {
if (!helpText) {
return false;
}
return /(^|\n)\s*-c,\s*--config\b/m.test(helpText) || helpText.includes('--config <key=value>');
}
function codexSupportsConfigOverrideProbe(codexPath: string): boolean {
return !!runCodexProbe(codexPath, CODEX_CONFIG_OVERRIDE_PROBE_ARGS)?.trim();
}
function detectCodexFeatures(codexPath: string): readonly string[] {
if (codexSupportsConfigOverrideProbe(codexPath)) {
return [CODEX_CONFIG_OVERRIDE_FEATURE];
}
const helpText = runCodexProbe(codexPath, ['--help']);
return helpText?.includes('--config <key=value>') ? [CODEX_CONFIG_OVERRIDE_FEATURE] : [];
return codexHelpAdvertisesConfigOverrides(helpText) ? [CODEX_CONFIG_OVERRIDE_FEATURE] : [];
}
export function detectCodexCli(): string | null {
@@ -95,12 +128,7 @@ export function detectCodexCli(): string | null {
.map((entry) => entry.trim())
.filter(Boolean);
const candidates = isWindows
? [
...matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry)),
...matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry)),
]
: matches;
const candidates = isWindows ? buildWindowsCodexCandidates(matches) : matches;
for (const candidate of candidates) {
try {
+5
View File
@@ -25,6 +25,11 @@ export function getClaudeConfigDir(): string {
return getDefaultClaudeConfigDir();
}
/** Resolve user-scope Claude JSON config path (~/.claude.json). */
export function getClaudeUserConfigPath(): string {
return path.join(getCcsHome(), '.claude.json');
}
/** Resolve Claude settings.json path. */
export function getClaudeSettingsPath(): string {
return path.join(getClaudeConfigDir(), 'settings.json');
+34 -16
View File
@@ -1,15 +1,15 @@
/**
* WebSearch Manager - Manages WebSearch hook for CCS
* WebSearch Manager - Manages CCS WebSearch runtime
*
* WebSearch is a server-side tool executed by Anthropic's API.
* Third-party providers (gemini, agy, codex, qwen) don't have access.
* This manager installs a hook that uses deterministic local search backends,
* with legacy AI CLI tools kept only as optional fallback.
* CCS exposes a first-class local WebSearch tool for those profiles and keeps
* the legacy hook runtime only as a compatibility fallback.
*
* Simplified Architecture:
* - No MCP dependency for the default path
* - Uses real search providers first (DuckDuckGo, Brave)
* - Keeps Gemini/OpenCode/Grok as compatibility fallback only
* Runtime Architecture:
* - User-scope MCP server in ~/.claude.json for third-party profiles
* - Real search providers first (Exa, Tavily, Brave Search, DuckDuckGo)
* - Gemini/OpenCode/Grok retained as optional legacy fallback
*
* @module utils/websearch-manager
*/
@@ -53,6 +53,31 @@ export {
// Re-export hook environment
export { getWebSearchHookEnv } from './websearch/hook-env';
// Re-export MCP runtime helpers
export {
getWebSearchMcpServerName,
getWebSearchMcpServerPath,
installWebSearchMcpServer,
ensureWebSearchMcpConfig,
ensureWebSearchMcp,
uninstallWebSearchMcpServer,
removeWebSearchMcpConfig,
uninstallWebSearchMcp,
syncWebSearchMcpToConfigDir,
ensureWebSearchMcpOrThrow,
} from './websearch/mcp-installer';
// Re-export Claude launch arg helpers
export { appendThirdPartyWebSearchToolArgs } from './websearch/claude-tool-args';
// Re-export trace helpers
export {
appendWebSearchTrace,
createWebSearchTraceContext,
isWebSearchTraceEnabled,
readWebSearchTraceRecords,
} from './websearch/trace';
// Re-export status and readiness functions
export {
getWebSearchCliProviders,
@@ -62,7 +87,7 @@ export {
displayWebSearchStatus,
} from './websearch/status';
// Re-export profile hook injection
// Re-export profile compatibility hook injection
export { ensureProfileHooks, ensureProfileHooksOrThrow } from './websearch/profile-hook-injector';
// Import for local use
@@ -77,11 +102,4 @@ export function clearAllCliCaches(): void {
clearOpenCodeCliCache();
}
// ========== Backward Compatibility Exports ==========
/**
* @deprecated Use installWebSearchHook instead - MCP is no longer used
*/
export function ensureMcpWebSearch(): boolean {
return false;
}
export { ensureWebSearchMcp as ensureMcpWebSearch } from './websearch/mcp-installer';
+153
View File
@@ -0,0 +1,153 @@
/**
* Claude launch argument helpers for third-party WebSearch.
*/
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.';
function parseToolValue(rawValue: string): string[] {
return rawValue
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
function mergeToolValues(rawValues: string[], toolName: string): string {
const merged = rawValues.flatMap(parseToolValue);
if (!merged.includes(toolName)) {
merged.push(toolName);
}
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];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value && parseToolValue(value).includes(toolName)) {
return true;
}
continue;
}
if (!arg.startsWith(`${flag}=`)) {
continue;
}
const rawValue = arg.slice(flag.length + 1);
if (parseToolValue(rawValue).includes(toolName)) {
return true;
}
}
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);
if (hasToolInFlag(optionArgs, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) {
return args;
}
for (let index = 0; index < optionArgs.length; index += 1) {
const arg = optionArgs[index];
if (arg === DISALLOWED_TOOLS_FLAG) {
const currentValue = getImmediateFlagValue(optionArgs, index);
const mergedValue = mergeToolValues(
currentValue ? [currentValue] : [],
NATIVE_WEBSEARCH_TOOL
);
return [
...optionArgs.slice(0, index + 1),
mergedValue,
...optionArgs.slice(currentValue === null ? index + 1 : index + 2),
...trailingArgs,
];
}
if (arg.startsWith(`${DISALLOWED_TOOLS_FLAG}=`)) {
const rawValue = arg.slice(DISALLOWED_TOOLS_FLAG.length + 1);
return [
...optionArgs.slice(0, index),
`${DISALLOWED_TOOLS_FLAG}=${mergeToolValues([rawValue], NATIVE_WEBSEARCH_TOOL)}`,
...optionArgs.slice(index + 1),
...trailingArgs,
];
}
}
return [...optionArgs, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL, ...trailingArgs];
}
function ensureWebSearchSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
if (
hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, THIRD_PARTY_WEBSEARCH_STEERING_PROMPT)
) {
return args;
}
return [
...optionArgs,
APPEND_SYSTEM_PROMPT_FLAG,
THIRD_PARTY_WEBSEARCH_STEERING_PROMPT,
...trailingArgs,
];
}
export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] {
return ensureWebSearchSteeringPrompt(ensureDisallowedNativeWebSearchTool(args));
}
+9
View File
@@ -7,6 +7,7 @@
*/
import { getWebSearchConfig } from '../../config/unified-config-loader';
import { resolveAllowedWebSearchTraceFile } from './trace';
/**
* Get environment variables for WebSearch hook configuration.
@@ -19,6 +20,14 @@ export function getWebSearchHookEnv(): Record<string, string> {
const wsConfig = getWebSearchConfig();
const env: Record<string, string> = {};
if (process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1') {
env.CCS_WEBSEARCH_TRACE = '1';
}
const traceFileOverride = resolveAllowedWebSearchTraceFile(process.env);
if (traceFileOverride) {
env.CCS_WEBSEARCH_TRACE_FILE = traceFileOverride;
}
// Skip hook entirely if disabled
if (!wsConfig.enabled) {
env.CCS_WEBSEARCH_SKIP = '1';
+3 -2
View File
@@ -1,7 +1,7 @@
/**
* WebSearch Hook Installer
*
* Manages installation and uninstallation of the WebSearch hook.
* Manages installation and uninstallation of the legacy WebSearch hook runtime.
*
* @module utils/websearch/hook-installer
*/
@@ -70,7 +70,8 @@ export function hasWebSearchHook(): boolean {
/**
* Install WebSearch hook to ~/.ccs/hooks/
*
* This hook intercepts WebSearch and executes via Gemini CLI.
* This hook now serves as a compatibility fallback and a shared provider runtime
* for the first-class CCS WebSearch MCP server.
*
* @returns true if hook installed successfully
*/
+26 -1
View File
@@ -50,6 +50,31 @@ export { removeHookConfig } from './hook-config';
// Hook Environment
export { getWebSearchHookEnv } from './hook-env';
// MCP Runtime
export {
getWebSearchMcpServerName,
getWebSearchMcpServerPath,
installWebSearchMcpServer,
ensureWebSearchMcpConfig,
ensureWebSearchMcp,
uninstallWebSearchMcpServer,
removeWebSearchMcpConfig,
uninstallWebSearchMcp,
syncWebSearchMcpToConfigDir,
ensureWebSearchMcpOrThrow,
} from './mcp-installer';
// Claude launch args
export { appendThirdPartyWebSearchToolArgs } from './claude-tool-args';
// Trace helpers
export {
appendWebSearchTrace,
createWebSearchTraceContext,
isWebSearchTraceEnabled,
readWebSearchTraceRecords,
} from './trace';
// Status and Readiness
export {
getWebSearchCliProviders,
@@ -61,5 +86,5 @@ export {
export { WEBSEARCH_API_KEY_PROVIDERS, getWebSearchApiKeyStates } from './provider-secrets';
// Profile Hook Injection
// Profile compatibility hook injection
export { ensureProfileHooks, ensureProfileHooksOrThrow } from './profile-hook-injector';
+378
View File
@@ -0,0 +1,378 @@
/**
* WebSearch MCP installer and ~/.claude.json provisioning.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getWebSearchConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../config-manager';
import { getClaudeUserConfigPath } from '../claude-config-path';
import { info, warn } from '../ui';
import { InstanceManager } from '../../management/instance-manager';
import { installWebSearchHook } from './hook-installer';
import { appendWebSearchTrace } from './trace';
const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs';
const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch';
interface ClaudeUserConfig {
mcpServers?: Record<string, unknown>;
[key: string]: unknown;
}
interface ManagedWebSearchMcpConfig {
type: 'stdio';
command: 'node';
args: [string];
env: Record<string, string>;
}
function getCcsMcpDir(): string {
return path.join(getCcsDir(), 'mcp');
}
export function getWebSearchMcpServerName(): string {
return WEBSEARCH_MCP_SERVER_NAME;
}
export function getWebSearchMcpServerPath(): string {
return path.join(getCcsMcpDir(), WEBSEARCH_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 (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Existing WebSearch MCP server is unreadable: ${(error as Error).message}`)
);
}
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', WEBSEARCH_MCP_SERVER),
path.join(__dirname, '..', '..', 'lib', 'mcp', WEBSEARCH_MCP_SERVER),
path.join(__dirname, '..', 'lib', 'mcp', WEBSEARCH_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 removeManagedServerConfig(configPath: string): boolean {
if (!fs.existsSync(configPath)) {
return false;
}
const config = readClaudeUserConfig(configPath);
if (config === null) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`));
}
return false;
}
const existingServers =
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
? { ...(config.mcpServers as Record<string, unknown>) }
: {};
if (!(WEBSEARCH_MCP_SERVER_NAME in existingServers)) {
return false;
}
delete existingServers[WEBSEARCH_MCP_SERVER_NAME];
const nextConfig: ClaudeUserConfig = { ...config };
if (Object.keys(existingServers).length === 0) {
delete nextConfig.mcpServers;
} else {
nextConfig.mcpServers = existingServers;
}
try {
writeClaudeUserConfig(configPath, nextConfig);
if (process.env.CCS_DEBUG) {
console.error(info(`Removed WebSearch MCP config from ${configPath}`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Failed to remove WebSearch MCP config from ${configPath}: ${(error as Error).message}`
)
);
}
return false;
}
}
export function installWebSearchMcpServer(): boolean {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
appendWebSearchTrace('websearch_mcp_install_skipped', { reason: 'disabled' });
return false;
}
if (!installWebSearchHook()) {
appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'hook_unavailable' });
if (process.env.CCS_DEBUG) {
console.error(
warn('WebSearch MCP server install skipped because hook runtime is unavailable')
);
}
return false;
}
const sourcePath = resolveBundledServerSourcePath();
if (!sourcePath) {
appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'source_missing' });
if (process.env.CCS_DEBUG) {
console.error(warn(`WebSearch MCP server source not found: ${WEBSEARCH_MCP_SERVER}`));
}
return false;
}
const mcpDir = getCcsMcpDir();
if (!fs.existsSync(mcpDir)) {
fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 });
}
const serverPath = getWebSearchMcpServerPath();
if (hasMatchingContents(sourcePath, serverPath)) {
appendWebSearchTrace('websearch_mcp_install_ready', { serverPath });
return true;
}
const tempPath = getTempPath(serverPath);
try {
fs.copyFileSync(sourcePath, tempPath);
fs.chmodSync(tempPath, 0o755);
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, 0o755);
}
}
appendWebSearchTrace('websearch_mcp_install_ready', { serverPath });
return true;
} catch (error) {
appendWebSearchTrace('websearch_mcp_install_failed', {
reason: 'copy_failed',
error: (error as Error).message,
});
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to install WebSearch MCP server: ${(error as Error).message}`));
}
return false;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
export function ensureWebSearchMcpConfig(): boolean {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
appendWebSearchTrace('websearch_mcp_config_skipped', { reason: 'disabled' });
return false;
}
const claudeUserConfigPath = getClaudeUserConfigPath();
const claudeUserConfigDir = path.dirname(claudeUserConfigPath);
const config = readClaudeUserConfig(claudeUserConfigPath);
if (config === null) {
appendWebSearchTrace('websearch_mcp_config_failed', { reason: 'malformed_user_config' });
if (process.env.CCS_DEBUG) {
console.error(warn('Malformed ~/.claude.json prevents WebSearch MCP provisioning'));
}
return false;
}
if (!fs.existsSync(claudeUserConfigDir)) {
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
}
const existingServers =
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>)
: {};
const desiredServerConfig: ManagedWebSearchMcpConfig = {
type: 'stdio',
command: 'node',
args: [getWebSearchMcpServerPath()],
env: {},
};
const currentConfig = existingServers[WEBSEARCH_MCP_SERVER_NAME];
if (
typeof currentConfig === 'object' &&
currentConfig !== null &&
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
) {
appendWebSearchTrace('websearch_mcp_config_ready', { configPath: claudeUserConfigPath });
return true;
}
const nextConfig: ClaudeUserConfig = {
...config,
mcpServers: {
...existingServers,
[WEBSEARCH_MCP_SERVER_NAME]: desiredServerConfig,
},
};
try {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
appendWebSearchTrace('websearch_mcp_config_ready', { configPath: claudeUserConfigPath });
if (process.env.CCS_DEBUG) {
console.error(info(`Ensured WebSearch MCP config in ${claudeUserConfigPath}`));
}
return true;
} catch (error) {
appendWebSearchTrace('websearch_mcp_config_failed', {
reason: 'write_failed',
configPath: claudeUserConfigPath,
error: (error as Error).message,
});
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`));
}
return false;
}
}
export function ensureWebSearchMcp(): boolean {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
appendWebSearchTrace('websearch_mcp_ensure_skipped', { reason: 'disabled' });
return false;
}
const installed = installWebSearchMcpServer();
const configured = installed && ensureWebSearchMcpConfig();
appendWebSearchTrace('websearch_mcp_ensure_result', { installed, configured });
return installed && configured;
}
export function syncWebSearchMcpToConfigDir(claudeConfigDir: string | undefined): boolean {
if (!claudeConfigDir) {
appendWebSearchTrace('websearch_mcp_sync_skipped', { reason: 'missing_config_dir' });
return false;
}
const synced = new InstanceManager().syncMcpServers(claudeConfigDir);
appendWebSearchTrace('websearch_mcp_sync_result', { claudeConfigDir, synced });
return synced;
}
export function uninstallWebSearchMcpServer(): boolean {
const serverPath = getWebSearchMcpServerPath();
if (!fs.existsSync(serverPath)) {
return false;
}
try {
fs.unlinkSync(serverPath);
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to remove WebSearch MCP server: ${(error as Error).message}`));
}
return false;
}
}
export function removeWebSearchMcpConfig(): 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 uninstallWebSearchMcp(): boolean {
const removedConfig = removeWebSearchMcpConfig();
const removedServer = uninstallWebSearchMcpServer();
return removedConfig || removedServer;
}
export function ensureWebSearchMcpOrThrow(): void {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
return;
}
if (!ensureWebSearchMcp()) {
throw new Error('WebSearch is enabled, but CCS could not prepare the local WebSearch tool.');
}
}
+5 -3
View File
@@ -1,8 +1,9 @@
/**
* Profile Hook Injector
*
* Injects WebSearch hooks into per-profile settings files.
* This replaces the global ~/.claude/settings.json approach.
* Injects the legacy WebSearch compatibility hook into per-profile settings files.
* The first-class runtime now uses the CCS-managed MCP server; these hooks remain
* for compatibility and migration safety only.
*
* @module utils/websearch/profile-hook-injector
*/
@@ -76,7 +77,8 @@ function migrateGlobalHook(): void {
}
/**
* Ensure WebSearch hook is configured in profile's settings file
* Ensure the legacy WebSearch compatibility hook is configured in a profile's
* settings file when that path is still needed.
*
* @param profileName - Name of the profile (e.g., 'agy', 'gemini', 'glm')
* @returns true if hook is configured (existing or newly added)
+91 -1
View File
@@ -6,18 +6,105 @@
* @module utils/websearch/status
*/
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { ok, warn, fail, info } from '../ui';
import { getWebSearchConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../config-manager';
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';
const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
type ProviderCooldown = {
reason: string;
until: number;
};
function hasEnvValue(name: string): boolean {
return (process.env[name] || '').trim().length > 0;
}
function getProviderStatePath(): string {
return join(getCcsDir(), 'cache', PROVIDER_STATE_FILE);
}
function readProviderCooldowns(now = Date.now()): Record<string, ProviderCooldown> {
try {
const statePath = getProviderStatePath();
if (!existsSync(statePath)) {
return {};
}
const parsed = JSON.parse(readFileSync(statePath, 'utf8')) as {
cooldowns?: Record<string, { reason?: unknown; until?: unknown }>;
};
const nextCooldowns: Record<string, ProviderCooldown> = {};
for (const [providerId, entry] of Object.entries(parsed.cooldowns || {})) {
const until = Number.parseInt(String(entry?.until || ''), 10);
if (!Number.isFinite(until) || until <= now) {
continue;
}
nextCooldowns[providerId] = {
reason: typeof entry?.reason === 'string' ? entry.reason : 'rate_limited',
until,
};
}
return nextCooldowns;
} catch {
return {};
}
}
function formatCooldownDuration(until: number, now = Date.now()): string {
const remainingSec = Math.max(1, Math.ceil((until - now) / 1000));
if (remainingSec >= 3600) {
return `~${Math.ceil(remainingSec / 3600)}h`;
}
if (remainingSec >= 60) {
return `~${Math.ceil(remainingSec / 60)}m`;
}
return `~${remainingSec}s`;
}
function formatCooldownReason(reason: string): string {
switch (reason) {
case 'quota_exhausted':
return 'quota exhaustion';
case 'rate_limited':
return 'rate limiting';
default:
return 'a temporary provider error';
}
}
function applyCooldownStatus(
provider: WebSearchCliInfo,
cooldowns: Record<string, ProviderCooldown>,
now = Date.now()
): WebSearchCliInfo {
if (!(provider.enabled && provider.available)) {
return provider;
}
const cooldown = cooldowns[provider.id];
if (!cooldown) {
return provider;
}
return {
...provider,
available: false,
detail: `Cooling down ${formatCooldownDuration(cooldown.until, now)} after ${formatCooldownReason(cooldown.reason)}`,
};
}
function getLegacyProviderStatuses(): WebSearchCliInfo[] {
const wsConfig = getWebSearchConfig();
const geminiStatus = getGeminiCliStatus();
@@ -86,6 +173,7 @@ function getLegacyProviderStatuses(): WebSearchCliInfo[] {
export function getWebSearchCliProviders(): WebSearchCliInfo[] {
const wsConfig = getWebSearchConfig();
const apiKeyStates = getWebSearchApiKeyStates();
const cooldowns = readProviderCooldowns();
const providers: WebSearchCliInfo[] = [
{
id: 'exa',
@@ -152,7 +240,9 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] {
},
];
return [...providers, ...getLegacyProviderStatuses()];
return [...providers, ...getLegacyProviderStatuses()].map((provider) =>
applyCooldownStatus(provider, cooldowns)
);
}
/**
+234
View File
@@ -0,0 +1,234 @@
/**
* Best-effort WebSearch trace helpers.
*
* Writes opt-in JSONL trace records to ~/.ccs/logs/websearch-trace.jsonl so
* CCS can explain launch intent, MCP exposure, provider selection, and likely
* bypass scenarios without polluting Claude/MCP stdout.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir } from '../config-manager';
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.';
function parseToolValue(rawValue: string): string[] {
return rawValue
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
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];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value && parseToolValue(value).includes(toolName)) {
return true;
}
continue;
}
if (arg.startsWith(`${flag}=`)) {
const rawValue = arg.slice(flag.length + 1);
if (parseToolValue(rawValue).includes(toolName)) {
return true;
}
}
}
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) {
if (getImmediateFlagValue(args, index) === expectedValue) {
return true;
}
continue;
}
if (arg === `${flag}=${expectedValue}`) {
return true;
}
}
return false;
}
function normalizeSafePrefix(inputPath: string): string {
return `${path.resolve(inputPath)}${path.sep}`;
}
function getSafeTracePrefixes(): string[] {
return [
normalizeSafePrefix(path.join(getCcsDir(), 'logs')),
normalizeSafePrefix(os.tmpdir()),
normalizeSafePrefix('/var/log'),
];
}
export function resolveAllowedWebSearchTraceFile(
env: NodeJS.ProcessEnv = process.env
): string | null {
const configured = env.CCS_WEBSEARCH_TRACE_FILE?.trim();
if (!configured) {
return null;
}
const resolved = path.resolve(configured);
if (getSafeTracePrefixes().some((prefix) => resolved.startsWith(prefix))) {
return resolved;
}
return null;
}
function getTraceFilePath(env: NodeJS.ProcessEnv): string {
return resolveAllowedWebSearchTraceFile(env) ?? path.join(getCcsDir(), 'logs', TRACE_FILE_NAME);
}
export function isWebSearchTraceEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return env.CCS_WEBSEARCH_TRACE === '1' || env.CCS_DEBUG === '1';
}
export function appendWebSearchTrace(
event: string,
payload: Record<string, unknown> = {},
env: NodeJS.ProcessEnv = process.env
): void {
if (!isWebSearchTraceEnabled(env)) {
return;
}
try {
const traceFilePath = getTraceFilePath(env);
fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
fs.appendFileSync(
traceFilePath,
JSON.stringify({
at: new Date().toISOString(),
event,
launchId: env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null,
launcher: env.CCS_WEBSEARCH_TRACE_LAUNCHER || null,
profileType: env.CCS_PROFILE_TYPE || null,
pid: process.pid,
...payload,
}) + '\n',
'utf8'
);
} catch {
// Tracing must never affect launch behavior.
}
}
export function readWebSearchTraceRecords(
launchId: string,
env: NodeJS.ProcessEnv = process.env
): Array<Record<string, unknown>> {
if (!launchId) {
return [];
}
try {
const traceFilePath = getTraceFilePath(env);
if (!fs.existsSync(traceFilePath)) {
return [];
}
return fs
.readFileSync(traceFilePath, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>)
.filter((record) => record.launchId === launchId);
} catch {
return [];
}
}
function buildLaunchId(): string {
const random = Math.random().toString(36).slice(2, 10);
return `websearch-${Date.now()}-${process.pid}-${random}`;
}
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
),
};
}
export function createWebSearchTraceContext(params: {
launcher: string;
args: string[];
cwd?: string;
profile?: string;
profileType?: string;
settingsPath?: string;
claudeConfigDir?: string;
env?: NodeJS.ProcessEnv;
}): Record<string, string> {
const env = params.env ?? process.env;
if (!isWebSearchTraceEnabled(env)) {
return {};
}
const launchId = buildLaunchId();
const traceEnv: Record<string, string> = {
CCS_WEBSEARCH_TRACE: '1',
CCS_WEBSEARCH_TRACE_LAUNCH_ID: launchId,
CCS_WEBSEARCH_TRACE_LAUNCHER: params.launcher,
};
const traceFileOverride = resolveAllowedWebSearchTraceFile(env);
if (traceFileOverride) {
traceEnv.CCS_WEBSEARCH_TRACE_FILE = traceFileOverride;
}
appendWebSearchTrace(
'ccs_websearch_launch',
{
launcher: params.launcher,
profile: params.profile || null,
profileType: params.profileType || null,
cwd: params.cwd || null,
settingsPath: params.settingsPath || null,
claudeConfigDir: params.claudeConfigDir || null,
...summarizeLaunchArgs(params.args),
},
{
...env,
...traceEnv,
CCS_PROFILE_TYPE: params.profileType || env.CCS_PROFILE_TYPE || '',
}
);
return traceEnv;
}
+4
View File
@@ -63,6 +63,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Auth middleware (protects API routes when enabled)
app.use(authMiddleware);
// CLIProxy local reverse proxy (avoids cross-origin issues in Docker)
const cliproxyLocalProxy = (await import('./routes/cliproxy-local-proxy')).default;
app.use('/api/cliproxy-local', cliproxyLocalProxy);
// REST API routes (modularized)
const { apiRoutes } = await import('./routes/index');
app.use('/api', apiRoutes);
+2 -1
View File
@@ -18,6 +18,7 @@ import {
bulkResumeAccounts,
soloAccount,
} from '../../cliproxy/account-manager';
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
import {
DEFAULT_ACCOUNT_CONTINUITY_MODE,
@@ -122,7 +123,7 @@ router.get('/', (_req: Request, res: Response): void => {
continue;
}
// Use unique ID for key to prevent collisions between accounts with same nickname/email
const displayName = acct.nickname || acct.email || acct.id;
const displayName = acct.nickname || formatAccountDisplayName(acct);
const rawKey = `${provider}:${acct.id}`;
const key = buildCliproxyAccountKey(rawKey, merged);
if (!key) {
@@ -24,7 +24,6 @@ import {
pauseAccount as pauseAccountFn,
resumeAccount as resumeAccountFn,
touchAccount,
extractAccountIdFromTokenFile,
hasAccountNameConflict,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
@@ -932,7 +931,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
getProviderTokenDir(localProvider),
pendingAuth.nickname,
false,
extractAccountIdFromTokenFile(tokenSnapshot.file, tokenSnapshot.email)
tokenSnapshot.file
);
if (!account) {
@@ -0,0 +1,151 @@
/**
* CLIProxy Local Reverse Proxy
*
* Proxies requests from the dashboard to the local CLIProxy service
* running on 127.0.0.1 inside the same host/container.
*
* Mounted at: /api/cliproxy-local/* -> http://127.0.0.1:{port}/*
*/
import http from 'http';
import { Request, Response, Router } from 'express';
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
export interface CliproxyLocalProxyDeps {
enforceAccess?: (req: Request, res: Response) => boolean;
request?: typeof http.request;
resolveTargetPort?: () => number;
}
/** Proxy request timeout in milliseconds (30 seconds) */
const PROXY_TIMEOUT_MS = 30_000;
function resolveLocalCliproxyPort(): number {
try {
const config = loadOrCreateUnifiedConfig();
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
} catch {
return CLIPROXY_DEFAULT_PORT;
}
}
function isJsonContentType(contentType: string | string[] | undefined): boolean {
const values = Array.isArray(contentType) ? contentType : [contentType];
return values.some((value) => value?.toLowerCase().includes('application/json') === true);
}
function buildProxyBody(req: Request): Buffer | undefined {
// If express.json() parsed the body (content-type is JSON and req.body is populated),
// re-serialize it since the original request stream was consumed by the middleware.
if (
!isJsonContentType(req.headers['content-type']) ||
req.body === undefined ||
req.body === null
) {
return undefined;
}
// express.json() sets req.body to the parsed value — re-serialize for the proxy target
return Buffer.from(JSON.stringify(req.body));
}
function buildProxyHeaders(
headers: http.IncomingHttpHeaders,
port: number,
bodyBuffer?: Buffer
): http.IncomingHttpHeaders {
const proxyHeaders: http.IncomingHttpHeaders = {
...headers,
host: `127.0.0.1:${port}`,
};
delete proxyHeaders.connection;
if (bodyBuffer) {
delete proxyHeaders['transfer-encoding'];
proxyHeaders['content-length'] = String(bodyBuffer.length);
}
return proxyHeaders;
}
export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}): Router {
const router = Router();
const enforceAccess =
deps.enforceAccess ??
((req: Request, res: Response) =>
requireLocalAccessWhenAuthDisabled(
req,
res,
'CLIProxy local proxy requires localhost access when dashboard auth is disabled.'
));
const createRequest = deps.request ?? http.request;
const resolveTargetPort = deps.resolveTargetPort ?? resolveLocalCliproxyPort;
router.use((req: Request, res: Response, next) => {
if (enforceAccess(req, res)) {
next();
}
});
router.all('/*', (req: Request, res: Response) => {
const targetPort = resolveTargetPort();
const targetPath = req.url || '/';
const bodyBuffer = buildProxyBody(req);
const proxyReq = createRequest(
{
hostname: '127.0.0.1',
port: targetPort,
path: targetPath,
method: req.method,
headers: buildProxyHeaders(req.headers, targetPort, bodyBuffer),
timeout: PROXY_TIMEOUT_MS,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
// Manual streaming instead of pipe() for Bun runtime compatibility
proxyRes.on('data', (chunk: Buffer) => res.write(chunk));
proxyRes.on('end', () => res.end());
}
);
proxyReq.on('timeout', () => proxyReq.destroy());
proxyReq.on('error', () => {
if (!res.headersSent) {
res.status(502).json({ error: 'CLIProxy is not reachable' });
}
});
// 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', () => {
if (!res.writableEnded) {
proxyReq.destroy();
}
});
if (bodyBuffer) {
proxyReq.end(bodyBuffer);
return;
}
// For methods without a body (GET, HEAD, etc.) or when express.json()
// has already consumed the stream, end the request immediately.
const hasBody = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS';
if (!hasBody) {
proxyReq.end();
return;
}
req.pipe(proxyReq, { end: true });
});
return router;
}
export default createCliproxyLocalProxyRouter();
@@ -646,7 +646,7 @@ export function summarizeCodexMcpServers(value: unknown): CodexMcpServerDiagnost
.sort((left, right) => left.name.localeCompare(right.name));
}
function getCodexSupportMatrix(): CodexSupportMatrixEntry[] {
function getCodexSupportMatrix(supportsManagedRouting: boolean): CodexSupportMatrixEntry[] {
return [
{
id: 'default',
@@ -657,14 +657,18 @@ function getCodexSupportMatrix(): CodexSupportMatrixEntry[] {
{
id: 'cliproxy-provider-codex',
label: 'cliproxy provider=codex',
supported: true,
notes: 'Routed through the CLIProxy Codex Responses bridge.',
supported: supportsManagedRouting,
notes: supportsManagedRouting
? 'Routed through the CLIProxy Codex Responses bridge.'
: 'Requires a Codex build that exposes --config overrides.',
},
{
id: 'settings-with-bridge',
label: 'settings with bridge metadata',
supported: true,
notes: 'Supported when the resolved API profile points at a Codex CLIProxy bridge.',
supported: supportsManagedRouting,
notes: supportsManagedRouting
? 'Supported when the resolved API profile points at a Codex CLIProxy bridge.'
: 'Requires a Codex build that exposes --config overrides.',
},
{
id: 'cliproxy-composite',
@@ -696,6 +700,7 @@ function getCodexSupportMatrix(): CodexSupportMatrixEntry[] {
export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiagnostics> {
const paths = resolveCodexConfigPaths();
const binaryInfo = getCodexBinaryInfo();
const supportsConfigOverrides = !!binaryInfo && codexBinarySupportsConfigOverrides(binaryInfo);
const docsReference = getCompatibleCliDocsReference('codex');
const fileProbe = await probeTomlObjectFile(
paths.configPath,
@@ -715,12 +720,12 @@ export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiag
const features = summarizeCodexFeatureFlags(config?.features);
const projectTrust = summarizeCodexProjectTrust(config?.projects);
const mcpServers = summarizeCodexMcpServers(config?.mcp_servers);
const supportMatrix = getCodexSupportMatrix();
const supportMatrix = getCodexSupportMatrix(supportsConfigOverrides);
const warnings: string[] = [];
if (!binaryInfo) {
warnings.push('Codex binary is not detected in PATH or CCS_CODEX_PATH.');
} else if (!codexBinarySupportsConfigOverrides(binaryInfo)) {
} else if (!supportsConfigOverrides) {
warnings.push(
'This Codex build does not expose --config overrides required for CCS-backed Codex routing.'
);
@@ -766,7 +771,7 @@ export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiag
source: process.env.CCS_CODEX_PATH ? 'CCS_CODEX_PATH' : binaryInfo ? 'PATH' : 'missing',
version: binaryInfo?.version ?? null,
overridePath: process.env.CCS_CODEX_PATH || null,
supportsConfigOverrides: codexBinarySupportsConfigOverrides(binaryInfo),
supportsConfigOverrides,
},
file: fileProbe.diagnostics,
workspacePath: process.cwd(),
+5
View File
@@ -175,6 +175,11 @@ describe('cross-platform', () => {
'dedicated ccsxp shortcut entrypoint should exist'
);
assert(packageJson.scripts, 'package.json should have scripts field');
assert.strictEqual(
packageJson.scripts.prepack,
'bun run build:all',
'prepack should rebuild packaged assets before npm pack/publish'
);
});
});
});
@@ -128,7 +128,7 @@ describe('profile lifecycle service', () => {
expect(result.skipped).toEqual([]);
});
it('does not register orphan profiles when WebSearch hook setup fails', async () => {
it('does not register orphan profiles when local WebSearch tool setup fails', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
@@ -152,7 +152,7 @@ describe('profile lifecycle service', () => {
expect(copyFileSpy).toHaveBeenCalled();
expect(result.registered).toEqual([]);
expect(result.skipped).toHaveLength(1);
expect(result.skipped[0]?.reason).toContain('could not prepare the profile hook');
expect(result.skipped[0]?.reason).toContain('could not prepare the local WebSearch tool');
expect(config.profiles.extra).toBeUndefined();
});
@@ -240,7 +240,7 @@ describe('profile lifecycle service', () => {
expect(result.error).toContain('Invalid source profile name');
});
it('rolls back copied settings when WebSearch hook setup fails', async () => {
it('rolls back copied settings when local WebSearch tool setup fails', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
@@ -263,7 +263,7 @@ describe('profile lifecycle service', () => {
const result = await runInScopedCcsDir(() => copyApiProfile('source', 'copy-dest'));
expect(result.success).toBe(false);
expect(result.error).toContain('could not prepare the profile hook');
expect(result.error).toContain('could not prepare the local WebSearch tool');
expect(copyFileSpy).toHaveBeenCalled();
expect(fs.existsSync(path.join(ccsDir, 'copy-dest.settings.json'))).toBe(false);
});
@@ -287,7 +287,7 @@ describe('profile lifecycle service', () => {
expect(result.error).toContain('Invalid bundle profile target');
});
it('rolls back imported settings when WebSearch hook setup fails', async () => {
it('rolls back imported settings when local WebSearch tool setup fails', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
@@ -314,7 +314,7 @@ describe('profile lifecycle service', () => {
);
expect(result.success).toBe(false);
expect(result.error).toContain('could not prepare the profile hook');
expect(result.error).toContain('could not prepare the local WebSearch tool');
expect(copyFileSpy).toHaveBeenCalled();
expect(fs.existsSync(path.join(ccsDir, 'import-failure.settings.json'))).toBe(false);
});
@@ -104,7 +104,7 @@ describe('profile-writer Anthropic direct', () => {
expect(settings.env.ANTHROPIC_API_KEY).toBe('');
});
it('rolls back the created settings file when WebSearch hook installation fails', () => {
it('rolls back the created settings file when local WebSearch tool setup fails', () => {
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
throw new Error('copy failed');
});
@@ -117,7 +117,7 @@ describe('profile-writer Anthropic direct', () => {
);
expect(result.success).toBe(false);
expect(result.error).toContain('could not prepare the profile hook');
expect(result.error).toContain('could not prepare the local WebSearch tool');
expect(copyFileSpy).toHaveBeenCalled();
expect(fs.existsSync(path.join(tempHome, '.ccs', 'hook-failure.settings.json'))).toBe(false);
});
@@ -116,4 +116,41 @@ describe('registerAccount optional nickname flow', () => {
expect(match).toBeNull();
});
it('treats duplicate Codex emails as distinct accounts and rejects bare-email lookups', async () => {
const result = await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'codex-04a0f049-kaidu.kd@gmail.com-team.json');
writeTokenFile(homeDir, 'codex-kaidu.kd@gmail.com-free.json');
const { registerAccount, getProviderAccounts, findAccountByQuery } = await loadAccountManager();
const team = registerAccount(
'codex',
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
'kaidu.kd@gmail.com'
);
const free = registerAccount(
'codex',
'codex-kaidu.kd@gmail.com-free.json',
'kaidu.kd@gmail.com'
);
return {
team,
free,
accounts: getProviderAccounts('codex'),
ambiguousLookup: findAccountByQuery('codex', 'kaidu.kd@gmail.com'),
teamLookup: findAccountByQuery('codex', 'kaidu.kd@gmail.com#04a0f049-team'),
freeLookup: findAccountByQuery('codex', free.id),
};
});
expect(result.team.id).toBe('kaidu.kd@gmail.com');
expect(result.free.id).toBe('kaidu.kd@gmail.com#free');
expect(result.accounts.map((account) => account.id).sort()).toEqual([
'kaidu.kd@gmail.com#04a0f049-team',
'kaidu.kd@gmail.com#free',
]);
expect(result.ambiguousLookup).toBeNull();
expect(result.teamLookup?.id).toBe('kaidu.kd@gmail.com#04a0f049-team');
expect(result.freeLookup?.id).toBe('kaidu.kd@gmail.com#free');
});
});
@@ -17,6 +17,7 @@ let buildCodexQuotaWindows: typeof import('../../../src/cliproxy/quota-fetcher-c
let buildCodexCoreUsageSummary: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexCoreUsageSummary;
let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota;
let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels;
let registerAccount: typeof import('../../../src/cliproxy/account-manager').registerAccount;
function createCodexAccount(
accountId: string,
@@ -40,6 +41,7 @@ beforeEach(async () => {
);
mock.module('../../../src/cliproxy/config-generator', () => configGenerator);
mock.module('../../../src/cliproxy/account-manager', () => accountManager);
({ registerAccount } = accountManager);
({
buildCodexQuotaWindows,
@@ -363,14 +365,18 @@ describe('Codex Quota Fetcher', () => {
});
describe('fetchCodexQuota failure mapping', () => {
function createValidCodexAccount(email: string, accountId = `workspace-${email}`): void {
function createValidCodexAccount(
email: string,
accountId = `workspace-${email}`,
tokenFile?: string
): void {
createCodexAccount(email, {
access_token: 'test-token',
account_id: accountId,
expired: '2099-01-01T00:00:00.000Z',
email,
type: 'codex',
});
}, tokenFile);
}
it('maps deactivated workspace 402 responses to structured metadata', async () => {
@@ -409,6 +415,92 @@ describe('Codex Quota Fetcher', () => {
expect(result.actionHint).toContain('ccs cliproxy auth codex');
});
it('uses the registry token file for duplicate-email Codex accounts', async () => {
createValidCodexAccount(
'kaidu.kd@gmail.com',
'workspace-team',
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
);
createValidCodexAccount(
'kaidu.kd@gmail.com',
'workspace-free',
'codex-kaidu.kd@gmail.com-free.json'
);
registerAccount(
'codex',
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
'kaidu.kd@gmail.com'
);
const freeAccount = registerAccount(
'codex',
'codex-kaidu.kd@gmail.com-free.json',
'kaidu.kd@gmail.com'
);
const fetchSpy = mock((input: RequestInfo | URL, init?: RequestInit) =>
Promise.resolve(
new Response(
JSON.stringify({
plan_type: 'free',
rate_limit: {
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)
) as typeof fetch;
global.fetch = fetchSpy;
const result = await fetchCodexQuota(freeAccount.id);
const requestInit = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined;
const headers = new Headers(requestInit?.headers);
expect(result.success).toBe(true);
expect(headers.get('ChatGPT-Account-Id')).toBe('workspace-free');
});
it('does not guess a duplicate-email Codex auth file when the registry entry is missing', async () => {
createValidCodexAccount(
'kaidu.kd@gmail.com',
'workspace-team',
'codex-legacy-slot-a.json'
);
createValidCodexAccount(
'kaidu.kd@gmail.com',
'workspace-free',
'codex-legacy-slot-b.json'
);
const fetchSpy = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
plan_type: 'free',
rate_limit: {
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)
) as typeof fetch;
global.fetch = fetchSpy;
const result = await fetchCodexQuota('kaidu.kd@gmail.com#04a0f049-team');
expect(result.success).toBe(false);
expect(result.errorCode).toBe('auth_file_missing');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('maps 403 responses to forbidden metadata', async () => {
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
@@ -288,4 +288,45 @@ describe('buildCliproxyStatsFromUsageResponse', () => {
});
expect(stats.requestsByProvider).toEqual({ codex: 1, 'ccs-internal-managed': 2 });
});
it('derives duplicate-email Codex account ids from auth file metadata', () => {
const usage = createInternallyBucketedUsage([
createDetail({ auth_index: 'codex-team', source: 'kaidu.kd@gmail.com' }),
createDetail({
auth_index: 'codex-free',
source: 'kaidu.kd@gmail.com',
timestamp: '2025-03-26T10:01:00.000Z',
failed: true,
}),
]);
const authFiles: CliproxyManagementAuthFile[] = [
{
auth_index: 'codex-team',
provider: 'codex',
email: 'kaidu.kd@gmail.com',
name: 'codex-04a0f049-kaidu.kd@gmail.com-team.json',
},
{
auth_index: 'codex-free',
provider: 'codex',
email: 'kaidu.kd@gmail.com',
name: 'codex-kaidu.kd@gmail.com-free.json',
},
];
const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles });
expect(stats.accountStats['codex:kaidu.kd@gmail.com#04a0f049-team']).toMatchObject({
provider: 'codex',
source: 'kaidu.kd@gmail.com#04a0f049-team',
successCount: 1,
failureCount: 0,
});
expect(stats.accountStats['codex:kaidu.kd@gmail.com#free']).toMatchObject({
provider: 'codex',
source: 'kaidu.kd@gmail.com#free',
successCount: 0,
failureCount: 1,
});
});
});
@@ -0,0 +1,438 @@
import { describe, expect, it } from 'bun:test';
import { spawn } from 'child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-websearch-server.cjs');
function encodeMessage(message: unknown): string {
return `${JSON.stringify(message)}\n`;
}
function encodeLegacyMessage(message: unknown): string {
const body = JSON.stringify(message);
return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
}
function collectResponses(
child: ReturnType<typeof spawn>,
expectedCount: number
): Promise<Array<Record<string, unknown>>> {
return new Promise((resolve, reject) => {
let buffer = Buffer.alloc(0);
const responses: Array<Record<string, unknown>> = [];
const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 5000);
function tryParse(): void {
while (true) {
const startsWithLegacyHeaders = buffer
.slice(0, Math.min(buffer.length, 32))
.toString('utf8')
.toLowerCase()
.startsWith('content-length:');
let body: string;
if (startsWithLegacyHeaders) {
const headerEnd = buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
return;
}
const headerText = buffer.slice(0, headerEnd).toString('utf8');
const match = headerText.match(/content-length:\s*(\d+)/i);
if (!match) {
reject(new Error('Missing Content-Length header'));
return;
}
const contentLength = Number.parseInt(match[1], 10);
const messageEnd = headerEnd + 4 + contentLength;
if (buffer.length < messageEnd) {
return;
}
body = buffer.slice(headerEnd + 4, messageEnd).toString('utf8');
buffer = buffer.slice(messageEnd);
} else {
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex === -1) {
return;
}
body = buffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
buffer = buffer.slice(newlineIndex + 1);
if (!body) {
continue;
}
}
responses.push(JSON.parse(body) as Record<string, unknown>);
if (responses.length >= expectedCount) {
clearTimeout(timer);
resolve(responses);
return;
}
}
}
child.stdout.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
try {
tryParse();
} catch (error) {
clearTimeout(timer);
reject(error);
}
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.stderr.on('data', () => {
// Ignore debug noise in tests.
});
});
}
function waitForClose(child: ReturnType<typeof spawn>): Promise<number | null> {
return new Promise((resolve, reject) => {
child.once('close', (code) => resolve(code));
child.once('error', reject);
});
}
describe('ccs-websearch MCP server', () => {
it('lists the CCS WebSearch tool and returns provider-backed results', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const html = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
<a class="result__snippet">Example snippet</a>
`.trim();
writeFileSync(
preloadPath,
`global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`,
'utf8'
);
const child = spawn('node', ['-r', preloadPath, serverPath], {
env: {
...process.env,
CCS_PROFILE_TYPE: 'settings',
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_TAVILY: '0',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 3);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }));
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'WebSearch', arguments: { query: 'btc price' } },
})
);
const responses = await responsesPromise;
const toolsList = responses.find((message) => message.id === 2);
const toolCall = responses.find((message) => message.id === 3);
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: {
query: {
type: 'string',
description:
'Web query to resolve through CCS providers. Prefer this tool over ad hoc Bash/curl lookups when you need current web information.',
},
},
required: ['query'],
additionalProperties: false,
},
},
],
});
expect(toolCall?.result).toBeDefined();
expect(
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
).toContain('CCS local WebSearch evidence');
expect(
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
).toContain('Provider: DuckDuckGo');
} finally {
child.kill();
rmSync(tempDir, { recursive: true, force: true });
}
});
it('accepts the legacy search alias for direct calls', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const html = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
<a class="result__snippet">Example snippet</a>
`.trim();
writeFileSync(
preloadPath,
`global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`,
'utf8'
);
const child = spawn('node', ['-r', preloadPath, serverPath], {
env: {
...process.env,
CCS_PROFILE_TYPE: 'settings',
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_TAVILY: '0',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 2);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'search', arguments: { query: 'btc price' } },
})
);
const responses = await responsesPromise;
const toolCall = responses.find((message) => message.id === 2);
expect(toolCall?.result).toBeDefined();
expect(
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
).toContain('CCS local WebSearch evidence');
expect(
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
).toContain('Provider: DuckDuckGo');
} finally {
child.kill();
rmSync(tempDir, { recursive: true, force: true });
}
});
it('hides the tool for native account profiles', async () => {
const child = spawn('node', [serverPath], {
env: {
...process.env,
CCS_PROFILE_TYPE: 'account',
CCS_WEBSEARCH_ENABLED: '1',
CCS_WEBSEARCH_SKIP: '1',
CCS_WEBSEARCH_DUCKDUCKGO: '1',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 2);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }));
const responses = await responsesPromise;
const toolsList = responses.find((message) => message.id === 2);
expect(toolsList?.result).toEqual({ tools: [] });
} finally {
child.kill();
}
});
it('writes trace records for exposure, tool calls, provider success, and session summary', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-trace-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const ccsHome = join(tempDir, 'home');
const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
const html = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
<a class="result__snippet">Example snippet</a>
`.trim();
writeFileSync(
preloadPath,
`global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`,
'utf8'
);
const child = spawn('node', ['-r', preloadPath, serverPath], {
env: {
...process.env,
CCS_HOME: ccsHome,
CCS_PROFILE_TYPE: 'settings',
CCS_WEBSEARCH_TRACE: '1',
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'mcp-trace-test',
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
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_TAVILY: '0',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 3);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }));
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'WebSearch', arguments: { query: 'btc price' } },
})
);
await responsesPromise;
} finally {
child.kill();
await waitForClose(child);
}
const traceEvents = readFileSync(tracePath, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(
traceEvents.some((event) => event.event === 'mcp_tools_list' && event.exposed === true)
).toBe(true);
expect(
traceEvents.some(
(event) => event.event === 'mcp_tool_call_received' && event.toolName === 'WebSearch'
)
).toBe(true);
expect(
traceEvents.some(
(event) =>
event.event === 'websearch_provider_success' && event.providerName === 'DuckDuckGo'
)
).toBe(true);
expect(
traceEvents.some(
(event) =>
event.event === 'mcp_session_summary' &&
event.calledWebSearch === true &&
event.toolCalls === 1
)
).toBe(true);
rmSync(tempDir, { recursive: true, force: true });
});
it('accepts legacy Content-Length framed requests for compatibility', async () => {
const child = spawn('node', [serverPath], {
env: {
...process.env,
CCS_PROFILE_TYPE: 'account',
CCS_WEBSEARCH_ENABLED: '1',
CCS_WEBSEARCH_SKIP: '1',
CCS_WEBSEARCH_DUCKDUCKGO: '1',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 2);
child.stdin.write(
encodeLegacyMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(encodeLegacyMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }));
const responses = await responsesPromise;
const toolsList = responses.find((message) => message.id === 2);
expect(toolsList?.result).toEqual({ tools: [] });
} finally {
child.kill();
}
});
});
+638 -3
View File
@@ -1,19 +1,131 @@
import { describe, expect, it } from 'bun:test';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
const hookPath = join(process.cwd(), 'lib', 'hooks', 'websearch-transformer.cjs');
type HookOutput = {
hookSpecificOutput: {
additionalContext: string;
hookEventName: string;
permissionDecision: string;
permissionDecisionReason: string;
};
};
const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
buildFailureHookOutput: (
query: string,
errors: Array<{ provider: string; error: string }>
) => HookOutput;
buildSuccessHookOutput: (
query: string,
providerName: string,
content: string
) => HookOutput;
extractDuckDuckGoResults: (html: string, count: number) => Array<{
title: string;
url: string;
description: string;
}>;
classifyProviderFailure: (result: {
error?: string;
retryAfterSec?: number | null;
statusCode?: number | null;
success?: boolean;
}) => Record<string, unknown>;
formatStructuredSearchResults: (
query: string,
providerName: string,
results: Array<{ title: string; url: string; description: string }>
) => string;
parseRetryAfterSeconds: (rawValue: string) => number | null;
};
function runHookWithMockedFetch(mode: 'success' | 'failure') {
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const html = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
<a class="result__snippet">Example snippet</a>
`.trim();
const preloadScript =
mode === 'success'
? `global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`
: `global.fetch = async () => ({ ok: false, status: 503, text: async () => 'Service unavailable' });\n`;
writeFileSync(preloadPath, preloadScript, 'utf8');
try {
return 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_TAVILY: '0',
},
});
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
}
describe('websearch-transformer hook helpers', () => {
it('parses Retry-After seconds and HTTP dates', () => {
expect(hook.parseRetryAfterSeconds('2')).toBe(2);
expect(
hook.parseRetryAfterSeconds(new Date(Date.now() + 2000).toUTCString())
).toBeGreaterThanOrEqual(1);
expect(hook.parseRetryAfterSeconds('invalid')).toBeNull();
});
it('classifies quota exhaustion and short rate limits into the correct provider policy', () => {
expect(
hook.classifyProviderFailure({
success: false,
statusCode: 429,
error: 'Exa returned 429: quota exceeded for current plan',
})
).toMatchObject({
kind: 'cooldown',
reason: 'quota_exhausted',
cooldownSec: 900,
});
expect(
hook.classifyProviderFailure({
success: false,
statusCode: 429,
retryAfterSec: 2,
error: 'Brave Search returned 429: rate limit exceeded',
})
).toMatchObject({
kind: 'retry',
reason: 'rate_limited_short_backoff',
delayMs: 2000,
retryAfterSec: 2,
});
});
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>
@@ -46,9 +158,532 @@ describe('websearch-transformer hook helpers', () => {
},
]);
expect(formatted).toContain('Search results for "ccs websearch" via DuckDuckGo');
expect(formatted).toContain('CCS local WebSearch evidence');
expect(formatted).toContain('Provider: DuckDuckGo');
expect(formatted).toContain('Query: "ccs websearch"');
expect(formatted).toContain('Result count: 1');
expect(formatted).toContain('1. Result title');
expect(formatted).toContain('https://example.com');
expect(formatted).toContain('Result snippet');
expect(formatted).toContain('URL: https://example.com');
expect(formatted).toContain('Snippet: Result snippet');
expect(formatted).not.toContain('Use these results to answer the user directly.');
});
it('builds a structured success hook output with short deny reason and additional context', () => {
const output = hook.buildSuccessHookOutput(
'btc price',
'Exa',
'CCS local WebSearch evidence\nProvider: Exa'
);
expect(output.hookSpecificOutput).toEqual({
additionalContext: 'CCS local WebSearch evidence\nProvider: Exa',
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason:
'CCS already retrieved WebSearch results locally via Exa. Use the provided context instead of calling native WebSearch for "btc price".',
});
expect(output).not.toHaveProperty('decision');
expect(output).not.toHaveProperty('reason');
expect(output).not.toHaveProperty('additionalContext');
});
it('builds a concise failure hook output with provider failure details in additional context', () => {
const output = hook.buildFailureHookOutput('btc price', [
{ provider: 'Exa', error: 'Exa timed out' },
{ provider: 'DuckDuckGo', error: 'DuckDuckGo returned 503' },
]);
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.permissionDecisionReason).toBe(
'CCS could not complete local WebSearch for "btc price". Native WebSearch is unavailable for this profile.'
);
expect(output.hookSpecificOutput.additionalContext).toContain(
'Attempted providers: Exa: Exa timed out'
);
expect(output.hookSpecificOutput.additionalContext).toContain(
'DuckDuckGo: DuckDuckGo returned 503'
);
});
it('emits runtime success output with additionalContext nested under hookSpecificOutput', () => {
const result = runHookWithMockedFetch('success');
expect(result.status).toBe(0);
expect(result.stderr.trim()).toBe('');
const output = JSON.parse(result.stdout.trim()) as HookOutput;
expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse');
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.additionalContext).toContain(
'CCS local WebSearch evidence'
);
expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo');
expect(output.hookSpecificOutput.additionalContext).toContain(
'URL: https://example.com/article'
);
expect(output).not.toHaveProperty('additionalContext');
});
it('emits runtime failure output with attempted provider details nested under hookSpecificOutput', () => {
const result = runHookWithMockedFetch('failure');
expect(result.status).toBe(0);
expect(result.stderr.trim()).toBe('');
const output = JSON.parse(result.stdout.trim()) as HookOutput;
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain(
'Native WebSearch is unavailable for this profile.'
);
expect(output.hookSpecificOutput.additionalContext).toContain(
'CCS local WebSearch failed for "btc price".'
);
expect(output.hookSpecificOutput.additionalContext).toContain(
'Attempted providers: DuckDuckGo: DuckDuckGo returned 503'
);
expect(output).not.toHaveProperty('additionalContext');
});
it('writes opt-in trace records with redacted query fingerprints', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-trace-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const ccsHome = join(tempDir, 'home');
const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
const html = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
<a class="result__snippet">Example snippet</a>
`.trim();
writeFileSync(
preloadPath,
`global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`,
'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_HOME: ccsHome,
CCS_WEBSEARCH_TRACE: '1',
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'hook-trace-test',
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
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_TAVILY: '0',
},
});
expect(result.status).toBe(0);
const traceContents = readFileSync(tracePath, 'utf8');
expect(traceContents).not.toContain('btc price');
const traceEvents = traceContents
.trim()
.split('\n')
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(traceEvents.some((event) => event.event === 'websearch_hook_invoked')).toBe(true);
expect(
traceEvents.some(
(event) =>
event.event === 'websearch_provider_attempt' && event.providerName === 'DuckDuckGo'
)
).toBe(true);
expect(
traceEvents.some(
(event) =>
event.event === 'websearch_provider_success' && event.providerName === 'DuckDuckGo'
)
).toBe(true);
const fingerprintEvent = traceEvents.find(
(event) => event.event === 'websearch_hook_invoked'
) as { queryHash?: string; queryLength?: number } | undefined;
expect(fingerprintEvent?.queryHash).toBeString();
expect(fingerprintEvent?.queryLength).toBe(9);
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
});
it('falls back to the default trace file when CCS_WEBSEARCH_TRACE_FILE points outside safe paths', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-trace-safe-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const ccsHome = join(tempDir, 'home');
const fallbackTracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
const disallowedTracePath = join(process.cwd(), '.tmp-websearch-trace-unsafe.jsonl');
const html = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
<a class="result__snippet">Example snippet</a>
`.trim();
writeFileSync(
preloadPath,
`global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`,
'utf8'
);
try {
rmSync(disallowedTracePath, { force: true });
const result = spawnSync('node', ['-r', preloadPath, hookPath], {
encoding: 'utf8',
input: JSON.stringify({
tool_name: 'WebSearch',
tool_input: { query: 'btc price' },
}),
env: {
...process.env,
CCS_HOME: ccsHome,
CCS_WEBSEARCH_TRACE: '1',
CCS_WEBSEARCH_TRACE_FILE: disallowedTracePath,
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'hook-trace-safe-test',
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
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_TAVILY: '0',
},
});
expect(result.status).toBe(0);
expect(existsSync(disallowedTracePath)).toBe(false);
expect(existsSync(fallbackTracePath)).toBe(true);
} finally {
rmSync(disallowedTracePath, { force: true });
rmSync(tempDir, { recursive: true, force: true });
}
});
it('applies provider cooldown on quota exhaustion and falls back to the next backend', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-quota-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const requestLogPath = join(tempDir, 'requests.json');
const ccsHome = join(tempDir, 'home');
const statePath = join(ccsHome, '.ccs', 'cache', 'websearch-provider-state.json');
const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
const html = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Fallback title</a>
<a class="result__snippet">Fallback 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 resolvedUrl = String(url);
record(resolvedUrl);
if (resolvedUrl.includes('api.exa.ai')) {
return {
ok: false,
status: 429,
headers: { get: () => null },
text: async () => 'quota exceeded for current plan',
};
}
return {
ok: true,
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_HOME: ccsHome,
CCS_WEBSEARCH_TRACE: '1',
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'quota-fallback-test',
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
CCS_WEBSEARCH_ENABLED: '1',
CCS_WEBSEARCH_SKIP: '0',
CCS_WEBSEARCH_BRAVE: '0',
CCS_WEBSEARCH_DUCKDUCKGO: '1',
CCS_WEBSEARCH_EXA: '1',
CCS_WEBSEARCH_GEMINI: '0',
CCS_WEBSEARCH_GROK: '0',
CCS_WEBSEARCH_OPENCODE: '0',
CCS_WEBSEARCH_TAVILY: '0',
EXA_API_KEY: 'exa-test-key',
},
});
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout.trim()) as HookOutput;
expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo');
const providerState = JSON.parse(readFileSync(statePath, 'utf8')) as {
cooldowns?: Record<string, { reason?: string; until?: number }>;
};
expect(providerState.cooldowns?.exa?.reason).toBe('quota_exhausted');
expect(providerState.cooldowns?.exa?.until).toBeGreaterThan(Date.now());
const traceEvents = readFileSync(tracePath, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(
traceEvents.some(
(event) =>
event.event === 'websearch_provider_cooldown_applied' &&
event.providerId === 'exa' &&
event.reason === 'quota_exhausted'
)
).toBe(true);
expect(
traceEvents.some(
(event) =>
event.event === 'websearch_provider_success' &&
event.providerId === 'duckduckgo'
)
).toBe(true);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('skips providers that are already cooling down on later WebSearch calls', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-cooldown-skip-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const requestLogPath = join(tempDir, 'requests.json');
const ccsHome = join(tempDir, 'home');
const statePath = join(ccsHome, '.ccs', 'cache', 'websearch-provider-state.json');
const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
const html = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fcooldown">Cooldown title</a>
<a class="result__snippet">Cooldown snippet</a>
`.trim();
mkdirSync(join(ccsHome, '.ccs', 'cache'), { recursive: true });
writeFileSync(
statePath,
JSON.stringify(
{
cooldowns: {
exa: {
until: Date.now() + 10 * 60 * 1000,
reason: 'quota_exhausted',
},
},
},
null,
2
),
'utf8'
);
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) => {
record(url);
return {
ok: true,
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_HOME: ccsHome,
CCS_WEBSEARCH_TRACE: '1',
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'cooldown-skip-test',
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
CCS_WEBSEARCH_ENABLED: '1',
CCS_WEBSEARCH_SKIP: '0',
CCS_WEBSEARCH_BRAVE: '0',
CCS_WEBSEARCH_DUCKDUCKGO: '1',
CCS_WEBSEARCH_EXA: '1',
CCS_WEBSEARCH_GEMINI: '0',
CCS_WEBSEARCH_GROK: '0',
CCS_WEBSEARCH_OPENCODE: '0',
CCS_WEBSEARCH_TAVILY: '0',
EXA_API_KEY: 'exa-test-key',
},
});
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.some((url) => url.includes('api.exa.ai'))).toBe(false);
const traceEvents = readFileSync(tracePath, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(
traceEvents.some(
(event) =>
event.event === 'websearch_provider_cooldown_skip' &&
event.providerId === 'exa' &&
event.cooldownReason === 'quota_exhausted'
)
).toBe(true);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it('retries transient backend failures once before succeeding', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-retry-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const requestLogPath = join(tempDir, 'requests.json');
const ccsHome = join(tempDir, 'home');
const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
writeFileSync(
preloadPath,
`
const fs = require('fs');
const requestLogPath = ${JSON.stringify(requestLogPath)};
let exaAttempts = 0;
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 resolvedUrl = String(url);
record(resolvedUrl);
exaAttempts += 1;
if (exaAttempts === 1) {
return {
ok: false,
status: 503,
headers: { get: () => null },
text: async () => 'service unavailable',
};
}
return {
ok: true,
headers: { get: () => null },
json: async () => ({
results: [
{
title: 'Exa title',
url: 'https://example.com/exa',
text: 'Exa snippet',
},
],
}),
};
};
`.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_HOME: ccsHome,
CCS_WEBSEARCH_TRACE: '1',
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'transient-retry-test',
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
CCS_WEBSEARCH_ENABLED: '1',
CCS_WEBSEARCH_SKIP: '0',
CCS_WEBSEARCH_BRAVE: '0',
CCS_WEBSEARCH_DUCKDUCKGO: '0',
CCS_WEBSEARCH_EXA: '1',
CCS_WEBSEARCH_GEMINI: '0',
CCS_WEBSEARCH_GROK: '0',
CCS_WEBSEARCH_OPENCODE: '0',
CCS_WEBSEARCH_TAVILY: '0',
EXA_API_KEY: 'exa-test-key',
},
});
expect(result.status).toBe(0);
const output = JSON.parse(result.stdout.trim()) as HookOutput;
expect(output.hookSpecificOutput.additionalContext).toContain('Provider: Exa');
const requests = JSON.parse(readFileSync(requestLogPath, 'utf8')) as string[];
expect(requests.filter((url) => url.includes('api.exa.ai'))).toHaveLength(2);
const traceEvents = readFileSync(tracePath, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(
traceEvents.some(
(event) =>
event.event === 'websearch_provider_retry_scheduled' &&
event.providerId === 'exa' &&
event.reason === 'transient_failure'
)
).toBe(true);
expect(
traceEvents.some(
(event) =>
event.event === 'websearch_provider_success' && event.providerId === 'exa'
)
).toBe(true);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
@@ -144,6 +144,68 @@ describe('InstanceManager MCP sync', () => {
});
});
it('repairs managed ccs-websearch entries from global config while preserving other instance MCP overrides', () => {
fs.writeFileSync(
path.join(tempRoot, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'ccs-websearch': {
type: 'stdio',
command: 'node',
args: ['/global/server.cjs'],
env: {},
},
shared: { command: 'global-shared' },
},
},
null,
2
),
'utf8'
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
fs.mkdirSync(instancePath, { recursive: true });
fs.writeFileSync(
path.join(instancePath, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'ccs-websearch': {
type: 'stdio',
command: 'node',
args: ['/old/server.cjs'],
env: {},
},
shared: { command: 'instance-shared' },
instanceOnly: { command: 'instance-only' },
},
},
null,
2
),
'utf8'
);
expect(manager.syncMcpServers(instancePath)).toBe(true);
const instanceContent = readJson(path.join(instancePath, '.claude.json')) as {
mcpServers: Record<string, unknown>;
};
expect(instanceContent.mcpServers).toEqual({
'ccs-websearch': {
type: 'stdio',
command: 'node',
args: ['/global/server.cjs'],
env: {},
},
shared: { command: 'instance-shared' },
instanceOnly: { command: 'instance-only' },
});
});
it('logs warning when global MCP sync fails', () => {
fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8');
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
+66
View File
@@ -72,4 +72,70 @@ describe('codex-detector', () => {
execFileSyncSpy.mockRestore();
});
it('prefers a sibling PowerShell wrapper over cmd when Windows PATH only exposes codex.cmd', () => {
const fakeCmdCodex = path.join(tmpDir, 'codex.cmd');
const fakePsCodex = path.join(tmpDir, 'codex.ps1');
fs.writeFileSync(fakeCmdCodex, '');
fs.writeFileSync(fakePsCodex, '');
Object.defineProperty(process, 'platform', { value: 'win32' });
const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => `${fakeCmdCodex}\n`);
expect(detectCodexCli()).toBe(fakePsCodex);
execSyncSpy.mockRestore();
});
it('falls back to a direct -c probe when help text omits the config flag', () => {
const fakeCodex = path.join(tmpDir, 'codex');
fs.writeFileSync(fakeCodex, '');
process.env.CCS_CODEX_PATH = fakeCodex;
const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
const joinedArgs = Array.isArray(args) ? args.join(' ') : '';
if (joinedArgs.includes('--help')) {
return 'Codex CLI\n';
}
if (joinedArgs.includes('-c') && joinedArgs.includes('--version')) {
return 'codex-cli 0.119.0-alpha.1';
}
return 'codex-cli 0.119.0-alpha.1';
});
const info = getCodexBinaryInfo();
expect(info?.features).toContain('config-overrides');
execFileSyncSpy.mockRestore();
});
it('still detects support from broader help text when the direct probe fails', () => {
const fakeCodex = path.join(tmpDir, 'codex');
fs.writeFileSync(fakeCodex, '');
process.env.CCS_CODEX_PATH = fakeCodex;
const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
const joinedArgs = Array.isArray(args) ? args.join(' ') : '';
if (joinedArgs.includes('--help')) {
return 'Codex CLI\n -c, --config <CONFIG_OVERRIDE>\n';
}
if (joinedArgs.includes('-c') && joinedArgs.includes('--version')) {
throw new Error('unsupported');
}
return 'codex-cli 0.119.0-alpha.1';
});
const info = getCodexBinaryInfo();
expect(info?.features).toContain('config-overrides');
execFileSyncSpy.mockRestore();
});
});
@@ -126,7 +126,17 @@ if (envOut) {
}) + '\\n'
);
}
if (cliArgs.includes('--version') || cliArgs.includes('-v')) {
const configFlagIndex = cliArgs.findIndex((arg) => arg === '-c' || arg === '--config');
if (process.env.CCS_TEST_CODEX_CONFIG_OVERRIDE_STATUS === 'unsupported' && configFlagIndex !== -1) {
process.stderr.write('codex: unknown option --config\\n');
process.exit(1);
}
if (
cliArgs.includes('--version') ||
cliArgs.includes('-v') ||
(configFlagIndex !== -1 &&
(cliArgs[configFlagIndex + 2] === '--version' || cliArgs[configFlagIndex + 2] === '-v'))
) {
process.stdout.write(process.env.CCS_TEST_CODEX_VERSION || 'codex-cli 0.118.0-alpha.3');
process.exit(0);
}
@@ -260,6 +270,120 @@ process.exit(0);
]);
});
it('creates an explicit CODEX_HOME directory before routed native Codex launches', () => {
if (process.platform === 'win32') return;
const freshCodexHome = path.join(tmpHome, 'fresh-codex-home');
const result = runCcs(['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
CODEX_HOME: freshCodexHome,
});
expect(result.status).toBe(0);
expect(fs.existsSync(freshCodexHome)).toBe(true);
expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true);
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
['-c', 'model="gpt-5"', '--version'],
['-c', 'model_reasoning_effort="high"', 'fix failing tests'],
]);
const loggedEnv = readLoggedCodexEnv(codexEnvLogPath);
expect(loggedEnv).toHaveLength(2);
expect(loggedEnv.map((entry) => entry.CODEX_HOME)).toEqual([freshCodexHome, freshCodexHome]);
expect(loggedEnv[1]).toEqual({
CODEX_HOME: freshCodexHome,
CODEX_CI: undefined,
CODEX_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined,
ANTHROPIC_BASE_URL: undefined,
});
});
it('fails with a clean error when routed launches receive a file CODEX_HOME path', () => {
if (process.platform === 'win32') return;
const invalidCodexHome = path.join(tmpHome, 'codex-home-file');
fs.writeFileSync(invalidCodexHome, 'not-a-directory');
const result = runCcs(
['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'],
{
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CODEX_HOME: invalidCodexHome,
}
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(`[X] CODEX_HOME path is not a directory: ${invalidCodexHome}`);
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['-c', 'model="gpt-5"', '--version']]);
});
it('keeps passthrough version launches aligned with native warning-only CODEX_HOME behavior', () => {
if (process.platform === 'win32') return;
const readOnlyRoot = path.join(tmpHome, 'readonly-root');
fs.mkdirSync(readOnlyRoot, { recursive: true });
fs.chmodSync(readOnlyRoot, 0o555);
try {
const result = runCodexAlias(['--version'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
CODEX_HOME: path.join(readOnlyRoot, 'missing-codex-home'),
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('codex-cli 9.9.9-test');
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]);
} finally {
fs.chmodSync(readOnlyRoot, 0o755);
}
});
it('normalizes explicit CODEX_HOME before launching native Codex', () => {
if (process.platform === 'win32') return;
const result = runCodexAlias(['--version'], {
...process.env,
CI: '1',
NO_COLOR: '1',
HOME: tmpHome,
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
CODEX_HOME: '~/.codex-lit',
});
expect(result.status).toBe(0);
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
{
CODEX_HOME: path.join(tmpHome, '.codex-lit'),
CODEX_CI: undefined,
CODEX_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined,
ANTHROPIC_BASE_URL: undefined,
},
]);
});
it('keeps ccsxp pinned to native Codex even when a user passes another --target override', () => {
if (process.platform === 'win32') return;
@@ -324,6 +448,7 @@ process.exit(0);
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_CONFIG_OVERRIDE_STATUS: 'unsupported',
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
CCS_TEST_CODEX_HELP: ' -p, --profile <CONFIG_PROFILE>\\n',
}
@@ -333,7 +458,31 @@ process.exit(0);
expect(result.stderr).toContain('Codex CLI (codex-cli 9.9.9-test)');
expect(result.stderr).toContain('does not advertise --config overrides');
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls).toEqual([['--help'], ['--version']]);
expect(calls).toEqual([['-c', 'model="gpt-5"', '--version'], ['--help'], ['--version']]);
});
it('accepts native Codex reasoning overrides when the direct -c probe succeeds', () => {
if (process.platform === 'win32') return;
const result = runCcs(
['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'],
{
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
CCS_TEST_CODEX_HELP: ' -p, --profile <CONFIG_PROFILE>\\n',
}
);
expect(result.status).toBe(0);
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
['-c', 'model="gpt-5"', '--version'],
['-c', 'model_reasoning_effort="high"', 'fix failing tests'],
]);
});
it('reports unsupported generic settings profiles before Codex install guidance', () => {
@@ -68,6 +68,13 @@ describe('Codex settings bridge launch', () => {
fs.writeFileSync(
fakeCodexPath,
`#!/bin/sh
if [ "$1" = "-c" ] || [ "$1" = "--config" ]; then
if [ "$3" = "--version" ] || [ "$3" = "-v" ]; then
echo "codex-cli 0.118.0-alpha.3"
exit 0
fi
fi
if [ "$1" = "--version" ]; then
echo "codex-cli 0.118.0-alpha.3"
exit 0
@@ -4,6 +4,8 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
interface RunResult {
status: number | null;
stdout: string;
@@ -25,6 +27,15 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
};
}
function readTraceEvents(tracePath: string): Array<Record<string, unknown>> {
return fs
.readFileSync(tracePath, 'utf8')
.trim()
.split('\n')
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
describe('settings profile WebSearch launch', () => {
let tmpHome = '';
let ccsDir = '';
@@ -92,7 +103,7 @@ exit 0
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('fails before Claude launch when an enabled WebSearch hook cannot be prepared', () => {
it('fails before Claude launch when the local WebSearch tool runtime cannot be prepared', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
@@ -100,7 +111,19 @@ exit 0
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(1);
expect(result.stderr).toContain('could not prepare the profile hook for "glm"');
expect(result.stderr).toContain('could not prepare the local WebSearch tool');
expect(fs.existsSync(claudeArgsLogPath)).toBe(false);
});
it('fails before delegated headless launch when the local WebSearch tool runtime cannot be prepared', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
const result = runCcs(['glm', '-p', 'smoke'], baseEnv);
expect(result.status).toBe(1);
expect(result.stderr).toContain('could not prepare the local WebSearch tool');
expect(fs.existsSync(claudeArgsLogPath)).toBe(false);
});
@@ -117,7 +140,42 @@ exit 0
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain('could not prepare the profile hook for "glm"');
expect(result.stderr).not.toContain('could not prepare the local WebSearch tool');
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).toContain('--disallowedTools');
expect(launchedArgs).toContain('WebSearch');
expect(launchedArgs).toContain('--append-system-prompt');
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
});
it('writes a source-side launch trace for settings profiles when tracing is enabled', () => {
if (process.platform === 'win32') return;
const tracePath = path.join(ccsDir, 'logs', 'websearch-trace.jsonl');
const result = runCcs(['glm', 'smoke'], {
...baseEnv,
CCS_WEBSEARCH_TRACE: '1',
});
expect(result.status).toBe(0);
expect(fs.existsSync(tracePath)).toBe(true);
const traceEvents = readTraceEvents(tracePath);
const launchEvent = traceEvents.find(
(event) => event.event === 'ccs_websearch_launch'
) as
| {
launcher?: string;
nativeWebSearchDisallowed?: boolean;
steeringPromptApplied?: boolean;
settingsPath?: string;
}
| undefined;
expect(launchEvent?.launcher).toBe('ccs.settings-profile');
expect(launchEvent?.nativeWebSearchDisallowed).toBe(true);
expect(launchEvent?.steeringPromptApplied).toBe(true);
expect(launchEvent?.settingsPath).toBe(settingsPath);
});
});
@@ -21,12 +21,14 @@ type SpawnCall = {
options: Record<string, unknown> | undefined;
};
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
const spawnCalls: SpawnCall[] = [];
const originalPlatform = process.platform;
let baselineSigintListeners: Array<(...args: unknown[]) => void> = [];
let baselineSigtermListeners: Array<(...args: unknown[]) => void> = [];
let baselineSighupListeners: Array<(...args: unknown[]) => void> = [];
let originalCcsHome: string | undefined;
let originalCcsClaudePath: string | undefined;
let originalDisableAutoUpdater: string | undefined;
const realSpawn = childProcess.spawn.bind(childProcess);
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
@@ -150,6 +152,7 @@ describe('CLAUDECODE environment stripping', () => {
spawnCalls.length = 0;
process.env.CCS_QUIET = '1';
originalCcsHome = process.env.CCS_HOME;
originalCcsClaudePath = process.env.CCS_CLAUDE_PATH;
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
delete process.env.DISABLE_AUTOUPDATER;
baselineSigintListeners = process.listeners('SIGINT');
@@ -162,8 +165,11 @@ describe('CLAUDECODE environment stripping', () => {
delete process.env.CLAUDECODE;
delete process.env.claudecode;
delete process.env.CCS_QUIET;
delete process.env.CCS_WEBSEARCH_TRACE;
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsClaudePath !== undefined) process.env.CCS_CLAUDE_PATH = originalCcsClaudePath;
else delete process.env.CCS_CLAUDE_PATH;
if (originalDisableAutoUpdater !== undefined) {
process.env.DISABLE_AUTOUPDATER = originalDisableAutoUpdater;
} else {
@@ -325,4 +331,76 @@ describe('CLAUDECODE environment stripping', () => {
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
expect(env.DISABLE_AUTOUPDATER).toBe('1');
});
it('headless executor adds third-party WebSearch steering args and env', async () => {
writeConfigWithAutoUpdatePreference(false);
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
fs.writeFileSync(path.join(ccsDir, 'glm.settings.json'), '{}\n', 'utf8');
const projectDir = path.join(ccsDir, 'project');
fs.mkdirSync(path.join(projectDir, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(projectDir, '.claude', 'settings.local.json'),
JSON.stringify(
{
permissions: {
deny: ['Bash', 'WebFetch'],
},
},
null,
2
) + '\n',
'utf8'
);
process.env.CCS_CLAUDE_PATH = 'claude';
const result = await HeadlessExecutor.execute('glm', 'latest AI chip news', {
cwd: projectDir,
permissionMode: 'default',
timeout: 1000,
});
expect(result.success).toBe(true);
expect(spawnCalls.length).toBeGreaterThan(0);
const launch = spawnCalls[0];
expect(launch.args).toContain('--disallowedTools');
const disallowedToolsIndex = launch.args.indexOf('--disallowedTools');
expect(disallowedToolsIndex).toBeGreaterThan(-1);
expect(launch.args[disallowedToolsIndex + 1]).toBe('Bash,WebFetch,WebSearch');
expect(launch.args).toContain('--append-system-prompt');
expect(launch.args.join(' ')).toContain(STEERING_PROMPT_SNIPPET);
const env = launch.options?.env as NodeJS.ProcessEnv;
expect(env.CCS_PROFILE_TYPE).toBe('settings');
expect(env.CCS_WEBSEARCH_ENABLED || env.CCS_WEBSEARCH_SKIP).toBeDefined();
const claudeUserConfig = JSON.parse(
fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8')
) as {
mcpServers?: Record<string, unknown>;
};
expect(claudeUserConfig.mcpServers?.['ccs-websearch']).toEqual({
type: 'stdio',
command: 'node',
args: [path.join(ccsDir, 'mcp', 'ccs-websearch-server.cjs')],
env: {},
});
});
it('headless executor propagates a WebSearch trace launch id when tracing is enabled', async () => {
writeConfigWithAutoUpdatePreference(false);
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
fs.writeFileSync(path.join(ccsDir, 'glm.settings.json'), '{}\n', 'utf8');
process.env.CCS_CLAUDE_PATH = 'claude';
process.env.CCS_WEBSEARCH_TRACE = '1';
const result = await HeadlessExecutor.execute('glm', 'latest AI chip news', {
permissionMode: 'default',
timeout: 1000,
});
expect(result.success).toBe(true);
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env.CCS_WEBSEARCH_TRACE).toBe('1');
expect(env.CCS_WEBSEARCH_TRACE_LAUNCH_ID).toBeString();
expect(env.CCS_WEBSEARCH_TRACE_LAUNCHER).toBe('delegation.headless-executor');
});
});
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'bun:test';
import { appendThirdPartyWebSearchToolArgs } from '../../../../src/utils/websearch/claude-tool-args';
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', () => {
expect(appendThirdPartyWebSearchToolArgs(['smoke'])).toEqual([
'smoke',
'--disallowedTools',
'WebSearch',
'--append-system-prompt',
STEERING_PROMPT,
]);
});
it('does not append duplicate suppression or steering prompt when both are already present', () => {
expect(
appendThirdPartyWebSearchToolArgs([
'smoke',
'--disallowedTools',
'WebSearch',
'--append-system-prompt',
STEERING_PROMPT,
])
).toEqual([
'smoke',
'--disallowedTools',
'WebSearch',
'--append-system-prompt',
STEERING_PROMPT,
]);
});
it('detects comma-separated disallowed tool values', () => {
expect(
appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read,WebSearch'])
).toEqual([
'smoke',
'--disallowedTools=Read,WebSearch',
'--append-system-prompt',
STEERING_PROMPT,
]);
});
it('merges WebSearch into an existing space-separated disallowed tool flag', () => {
expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools', 'Read'])).toEqual([
'smoke',
'--disallowedTools',
'Read,WebSearch',
'--append-system-prompt',
STEERING_PROMPT,
]);
});
it('merges WebSearch into an existing equals-form disallowed tool flag', () => {
expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read'])).toEqual([
'smoke',
'--disallowedTools=Read,WebSearch',
'--append-system-prompt',
STEERING_PROMPT,
]);
});
it('preserves user-supplied append-system-prompt values and adds the CCS steering hint once', () => {
expect(
appendThirdPartyWebSearchToolArgs([
'smoke',
'--append-system-prompt',
'User-provided instruction',
])
).toEqual([
'smoke',
'--append-system-prompt',
'User-provided instruction',
'--disallowedTools',
'WebSearch',
'--append-system-prompt',
STEERING_PROMPT,
]);
});
it('does not duplicate the steering prompt when it already exists in equals form', () => {
expect(
appendThirdPartyWebSearchToolArgs([
'smoke',
'--disallowedTools',
'WebSearch',
`--append-system-prompt=${STEERING_PROMPT}`,
])
).toEqual([
'smoke',
'--disallowedTools',
'WebSearch',
`--append-system-prompt=${STEERING_PROMPT}`,
]);
});
it('does not consume positional args after a disallowed-tools flag value', () => {
expect(
appendThirdPartyWebSearchToolArgs(['--disallowedTools', 'Read', 'latest AI news'])
).toEqual([
'--disallowedTools',
'Read,WebSearch',
'latest AI news',
'--append-system-prompt',
STEERING_PROMPT,
]);
});
it('injects synthetic flags before an end-of-options marker', () => {
expect(appendThirdPartyWebSearchToolArgs(['--', 'latest AI news'])).toEqual([
'--disallowedTools',
'WebSearch',
'--append-system-prompt',
STEERING_PROMPT,
'--',
'latest AI news',
]);
});
it('inserts the WebSearch disallow value when the flag is present without one', () => {
expect(appendThirdPartyWebSearchToolArgs(['--disallowedTools', '--verbose'])).toEqual([
'--disallowedTools',
'WebSearch',
'--verbose',
'--append-system-prompt',
STEERING_PROMPT,
]);
});
});
@@ -0,0 +1,213 @@
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 { getHookPath } from '../../../../src/utils/websearch/hook-config';
import {
ensureWebSearchMcp,
getWebSearchMcpServerName,
getWebSearchMcpServerPath,
uninstallWebSearchMcp,
} from '../../../../src/utils/websearch/mcp-installer';
describe('ensureWebSearchMcp', () => {
let tempHome: string | undefined;
let originalCcsHome: string | undefined;
function setupTempHome(): string {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-mcp-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
return tempHome;
}
function getCcsDir(): string {
if (!tempHome) {
throw new Error('tempHome not initialized');
}
return path.join(tempHome, '.ccs');
}
function writeEnabledConfig(): void {
const ccsDir = getCcsDir();
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
['version: 12', 'websearch:', ' enabled: true', ' providers:', ' duckduckgo:', ' enabled: true', ''].join('\n'),
'utf8'
);
}
function getManagedConfig() {
return {
type: 'stdio',
command: 'node',
args: [getWebSearchMcpServerPath()],
env: {},
};
}
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 MCP server and preserves existing user mcpServers entries', () => {
setupTempHome();
writeEnabledConfig();
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(ensureWebSearchMcp()).toBe(true);
expect(fs.existsSync(getHookPath())).toBe(true);
expect(fs.existsSync(getWebSearchMcpServerPath())).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[getWebSearchMcpServerName()]).toEqual(getManagedConfig());
});
it('preserves the existing ~/.claude.json permissions when provisioning WebSearch MCP', () => {
setupTempHome();
writeEnabledConfig();
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(ensureWebSearchMcp()).toBe(true);
expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600);
});
it('writes new ~/.claude.json with 0600 permissions', () => {
setupTempHome();
writeEnabledConfig();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
expect(ensureWebSearchMcp()).toBe(true);
expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600);
});
it('returns false and preserves malformed ~/.claude.json', () => {
setupTempHome();
writeEnabledConfig();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(claudeUserConfigPath, '{ invalid json', 'utf8');
expect(ensureWebSearchMcp()).toBe(false);
expect(fs.readFileSync(claudeUserConfigPath, 'utf8')).toBe('{ invalid json');
});
it('removes the managed MCP runtime while preserving unrelated server entries', () => {
setupTempHome();
writeEnabledConfig();
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(ensureWebSearchMcp()).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'] },
[getWebSearchMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] },
},
otherKey: 'keep-me',
},
null,
2
) + '\n',
'utf8'
);
expect(uninstallWebSearchMcp()).toBe(true);
expect(fs.existsSync(getWebSearchMcpServerPath())).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('falls back to copy-overwrite when rename is blocked during MCP server install', () => {
setupTempHome();
writeEnabledConfig();
const realRenameSync = fs.renameSync;
const renameSpy = spyOn(fs, 'renameSync').mockImplementation((oldPath, newPath) => {
if (String(newPath) === getWebSearchMcpServerPath()) {
const error = new Error('busy') as NodeJS.ErrnoException;
error.code = 'EPERM';
throw error;
}
return realRenameSync(oldPath, newPath);
});
expect(ensureWebSearchMcp()).toBe(true);
expect(renameSpy).toHaveBeenCalled();
expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(true);
});
});
+112 -2
View File
@@ -1,5 +1,16 @@
import { describe, expect, it } from 'bun:test';
import { buildWebSearchReadiness } from '../../../../src/utils/websearch/status';
import { describe, expect, it, spyOn } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as geminiCli from '../../../../src/utils/websearch/gemini-cli';
import * as grokCli from '../../../../src/utils/websearch/grok-cli';
import * as opencodeCli from '../../../../src/utils/websearch/opencode-cli';
import * as providerSecrets from '../../../../src/utils/websearch/provider-secrets';
import * as unifiedConfigLoader from '../../../../src/config/unified-config-loader';
import {
buildWebSearchReadiness,
getWebSearchCliProviders,
} from '../../../../src/utils/websearch/status';
import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types';
function provider(overrides: Partial<WebSearchCliInfo> & Pick<WebSearchCliInfo, 'id' | 'name'>): WebSearchCliInfo {
@@ -81,4 +92,103 @@ describe('websearch readiness', () => {
expect(readiness.readiness).toBe('ready');
expect(readiness.message).toContain('Exa');
});
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');
const originalCcsHome = process.env.CCS_HOME;
mkdirSync(join(tempHome, '.ccs', 'cache'), { recursive: true });
writeFileSync(
statePath,
JSON.stringify(
{
cooldowns: {
exa: {
until: Date.now() + 10 * 60 * 1000,
reason: 'quota_exhausted',
},
},
},
null,
2
),
'utf8'
);
process.env.CCS_HOME = tempHome;
const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({
enabled: true,
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 },
gemini: { enabled: false },
grok: { enabled: false },
opencode: { enabled: false },
},
} as any);
const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({
exa: {
envVar: 'EXA_API_KEY',
configured: true,
available: true,
source: 'process_env',
},
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 exa = providers.find((provider) => provider.id === 'exa');
expect(exa?.enabled).toBe(true);
expect(exa?.available).toBe(false);
expect(exa?.detail).toContain('Cooling down');
expect(exa?.detail).toContain('quota exhaustion');
const readiness = buildWebSearchReadiness(true, providers);
expect(readiness.readiness).toBe('needs_setup');
expect(readiness.message).toContain('Cooling down');
} finally {
getConfigSpy.mockRestore();
apiKeySpy.mockRestore();
geminiStatusSpy.mockRestore();
geminiAuthSpy.mockRestore();
grokStatusSpy.mockRestore();
opencodeStatusSpy.mockRestore();
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
rmSync(tempHome, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,152 @@
import { afterEach, describe, expect, it } from 'bun:test';
import express from 'express';
import http from 'http';
import type { AddressInfo } from 'net';
import {
createCliproxyLocalProxyRouter,
type CliproxyLocalProxyDeps,
} from '../../../src/web-server/routes/cliproxy-local-proxy';
const servers: http.Server[] = [];
async function listen(server: http.Server): Promise<number> {
servers.push(server);
return await new Promise<number>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolve((server.address() as AddressInfo).port);
});
});
}
async function createBackendServer(
handler: http.RequestListener
): Promise<{ port: number; server: http.Server }> {
const server = http.createServer(handler);
const port = await listen(server);
return { port, server };
}
async function createProxyServer(options: {
enforceAccess?: CliproxyLocalProxyDeps['enforceAccess'];
resolveTargetPort: () => number;
}): Promise<{ baseUrl: string; server: http.Server }> {
const app = express();
app.use(express.json());
app.use(
'/api/cliproxy-local',
createCliproxyLocalProxyRouter({
enforceAccess: options.enforceAccess,
resolveTargetPort: options.resolveTargetPort,
})
);
const server = http.createServer(app);
const port = await listen(server);
return { baseUrl: `http://127.0.0.1:${port}`, server };
}
afterEach(async () => {
while (servers.length > 0) {
const server = servers.pop();
if (!server) {
continue;
}
// Force-close keep-alive connections so server.close() doesn't hang
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
describe('cliproxy local proxy route', () => {
it('blocks requests when local-access enforcement fails', async () => {
let backendHit = false;
const backend = await createBackendServer((_req, res) => {
backendHit = true;
res.writeHead(200).end('ok');
});
const proxy = await createProxyServer({
resolveTargetPort: () => backend.port,
enforceAccess: (_req, res) => {
res.status(403).json({ error: 'blocked' });
return false;
},
});
const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/management.html`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: 'blocked' });
expect(backendHit).toBe(false);
});
it('forwards JSON request bodies after express.json has parsed them', async () => {
const backend = await createBackendServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
body: JSON.parse(body),
method: req.method,
path: req.url,
})
);
});
});
const proxy = await createProxyServer({
resolveTargetPort: () => backend.port,
enforceAccess: () => true,
});
const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/v0/management/test`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, ids: ['a', 'b'] }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
body: { enabled: true, ids: ['a', 'b'] },
method: 'PATCH',
path: '/v0/management/test',
});
});
it('forwards GET requests and returns backend response', async () => {
const backend = await createBackendServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html>management panel</html>');
});
const proxy = await createProxyServer({
resolveTargetPort: () => backend.port,
enforceAccess: () => true,
});
const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/management.html`);
expect(response.status).toBe(200);
expect(await response.text()).toBe('<html>management panel</html>');
});
it('returns 502 when CLIProxy is not reachable', async () => {
// Use a port with nothing listening
const proxy = await createProxyServer({
resolveTargetPort: () => 19999,
enforceAccess: () => true,
});
const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/`);
expect(response.status).toBe(502);
expect(await response.json()).toEqual({ error: 'CLIProxy is not reachable' });
});
});
@@ -20,14 +20,29 @@ const testRoot = path.join(os.tmpdir(), `ccs-codex-dashboard-test-${Date.now()}`
const codexHome = path.join(testRoot, '.codex-home');
const codexStubPath = path.join(testRoot, 'codex');
function writeCodexStub(options?: { helpText?: string; version?: string }) {
function writeCodexStub(options?: {
helpText?: string;
version?: string;
supportsConfigOverrides?: boolean;
}) {
const helpText =
options?.helpText ?? ' -c, --config <key=value>\n -p, --profile <CONFIG_PROFILE>\n';
const version = options?.version ?? 'codex-cli 0.118.0-alpha.3';
const supportsConfigOverrides = options?.supportsConfigOverrides ?? true;
fs.writeFileSync(
codexStubPath,
`#!/bin/sh
if [ "$1" = "-c" ] || [ "$1" = "--config" ]; then
if [ "${supportsConfigOverrides ? '1' : '0'}" != "1" ]; then
printf '%s\\n' 'codex: unknown option --config' >&2
exit 1
fi
if [ "$3" = "--version" ] || [ "$3" = "-v" ]; then
printf '%s\\n' "${version}"
exit 0
fi
fi
if [ "$1" = "--version" ]; then
printf '%s\\n' "${version}"
exit 0
@@ -272,7 +287,10 @@ requires_openai_auth = true
});
it('warns when active profile is missing, config overrides are unavailable, or risky fields exist', async () => {
writeCodexStub({ helpText: ' -p, --profile <CONFIG_PROFILE>\n' });
writeCodexStub({
helpText: ' -p, --profile <CONFIG_PROFILE>\n',
supportsConfigOverrides: false,
});
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
`profile = "missing-profile"
@@ -300,6 +318,12 @@ bearer_token = "secret"
expect(diagnostics.warnings.some((warning) => warning.includes('inline bearer_token'))).toBe(
true
);
expect(
diagnostics.supportMatrix.find((entry) => entry.id === 'cliproxy-provider-codex')?.supported
).toBe(false);
expect(
diagnostics.supportMatrix.find((entry) => entry.id === 'settings-with-bridge')?.supported
).toBe(false);
});
it('saves valid raw config content', async () => {
@@ -2,38 +2,25 @@
* Account Card Component for Flow Visualization
*/
import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getQuotaFailureInfo,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import {
GripVertical,
Loader2,
Pause,
Play,
KeyRound,
AlertTriangle,
AlertCircle,
} from 'lucide-react';
import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats';
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { formatQuotaPercent, getProviderMinQuota, getQuotaFailureInfo, cn } from '@/lib/utils';
import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
import {
useAccountQuota,
useAccountQuotas,
QUOTA_SUPPORTED_PROVIDERS,
} from '@/hooks/use-cliproxy-stats';
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
import { useTranslation } from 'react-i18next';
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import type { AccountData, DragOffset } from './types';
import { cleanEmail } from './utils';
import { AccountCardStats } from './account-card-stats';
import { cleanEmail } from './utils';
type Zone = 'left' | 'right' | 'top' | 'bottom';
const QUOTA_PROVIDER_ALIASES = [
'antigravity',
'anthropic',
@@ -56,7 +43,7 @@ interface AccountCardProps {
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: () => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
isPausingAccount?: boolean;
}
@@ -87,6 +74,40 @@ function getBorderColorStyle(zone: Zone, color: string): React.CSSProperties {
}
}
function getCompactQuotaColor(percentage: number) {
if (percentage > 50) return 'bg-emerald-500';
if (percentage > 20) return 'bg-amber-500';
return 'bg-red-500';
}
function getVariantMarkerLabel(audience: string, fallbackLabel?: string | null) {
if (audience === 'business') return 'Biz';
if (audience === 'personal') return 'Pers';
const normalizedFallback = fallbackLabel?.trim();
return normalizedFallback?.[0]?.toUpperCase() ?? '?';
}
function getGroupedVariantSummaryLabel(
variants: Array<{ audience: string; audienceLabel?: string | null; detailLabel?: string | null }>
) {
const audiences = new Set(variants.map((variant) => variant.audience));
if (audiences.size === 2 && audiences.has('business') && audiences.has('personal')) {
return 'B|P';
}
if (variants.length === 1) {
const [variant] = variants;
return getVariantMarkerLabel(
variant.audience,
variant.audienceLabel ?? variant.detailLabel ?? null
);
}
return null;
}
export function AccountCard({
account,
zone,
@@ -108,86 +129,155 @@ export function AccountCard({
const borderSide = BORDER_SIDE_MAP[zone];
const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
const normalizedProvider = account.provider.toLowerCase();
const isCliproxyProvider =
const showQuota =
QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) ||
QUOTA_PROVIDER_ALIASES.includes(normalizedProvider);
const isCodexProvider = normalizedProvider === 'codex';
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
const hasGroupedVariants = (account.variants?.length ?? 0) > 1;
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
normalizedProvider,
account.id,
isCliproxyProvider
showQuota && !hasGroupedVariants
);
const variantQuotaQueries = useAccountQuotas(
(account.variants ?? []).map((variant) => ({
provider: account.provider,
accountId: variant.id,
})),
showQuota && hasGroupedVariants
);
const groupedHeaderVariants = hasGroupedVariants
? Array.from(
new Map(
(account.variants ?? [])
.slice()
.sort((left, right) => {
const order = { business: 0, personal: 1, unknown: 2 } as const;
return order[left.audience] - order[right.audience];
})
.map((variant) => [variant.audienceLabel ?? variant.detailLabel ?? variant.id, variant])
).values()
)
: [];
const groupedVariantSummaryLabel = getGroupedVariantSummaryLabel(groupedHeaderVariants);
// Use shared helper for provider-specific minimum quota
const minQuota = getProviderMinQuota(account.provider, quota);
const resetTime = getProviderResetTime(account.provider, quota);
const codexBreakdown =
isCodexProvider && quota && isCodexQuotaResult(quota)
? getCodexQuotaBreakdown(quota.windows)
const compactMetaBadges = hasGroupedVariants ? (
<>
<div
className="inline-flex shrink-0 items-center overflow-hidden rounded-md border border-border/60 bg-muted/60 shadow-sm shadow-black/5 dark:bg-zinc-900/80"
title={groupedHeaderVariants
.map((variant) => variant.audienceLabel ?? variant.detailLabel ?? 'Variant')
.join(' • ')}
>
{groupedVariantSummaryLabel ? (
<span className="inline-flex min-w-[2.2rem] items-center justify-center px-1.5 py-1 text-[9px] font-semibold leading-none text-foreground/80">
{groupedVariantSummaryLabel}
</span>
) : (
groupedHeaderVariants.map((variant, index) => (
<span
key={variant.id}
className={cn(
'inline-flex min-w-[1.9rem] items-center justify-center px-1.5 py-1 text-[9px] font-semibold leading-none',
index > 0 && 'border-l border-border/50',
variant.audience === 'business'
? 'bg-sky-500/12 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300'
: variant.audience === 'personal'
? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
: 'bg-muted text-muted-foreground'
)}
>
{getVariantMarkerLabel(
variant.audience,
variant.audienceLabel ?? variant.detailLabel ?? null
)}
</span>
))
)}
</div>
{account.paused && (
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
Paused
</span>
)}
</>
) : undefined;
const groupedQuotaRows =
hasGroupedVariants && showQuota
? (account.variants ?? []).map((variant, index) => {
const quotaQuery = variantQuotaQueries[index];
const minQuota = getProviderMinQuota(account.provider, quotaQuery?.data);
const quotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const quotaValue = quotaLabel !== null ? Number(quotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quotaQuery?.data);
const label = variant.audienceLabel ?? variant.detailLabel ?? cleanEmail(variant.email);
return (
<div key={variant.id} className="space-y-0.5">
<div className="flex items-center justify-between gap-2 text-[8px]">
<span className="text-muted-foreground/80 truncate">{label}</span>
<span className="font-mono text-foreground/80 shrink-0">
{quotaQuery?.isLoading
? t('accountCard.quotaLoading')
: quotaValue !== null
? `${quotaLabel}%`
: failureInfo?.label || t('accountCard.quotaUnavailable')}
</span>
</div>
{quotaValue !== null && (
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
getCompactQuotaColor(quotaValue)
)}
style={{ width: `${quotaValue}%` }}
/>
</div>
)}
</div>
);
})
: null;
const codexQuotaRows = [
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{ label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null);
const claudeQuotaRows =
isClaudeProvider && quota && isClaudeQuotaResult(quota)
? [
{
label: '5h',
value:
quota.coreUsage?.fiveHour?.remainingPercent ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')
?.remainingPercent ??
null,
},
{
label: 'Wk',
value:
quota.coreUsage?.weekly?.remainingPercent ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.remainingPercent ??
null,
},
].filter((row): row is { label: string; value: number } => row.value !== null)
: [];
const compactQuotaRows = isCodexProvider
? codexQuotaRows
: isClaudeProvider
? claudeQuotaRows
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quota);
const FailureIcon =
failureInfo?.label === 'Reauth'
? KeyRound
: failureInfo?.tone === 'warning'
? AlertTriangle
: AlertCircle;
const failureTextClass =
failureInfo?.tone === 'warning'
? 'text-amber-600 dark:text-amber-400'
: failureInfo?.tone === 'destructive'
? 'text-destructive'
: 'text-muted-foreground/70';
// Tier badge (AGY only) - show P for Pro, U for Ultra
const showTierBadge =
account.provider === 'agy' &&
account.tier &&
account.tier !== 'unknown' &&
account.tier !== 'free';
const headerEnd = (
<>
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'h-4 w-4 shrink-0 transition-all rounded-full',
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
)}
onClick={(e) => {
e.stopPropagation();
onPauseToggle(account.memberIds ?? [account.id], !account.paused);
}}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-2.5 h-2.5 animate-spin" />
) : account.paused ? (
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
) : (
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
</>
);
return (
<div
@@ -214,171 +304,35 @@ export function AccountCard({
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
{/* Header row: Email + Tier | Pause button | Drag handle */}
<div className="flex items-center gap-1.5 mb-1">
{/* Email with tier badge inline */}
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)}
</span>
{showTierBadge && (
<span
className={cn(
'text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0',
account.tier === 'ultra'
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
)}
>
{account.tier}
</span>
)}
</div>
{/* Pause/Resume button */}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'h-4 w-4 shrink-0',
'transition-all rounded-full',
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
)}
onClick={(e) => {
e.stopPropagation();
onPauseToggle(account.id, !account.paused);
}}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-2.5 h-2.5 animate-spin" />
) : account.paused ? (
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
) : (
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Drag handle */}
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
</div>
<AccountCardStats
success={account.successCount}
failure={account.failureCount}
showDetails={showDetails}
<AccountSurfaceCard
mode="compact"
provider={account.provider}
accountId={account.id}
email={account.email}
displayEmail={cleanEmail(account.email)}
tokenFile={account.tokenFile}
tier={account.tier}
isDefault={account.isDefault}
paused={account.paused}
privacyMode={privacyMode}
showQuota={showQuota && !hasGroupedVariants}
quota={quota}
quotaLoading={quotaLoading}
runtimeLastUsed={account.lastUsedAt}
headerEnd={headerEnd}
compactMetaBadges={compactMetaBadges}
footerSlot={
<>
<AccountCardStats
success={account.successCount}
failure={account.failureCount}
showDetails={showDetails}
/>
{groupedQuotaRows && <div className="mt-2 px-0.5 space-y-1">{groupedQuotaRows}</div>}
</>
}
/>
{/* Quota bar for CLIProxy accounts */}
{isCliproxyProvider && (
<div className="mt-2 px-0.5">
{quotaLoading ? (
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
<span>{t('accountCard.quotaLoading')}</span>
</div>
) : minQuotaValue !== null ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-0.5 cursor-help">
<div className="flex items-center justify-between">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
{t('accountCard.quota')}
</span>
<span
className={cn(
'text-[10px] font-mono font-bold',
minQuotaValue > 50
? 'text-emerald-600 dark:text-emerald-400'
: minQuotaValue > 20
? 'text-amber-500'
: 'text-red-500'
)}
>
{minQuotaLabel}%
</span>
</div>
{compactQuotaRows.length > 0 && (
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
{compactQuotaRows.map((row) => (
<span key={row.label}>
{row.label} {row.value}%
</span>
))}
</div>
)}
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
minQuotaValue > 50
? 'bg-emerald-500'
: minQuotaValue > 20
? 'bg-amber-500'
: 'bg-red-500'
)}
style={{ width: `${minQuotaValue}%` }}
/>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.success ? (
<div className="text-[8px] text-muted-foreground/60">
{t('accountCard.quotaUnavailable')}
</div>
) : failureInfo ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-1 text-[8px]', failureTextClass)}>
<FailureIcon className="w-2.5 h-2.5" />
<span>{failureInfo.label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
)}
<div
className={cn(
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
+9 -1
View File
@@ -2,6 +2,8 @@
* Type definitions for Account Flow Visualization
*/
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
/** Account tier for subscription level */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
@@ -14,7 +16,9 @@ export interface DragOffset {
export interface AccountData {
id: string;
email: string;
tokenFile?: string;
provider: string;
isDefault?: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
@@ -22,6 +26,10 @@ export interface AccountData {
paused?: boolean;
/** Account tier (Antigravity only) */
tier?: AccountTier;
/** Raw member IDs when one visual card represents multiple underlying auth records */
memberIds?: string[];
/** Raw variant details shown inside grouped visual cards */
variants?: AccountVisualVariant[];
}
export interface ProviderData {
@@ -34,7 +42,7 @@ export interface ProviderData {
export interface AccountFlowVizProps {
providerData: ProviderData;
onBack?: () => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
isPausingAccount?: boolean;
}
+9 -1
View File
@@ -44,8 +44,16 @@ export function formatTimelineTime(date: Date): string {
export function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] {
const events: ConnectionEvent[] = [];
// Use a shared base time so events from all accounts interleave in the timeline.
// Without this, accounts with more recent lastUsedAt dominate the sorted output.
const now = Date.now();
const sharedBaseTime = accounts.reduce((latest, a) => {
const t = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : now;
return Math.max(latest, isNaN(t) ? now : t);
}, now);
accounts.forEach((account) => {
const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date();
const lastUsed = new Date(sharedBaseTime);
// Helper to add events
const addEvents = (count: number, status: 'success' | 'failed') => {
@@ -0,0 +1,330 @@
import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getProviderMinQuota,
getProviderResetTime,
getQuotaFailureInfo,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Clock,
HelpCircle,
KeyRound,
Loader2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
type AccountSurfaceMode = 'compact' | 'detailed';
interface AccountQuotaPanelProps {
provider: string;
quota?: UnifiedQuotaResult;
quotaLoading?: boolean;
runtimeLastUsed?: string;
mode: AccountSurfaceMode;
className?: string;
}
function getQuotaColor(percentage: number): string {
const clamped = Math.max(0, Math.min(100, percentage));
if (clamped <= 20) return 'bg-destructive';
if (clamped <= 50) return 'bg-yellow-500';
return 'bg-green-500';
}
function formatRelativeTime(dateStr: string | undefined): string {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
const diff = Date.now() - date.getTime();
if (diff < 0) return 'just now';
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
} catch {
return '';
}
}
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
if (!lastUsedAt) return false;
try {
return Date.now() - new Date(lastUsedAt).getTime() < 60 * 60 * 1000;
} catch {
return false;
}
}
export function AccountQuotaPanel({
provider,
quota,
quotaLoading,
runtimeLastUsed,
mode,
className,
}: AccountQuotaPanelProps) {
const { t } = useTranslation();
const normalizedProvider = provider.toLowerCase();
const isCodexProvider = normalizedProvider === 'codex';
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
const minQuota = getProviderMinQuota(provider, quota);
const resetTime = getProviderResetTime(provider, quota);
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quota);
const FailureIcon =
failureInfo?.label === 'Reauth'
? KeyRound
: failureInfo?.tone === 'warning'
? AlertTriangle
: AlertCircle;
const codexBreakdown =
isCodexProvider && quota && isCodexQuotaResult(quota)
? getCodexQuotaBreakdown(quota.windows)
: null;
const compactQuotaRows = isCodexProvider
? [
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{
label: mode === 'compact' ? 'Wk' : 'Weekly',
value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null,
},
]
: isClaudeProvider && quota && isClaudeQuotaResult(quota)
? [
{
label: '5h',
value:
quota.coreUsage?.fiveHour?.remainingPercent ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')
?.remainingPercent ??
null,
},
{
label: mode === 'compact' ? 'Wk' : 'Weekly',
value:
quota.coreUsage?.weekly?.remainingPercent ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.remainingPercent ??
null,
},
]
: [];
const quotaRows = compactQuotaRows.filter(
(row): row is { label: string; value: number } => row.value !== null
);
if (quotaLoading) {
return (
<div className={cn('flex items-center gap-1.5 text-xs text-muted-foreground', className)}>
<Loader2 className="w-3 h-3 animate-spin" />
<span>{mode === 'compact' ? t('accountCard.quotaLoading') : 'Loading quota...'}</span>
</div>
);
}
if (minQuotaValue !== null) {
return (
<div className={cn(mode === 'compact' ? 'px-0.5' : '', className)}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{mode === 'compact' ? (
<div className="space-y-0.5 cursor-help">
<div className="flex items-center justify-between">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
{t('accountCard.quota')}
</span>
<span
className={cn(
'text-[10px] font-mono font-bold',
minQuotaValue > 50
? 'text-emerald-600 dark:text-emerald-400'
: minQuotaValue > 20
? 'text-amber-500'
: 'text-red-500'
)}
>
{minQuotaLabel}%
</span>
</div>
{quotaRows.length > 0 && (
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
{quotaRows.map((row) => (
<span key={row.label}>
{row.label} {row.value}%
</span>
))}
</div>
)}
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
minQuotaValue > 50
? 'bg-emerald-500'
: minQuotaValue > 20
? 'bg-amber-500'
: 'bg-red-500'
)}
style={{ width: `${minQuotaValue}%` }}
/>
</div>
</div>
) : (
<div className="space-y-1.5 cursor-help">
<div className="flex items-center gap-1.5 text-xs">
{isRecentlyUsed(runtimeLastUsed) ? (
<>
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
<span className="text-emerald-600 dark:text-emerald-400">
Active · {formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : runtimeLastUsed ? (
<>
<Clock className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">
Last used {formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : (
<>
<HelpCircle className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">Not used yet</span>
</>
)}
</div>
{quotaRows.length > 0 ? (
<div className="space-y-1.5">
{quotaRows.map((row) => (
<div key={row.label} className="flex items-center gap-2">
<span className="w-10 text-[10px] text-muted-foreground">
{row.label}
</span>
<Progress
value={Math.max(0, Math.min(100, row.value))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(row.value)}
/>
<span className="text-xs font-medium w-10 text-right">{row.value}%</span>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2">
<Progress
value={Math.max(0, Math.min(100, minQuotaValue))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuotaValue)}
/>
<span className="text-xs font-medium w-10 text-right">{minQuotaLabel}%</span>
</div>
)}
</div>
)}
</TooltipTrigger>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-xs">
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
if (quota?.success) {
return mode === 'compact' ? (
<div className={cn('text-[8px] text-muted-foreground/60', className)}>
{t('accountCard.quotaUnavailable')}
</div>
) : (
<div className={className}>
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
>
<HelpCircle className="w-3 h-3" />
{t('accountCard.quotaUnavailable')}
</Badge>
</div>
);
}
if (!failureInfo) {
return null;
}
const failureClass =
failureInfo.tone === 'warning'
? 'text-amber-600 dark:text-amber-400 border-amber-500/50'
: failureInfo.tone === 'destructive'
? 'text-destructive border-destructive/50'
: 'text-muted-foreground/70 border-muted-foreground/50';
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{mode === 'compact' ? (
<div className={cn('flex items-center gap-1 text-[8px]', failureClass, className)}>
<FailureIcon className="w-2.5 h-2.5" />
<span>{failureInfo.label}</span>
</div>
) : (
<div className={className}>
<Badge variant="outline" className={cn('text-[10px] h-5 px-2 gap-1', failureClass)}>
<FailureIcon className="w-3 h-3" />
{failureInfo.label}
</Badge>
</div>
)}
</TooltipTrigger>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-[260px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,231 @@
import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
import { getAccountIdentityPresentation } from '@/lib/account-identity';
import { cn } from '@/lib/utils';
import { Pause, Star, User } from 'lucide-react';
import { AccountQuotaPanel } from './account-quota-panel';
type AccountSurfaceMode = 'compact' | 'detailed';
type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
interface AccountSurfaceCardProps {
mode: AccountSurfaceMode;
provider: string;
accountId: string;
email?: string;
displayEmail?: string;
tokenFile?: string;
tier?: AccountTier;
isDefault?: boolean;
paused?: boolean;
privacyMode?: boolean;
showQuota?: boolean;
quota?: UnifiedQuotaResult;
quotaLoading?: boolean;
runtimeLastUsed?: string;
beforeIdentity?: ReactNode;
headerEnd?: ReactNode;
compactMetaBadges?: ReactNode;
bodySlot?: ReactNode;
footerSlot?: ReactNode;
quotaInsetClassName?: string;
className?: string;
}
function getAudienceBadgeClass(audience: 'business' | 'personal' | 'unknown') {
if (audience === 'business') {
return 'bg-sky-500/12 text-sky-700 dark:text-sky-300';
}
if (audience === 'personal') {
return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300';
}
return 'bg-muted text-muted-foreground';
}
function getTierBadgeClass(tier: AccountTier | undefined) {
return tier === 'ultra'
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400';
}
function getCompactAudienceBadgeLabel(audience: 'business' | 'personal' | 'unknown') {
if (audience === 'business') return 'Biz';
if (audience === 'personal') return 'Pers';
return '?';
}
export function AccountSurfaceCard({
mode,
provider,
accountId,
email,
displayEmail,
tokenFile,
tier,
isDefault,
paused,
privacyMode,
showQuota,
quota,
quotaLoading,
runtimeLastUsed,
beforeIdentity,
headerEnd,
compactMetaBadges,
bodySlot,
footerSlot,
quotaInsetClassName,
className,
}: AccountSurfaceCardProps) {
const identity = getAccountIdentityPresentation(accountId, email, tokenFile);
const title = displayEmail || identity.email || accountId;
const normalizedProvider = provider.toLowerCase();
const showTierBadge =
(normalizedProvider === 'agy' || normalizedProvider === 'antigravity') &&
tier &&
tier !== 'unknown' &&
tier !== 'free';
const isCompact = mode === 'compact';
const defaultCompactMetaBadges = (
<>
{showTierBadge && (
<span
className={cn(
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
getTierBadgeClass(tier)
)}
>
{tier}
</span>
)}
{identity.audienceLabel && (
<span
title={identity.audienceLabel}
className={cn(
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
identity.audience === 'business'
? 'bg-sky-500/15 text-sky-700 dark:bg-sky-500/25 dark:text-sky-300'
: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/25 dark:text-emerald-300'
)}
>
{getCompactAudienceBadgeLabel(identity.audience)}
</span>
)}
{paused && (
<span className="text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
Paused
</span>
)}
</>
);
return (
<div className={cn('flex flex-col gap-2', className)}>
<div className="flex items-start justify-between gap-2">
<div className={cn('flex min-w-0 flex-1', isCompact ? 'gap-2' : 'gap-3')}>
{beforeIdentity}
{!isCompact && (
<div className="relative shrink-0">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
<User className="w-4 h-4" />
</div>
{showTierBadge && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded ring-1 ring-background',
tier === 'ultra'
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
)}
>
{tier === 'ultra' ? 'U' : 'P'}
</span>
)}
</div>
)}
<div className="min-w-0 flex-1">
<div
className={cn('flex items-center min-w-0', isCompact ? 'gap-1.5' : 'gap-2 flex-wrap')}
>
<span
title={title}
className={cn(
isCompact
? 'flex-1 min-w-0 text-xs font-semibold tracking-tight truncate leading-none'
: 'font-medium text-sm truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{title}
</span>
{isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)}
{!isCompact && identity.audienceLabel && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 border-transparent',
getAudienceBadgeClass(identity.audience)
)}
>
{identity.audienceLabel}
</Badge>
)}
{!isCompact && identity.detailLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{identity.detailLabel}
</Badge>
)}
{!isCompact && isDefault && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
<Star className="w-2.5 h-2.5 fill-current" />
Default
</Badge>
)}
{!isCompact && paused && (
<Badge
variant="outline"
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
>
<Pause className="w-2 h-2 mr-0.5" />
Paused
</Badge>
)}
</div>
{bodySlot && <div className="mt-1">{bodySlot}</div>}
</div>
</div>
{headerEnd && (
<div className={cn('flex items-center shrink-0', isCompact ? 'gap-0.5' : 'gap-1')}>
{headerEnd}
</div>
)}
</div>
{footerSlot}
{showQuota && (
<AccountQuotaPanel
provider={provider}
quota={quota}
quotaLoading={quotaLoading}
runtimeLastUsed={runtimeLastUsed}
mode={mode}
className={quotaInsetClassName}
/>
)}
</div>
);
}
@@ -18,6 +18,7 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
import { usePrivacy } from '@/contexts/privacy-context';
import { formatAccountDisplayName } from '@/lib/account-identity';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
import { isDeniedAgyModelId } from '@/lib/utils';
@@ -220,7 +221,9 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
{providerAccounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{privacyMode ? '••••••' : acc.email || acc.id}
{privacyMode
? '••••••'
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)}
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
</option>
))}
@@ -57,6 +57,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
// Calculate URLs and settings based on remote or local mode
const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => {
const remote = cliproxyConfig?.remote;
const localPort = cliproxyConfig?.local?.port ?? port;
if (remote?.enabled && remote?.host) {
const protocol = remote.protocol || 'http';
@@ -78,14 +79,15 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
};
}
// Local mode - use effective management secret from auth tokens API
// Local mode - proxy through dashboard server to avoid cross-origin/port issues
// (e.g., in Docker the browser cannot reach the internal CLIProxy port directly)
const effectiveSecret = authTokens?.managementSecret?.value || 'ccs';
return {
managementUrl: `http://localhost:${port}/management.html`,
checkUrl: `http://localhost:${port}/`,
managementUrl: withApiBase('/cliproxy-local/management.html'),
checkUrl: withApiBase('/cliproxy-local/'),
authToken: effectiveSecret,
isRemote: false,
displayHost: `localhost:${port}`,
displayHost: `localhost:${localPort}`,
};
}, [cliproxyConfig, authTokens, port]);
@@ -95,29 +97,50 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
// Check if CLIProxy is running
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
const updateConnectionState = (connected: boolean, nextError: string | null) => {
if (cancelled) return;
setIsConnected(connected);
setError(nextError);
};
const checkConnection = async () => {
try {
const response = await fetch(checkUrl, {
signal: controller.signal,
});
if (response.ok) {
setIsConnected(true);
setError(null);
if (isRemote) {
// Remote mode: use the test endpoint via same-origin API to avoid CORS
const remote = cliproxyConfig?.remote;
const result = await api.cliproxyServer.test({
host: remote?.host ?? '',
port: remote?.port,
protocol: remote?.protocol ?? 'http',
authToken: remote?.auth_token,
});
if (result?.reachable) {
updateConnectionState(true, null);
} else {
updateConnectionState(
false,
result?.error
? `Remote CLIProxy at ${displayHost}: ${result.error}`
: `Remote CLIProxy at ${displayHost} returned an error`
);
}
} else {
setIsConnected(false);
setError(
isRemote
? `Remote CLIProxy at ${displayHost} returned an error`
: 'CLIProxy returned an error'
);
// Local mode: probe the proxied control panel root directly.
const response = await fetch(checkUrl, { signal: controller.signal });
if (response.ok) {
updateConnectionState(true, null);
} else {
updateConnectionState(false, 'CLIProxy returned an error');
}
}
} catch (e) {
// Ignore abort errors (component unmounting)
if (e instanceof Error && e.name === 'AbortError') return;
setIsConnected(false);
setError(
updateConnectionState(
false,
isRemote
? `Remote CLIProxy at ${displayHost} is not reachable`
: 'CLIProxy is not running'
@@ -130,8 +153,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
checkConnection().finally(() => clearTimeout(timeoutId));
// Cleanup: abort fetch on unmount
return () => controller.abort();
}, [checkUrl, isRemote, displayHost]);
return () => {
cancelled = true;
controller.abort();
};
}, [checkUrl, isRemote, displayHost, cliproxyConfig]);
const postAutoLoginCredentials = useCallback(() => {
// Auto-login can only run when iframe has loaded and authToken is available.
@@ -140,12 +166,20 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
}
try {
// Derive apiBase from checkUrl (remove trailing slash)
const apiBase = checkUrl.replace(/\/$/, '');
// Derive apiBase and targetOrigin from checkUrl.
// Local mode uses the same-origin dashboard proxy; remote mode stays absolute.
const apiBase = checkUrl.startsWith('/')
? new URL(checkUrl.replace(/\/$/, ''), window.location.origin).href
: checkUrl.replace(/\/$/, '');
const apiBaseUrl = new URL(`${apiBase}/`);
const targetOrigin = apiBaseUrl.origin;
// Security: Validate iframe src matches target origin before sending credentials
const iframeSrc = iframeRef.current.src;
if (!iframeSrc.startsWith(apiBase)) {
// Security: Validate iframe src matches the expected origin/path before sending credentials.
const iframeUrl = new URL(iframeRef.current.src, window.location.origin);
if (
iframeUrl.origin !== apiBaseUrl.origin ||
!iframeUrl.pathname.startsWith(apiBaseUrl.pathname)
) {
console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage');
return;
}
@@ -157,7 +191,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
apiBase,
managementKey: authToken,
},
apiBase
targetOrigin
);
} catch (e) {
// Cross-origin restriction - expected if not same origin
@@ -3,9 +3,8 @@
* Displays a single OAuth account with actions and quota bar
*/
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,87 +12,69 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { getAccountStats } from '@/lib/cliproxy-account-stats';
import { cn } from '@/lib/utils';
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import {
User,
Star,
MoreHorizontal,
Clock,
Trash2,
AlertTriangle,
Check,
FolderCode,
Loader2,
CheckCircle2,
HelpCircle,
MoreHorizontal,
Pause,
Play,
AlertCircle,
AlertTriangle,
FolderCode,
Check,
KeyRound,
Star,
Trash2,
} from 'lucide-react';
import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getQuotaFailureInfo,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { getAccountStats } from '@/lib/cliproxy-account-stats';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import { useTranslation } from 'react-i18next';
import type { AccountItemProps } from './types';
/**
* Get color class based on quota percentage
*/
function getQuotaColor(percentage: number): string {
const clamped = Math.max(0, Math.min(100, percentage));
if (clamped <= 20) return 'bg-destructive';
if (clamped <= 50) return 'bg-yellow-500';
return 'bg-green-500';
}
/**
* Format relative time (e.g., "5m ago", "2h ago")
*/
function formatRelativeTime(dateStr: string | undefined): string {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 0) return 'just now';
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
} catch {
return '';
function renderProjectId(projectId: string | undefined, privacyMode: boolean | undefined) {
if (projectId) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<FolderCode className="w-3 h-3" aria-hidden="true" />
<span
className={cn(
'font-mono max-w-[180px] truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
title={projectId}
>
{projectId}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">GCP Project ID (read-only)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
}
/**
* Check if account was used recently (within last hour = token likely refreshed)
*/
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
if (!lastUsedAt) return false;
try {
const lastUsed = new Date(lastUsedAt);
const now = new Date();
const diff = now.getTime() - lastUsed.getTime();
return diff < 60 * 60 * 1000; // Within last hour
} catch {
return false;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
<span>Project ID: N/A</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[250px]">
<div className="text-xs space-y-1">
<p className="font-medium text-amber-600">Missing Project ID</p>
<p>This may cause errors. Remove the account and re-add it to fetch the project ID.</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function AccountItem({
@@ -109,390 +90,119 @@ export function AccountItem({
selected,
onSelectChange,
}: AccountItemProps) {
const { t } = useTranslation();
const normalizedProvider = account.provider.toLowerCase();
const isCodexProvider = normalizedProvider === 'codex';
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
const { data: stats } = useCliproxyStats(showQuota);
// Fetch quota for all provider accounts
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
normalizedProvider,
account.id,
showQuota
);
// Get last used time from runtime stats (more accurate than file)
const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt;
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
// Use shared utility functions for provider-specific quota handling
const minQuota = getProviderMinQuota(account.provider, quota);
const nextReset = getProviderResetTime(account.provider, quota);
const codexBreakdown =
isCodexProvider && quota && isCodexQuotaResult(quota)
? getCodexQuotaBreakdown(quota.windows)
: null;
const codexQuotaRows = [
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null);
const claudeQuotaRows =
isClaudeProvider && quota && isClaudeQuotaResult(quota)
? [
{
label: '5h',
value:
quota.coreUsage?.fiveHour?.remainingPercent ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')
?.remainingPercent ??
null,
},
{
label: 'Weekly',
value:
quota.coreUsage?.weekly?.remainingPercent ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.remainingPercent ??
null,
},
].filter((row): row is { label: string; value: number } => row.value !== null)
: [];
const dualWindowQuotaRows = isCodexProvider
? codexQuotaRows
: isClaudeProvider
? claudeQuotaRows
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quota);
const FailureIcon =
failureInfo?.label === 'Reauth'
? KeyRound
: failureInfo?.tone === 'warning'
? AlertTriangle
: AlertCircle;
const failureBadgeClass =
failureInfo?.tone === 'warning'
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
: failureInfo?.tone === 'destructive'
? 'border-destructive/50 text-destructive'
: 'border-muted-foreground/50 text-muted-foreground';
const beforeIdentity =
selectable || onPauseToggle ? (
<div className="flex items-center gap-2 shrink-0">
{selectable && (
<button
type="button"
onClick={() => onSelectChange?.(!selected)}
className={cn(
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/30 hover:border-primary/50'
)}
aria-label={selected ? 'Deselect account' : 'Select account'}
>
{selected && <Check className="w-3 h-3" />}
</button>
)}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onPauseToggle(!account.paused)}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : account.paused ? (
<Play className="w-4 h-4 text-emerald-500" />
) : (
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{account.paused ? 'Resume account' : 'Pause account'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
) : undefined;
const headerEnd = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<div
className={cn(
'flex flex-col gap-2 p-3 rounded-lg border transition-colors overflow-hidden',
'rounded-lg border p-3 transition-colors overflow-hidden',
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30',
account.paused && 'opacity-75',
selected && 'ring-2 ring-primary/50 bg-primary/5'
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Selection checkbox for bulk actions */}
{selectable && (
<button
type="button"
onClick={() => onSelectChange?.(!selected)}
className={cn(
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/30 hover:border-primary/50'
)}
aria-label={selected ? 'Deselect account' : 'Select account'}
>
{selected && <Check className="w-3 h-3" />}
</button>
)}
{/* Pause/Resume toggle button - visible left of avatar */}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onPauseToggle(!account.paused)}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : account.paused ? (
<Play className="w-4 h-4 text-emerald-500" />
) : (
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{account.paused ? 'Resume account' : 'Pause account'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Avatar with tier badge overlay */}
<div className="relative shrink-0">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
account.isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
<User className="w-4 h-4" />
</div>
{/* Tier badge - fixed position on avatar */}
{account.tier && account.tier !== 'unknown' && account.tier !== 'free' && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded',
'ring-1 ring-background',
account.tier === 'ultra'
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
)}
>
{account.tier === 'ultra' ? 'U' : 'P'}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn('font-medium text-sm truncate', privacyMode && PRIVACY_BLUR_CLASS)}
>
{account.email || account.id}
</span>
{account.isDefault && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
<Star className="w-2.5 h-2.5 fill-current" />
Default
</Badge>
)}
{account.paused && (
<Badge
variant="outline"
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
>
<Pause className="w-2 h-2 mr-0.5" />
Paused
</Badge>
)}
</div>
{/* Project ID for Antigravity accounts - read-only */}
{account.provider === 'agy' && (
<div className="flex items-center gap-1.5 mt-1">
{account.projectId ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<FolderCode className="w-3 h-3" aria-hidden="true" />
<span
className={cn(
'font-mono max-w-[180px] truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
title={account.projectId}
>
{account.projectId}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">GCP Project ID (read-only)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
<span>Project ID: N/A</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[250px]">
<div className="text-xs space-y-1">
<p className="font-medium text-amber-600">Missing Project ID</p>
<p>
This may cause errors. Remove the account and re-add it to fetch the
project ID.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
{account.lastUsedAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Clock className="w-3 h-3" />
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
</div>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Quota bar - supports all providers with quota API */}
{showQuota && (
<div className="pl-11">
{quotaLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Loading quota...</span>
</div>
) : minQuotaValue !== null ? (
<div className="space-y-1.5">
{/* Status indicator based on runtime usage, not file state */}
<div className="flex items-center gap-1.5 text-xs">
{wasRecentlyUsed ? (
<>
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
<span className="text-emerald-600 dark:text-emerald-400">
Active · {formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : runtimeLastUsed ? (
<>
<Clock className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">
Last used {formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : (
<>
<HelpCircle className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">Not used yet</span>
</>
)}
</div>
{/* Quota bar */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{dualWindowQuotaRows.length > 0 ? (
<div className="space-y-1.5">
{dualWindowQuotaRows.map((row) => (
<div key={row.label} className="flex items-center gap-2">
<span className="w-10 text-[10px] text-muted-foreground">
{row.label}
</span>
<Progress
value={Math.max(0, Math.min(100, row.value))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(row.value)}
/>
<span className="text-xs font-medium w-10 text-right">
{row.value}%
</span>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2">
<Progress
value={Math.max(0, Math.min(100, minQuotaValue))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuotaValue)}
/>
<span className="text-xs font-medium w-10 text-right">
{minQuotaLabel}%
</span>
</div>
)}
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<QuotaTooltipContent quota={quota} resetTime={nextReset} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : quota?.success ? (
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
>
<HelpCircle className="w-3 h-3" />
{t('accountCard.quotaUnavailable')}
</Badge>
</div>
) : failureInfo ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className={cn('text-[10px] h-5 px-2 gap-1', failureBadgeClass)}
>
<FailureIcon className="w-3 h-3" />
{failureInfo.label}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
)}
<AccountSurfaceCard
mode="detailed"
provider={account.provider}
accountId={account.id}
email={account.email}
displayEmail={account.email || account.id}
tokenFile={account.tokenFile}
tier={account.tier}
isDefault={account.isDefault}
paused={account.paused}
privacyMode={privacyMode}
showQuota={showQuota}
quota={quota}
quotaLoading={quotaLoading}
runtimeLastUsed={runtimeLastUsed}
beforeIdentity={beforeIdentity}
headerEnd={headerEnd}
bodySlot={
account.provider === 'agy' ? renderProjectId(account.projectId, privacyMode) : null
}
quotaInsetClassName="pl-11"
/>
</div>
);
}
@@ -62,6 +62,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
const inspectProfileCommand = diagnostics.config.activeProfile
? `codex --profile ${diagnostics.config.activeProfile}`
: 'codex';
const supportsManagedRouting = diagnostics.binary.supportsConfigOverrides;
return (
<ScrollArea className="h-full">
@@ -142,29 +143,42 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
There are two supported paths. Use <code>ccsxp</code> if you want the built-in CCS
Codex provider shortcut. Use the saved recipe below if you want plain{' '}
<code>codex</code> or a personal alias like <code>cxp</code> to default to CLIProxy.
</p>
<div className="rounded-md border bg-muted/20 p-3">
<p className="font-medium text-foreground">Saved native Codex recipe</p>
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-3 text-xs text-foreground">
{CLIPROXY_NATIVE_CODEX_RECIPE}
</pre>
</div>
<div className="space-y-1">
{supportsManagedRouting ? (
<>
<p>
There are two supported paths. Use <code>ccsxp</code> if you want the built-in CCS
Codex provider shortcut. Use the saved recipe below if you want plain{' '}
<code>codex</code> or a personal alias like <code>cxp</code> to default to
CLIProxy.
</p>
<div className="rounded-md border bg-muted/20 p-3">
<p className="font-medium text-foreground">Saved native Codex recipe</p>
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-3 text-xs text-foreground">
{CLIPROXY_NATIVE_CODEX_RECIPE}
</pre>
</div>
<div className="space-y-1">
<p>
1. Save a provider named <code>cliproxy</code> with the base URL and env key
above.
</p>
<p>
2. In <strong>Top-level settings</strong>, set <strong>Default provider</strong>{' '}
to <code>cliproxy</code>.
</p>
<p>
3. Export <code>CLIPROXY_API_KEY</code> in your shell before launching native
Codex.
</p>
</div>
</>
) : (
<p>
1. Save a provider named <code>cliproxy</code> with the base URL and env key above.
This Codex build can still use the native path, but CCS-backed Codex routing via{' '}
<code>ccsxp</code> or <code>ccs codex --target codex</code> stays unavailable until
the detected Codex binary exposes <code>--config</code> overrides.
</p>
<p>
2. In <strong>Top-level settings</strong>, set <strong>Default provider</strong> to{' '}
<code>cliproxy</code>.
</p>
<p>
3. Export <code>CLIPROXY_API_KEY</code> in your shell before launching native Codex.
</p>
</div>
)}
</CardContent>
</Card>
@@ -275,12 +289,16 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
{
label: 'CCS Codex shortcut',
command: 'ccsxp "your prompt"',
description: 'Run the built-in CCS Codex provider on native Codex.',
description: supportsManagedRouting
? 'Run the built-in CCS Codex provider on native Codex.'
: 'Requires a Codex build that exposes --config overrides.',
},
{
label: 'Explicit provider route',
command: 'ccs codex --target codex "your prompt"',
description: 'Use the explicit built-in Codex provider route.',
description: supportsManagedRouting
? 'Use the explicit built-in Codex provider route.'
: 'Requires a Codex build that exposes --config overrides.',
},
{
label: diagnostics.config.activeProfile
@@ -312,9 +330,19 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<div className="rounded-md border p-3 text-sm">
<p className="font-medium">CCS Codex provider / bridge</p>
<p className="mt-1 text-muted-foreground">
Use <code>ccsxp</code> or <code>ccs codex --target codex</code> when you want the
built-in CCS Codex provider on native Codex. That path uses transient CCS-managed
overrides and is separate from the saved <code>cliproxy</code> recipe above.
{supportsManagedRouting ? (
<>
Use <code>ccsxp</code> or <code>ccs codex --target codex</code> when you want
the built-in CCS Codex provider on native Codex. That path uses transient
CCS-managed overrides and is separate from the saved <code>cliproxy</code>{' '}
recipe above.
</>
) : (
<>
The CCS Codex provider route is currently unavailable because the detected Codex
build does not expose <code>--config</code> overrides.
</>
)}
</p>
</div>
</CardContent>
@@ -4,12 +4,13 @@
import type React from 'react';
import { ChevronRight, AlertTriangle } from 'lucide-react';
import { formatAccountDisplayName } from '@/lib/account-identity';
import { cn, STATUS_COLORS } from '@/lib/utils';
import { PROVIDER_COLORS } from '@/lib/provider-config';
import { ProviderIcon } from '@/components/shared/provider-icon';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { ProviderStats } from '../types';
import { getSuccessRate, cleanEmail } from '../utils';
import { getSuccessRate } from '../utils';
import { InlineStatsBadge } from './inline-stats-badge';
interface ProviderCardProps {
@@ -112,7 +113,11 @@ export function ProviderCard({
<div
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
title={
privacyMode
? '••••••'
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)
}
/>
{isMissingProjectId && (
<TooltipProvider>
@@ -5,8 +5,8 @@
import { useState, useMemo, useEffect } from 'react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
import { getProviderDisplayName } from '@/lib/provider-config';
import { getAccountStats } from '@/lib/cliproxy-account-stats';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import type { AccountRow, ProviderStats } from './types';
import { ACCOUNT_COLORS } from './utils';
@@ -74,28 +74,33 @@ export function useAuthMonitorData(): AuthMonitorData {
const providerData = providerMap.get(providerKey);
if (!providerData) return;
status.accounts?.forEach((account: OAuthAccount) => {
const realStats = getAccountStats(statsData, account);
const success = realStats?.successCount ?? 0;
const failure = realStats?.failureCount ?? 0;
tSuccess += success;
tFailure += failure;
providerData.success += success;
providerData.failure += failure;
const normalizedAccounts = (status.accounts ?? []).map((account: OAuthAccount) => ({
...account,
provider: account.provider || status.provider,
}));
buildAccountVisualGroups(normalizedAccounts, statsData).forEach((groupedAccount) => {
tSuccess += groupedAccount.successCount;
tFailure += groupedAccount.failureCount;
providerData.success += groupedAccount.successCount;
providerData.failure += groupedAccount.failureCount;
const row: AccountRow = {
id: account.id,
email: account.email || account.id,
id: groupedAccount.id,
email: groupedAccount.email,
tokenFile: groupedAccount.tokenFile,
provider: status.provider,
displayName: status.displayName,
isDefault: account.isDefault,
successCount: success,
failureCount: failure,
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
isDefault: groupedAccount.isDefault,
successCount: groupedAccount.successCount,
failureCount: groupedAccount.failureCount,
lastUsedAt: groupedAccount.lastUsedAt,
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
projectId: account.projectId,
paused: account.paused,
tier: account.tier,
projectId: groupedAccount.projectId,
paused: groupedAccount.paused,
tier: groupedAccount.tier,
memberIds: groupedAccount.memberIds,
variants: groupedAccount.variants,
};
accountsList.push(row);
providerData.accounts.push(row);
@@ -10,7 +10,12 @@ import { STATUS_COLORS } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { AccountFlowViz } from '@/components/account-flow-viz';
import { usePrivacy } from '@/contexts/privacy-context';
import { usePauseAccount, useResumeAccount } from '@/hooks/use-cliproxy';
import {
useBulkPauseAccounts,
useBulkResumeAccounts,
usePauseAccount,
useResumeAccount,
} from '@/hooks/use-cliproxy';
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
import { useAuthMonitorData } from './hooks';
@@ -66,14 +71,37 @@ export function AuthMonitor() {
// Account control mutations for flow viz
const pauseMutation = usePauseAccount();
const resumeMutation = useResumeAccount();
const bulkPauseMutation = useBulkPauseAccounts();
const bulkResumeMutation = useBulkResumeAccounts();
// Get selected provider data for detail view
const selectedProviderData = effectiveProvider
? providerStats.find((ps) => ps.provider === effectiveProvider)
: null;
const handlePauseToggle = (accountId: string, paused: boolean) => {
if (!effectiveProvider || pauseMutation.isPending || resumeMutation.isPending) return;
const handlePauseToggle = (accountIds: string[], paused: boolean) => {
if (
!effectiveProvider ||
pauseMutation.isPending ||
resumeMutation.isPending ||
bulkPauseMutation.isPending ||
bulkResumeMutation.isPending
) {
return;
}
if (accountIds.length > 1) {
if (paused) {
bulkPauseMutation.mutate({ provider: effectiveProvider, accountIds });
} else {
bulkResumeMutation.mutate({ provider: effectiveProvider, accountIds });
}
return;
}
const [accountId] = accountIds;
if (!accountId) return;
if (paused) {
pauseMutation.mutate({ provider: effectiveProvider, accountId });
} else {
@@ -165,7 +193,12 @@ export function AuthMonitor() {
providerData={selectedProviderData}
onBack={() => setSelectedProvider(null)}
onPauseToggle={handlePauseToggle}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isPausingAccount={
pauseMutation.isPending ||
resumeMutation.isPending ||
bulkPauseMutation.isPending ||
bulkResumeMutation.isPending
}
/>
) : (
<div className="p-6">
@@ -2,12 +2,15 @@
* Type definitions for Auth Monitor components
*/
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
/** Account tier for subscription level */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
export interface AccountRow {
id: string;
email: string;
tokenFile: string;
provider: string;
displayName: string;
isDefault: boolean;
@@ -21,6 +24,10 @@ export interface AccountRow {
paused?: boolean;
/** Account tier (Antigravity only) */
tier?: AccountTier;
/** Raw member IDs when one visual card represents multiple underlying auth records */
memberIds?: string[];
/** Raw variant details shown inside grouped visual cards */
variants?: AccountVisualVariant[];
}
export interface ProviderStats {
@@ -3,7 +3,9 @@
*/
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react';
import { getAccountIdentityPresentation } from '@/lib/account-identity';
import { cn } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import type { AccountStepProps } from '../types';
@@ -24,29 +26,52 @@ export function AccountStep({
{/* Scrollable account list with max-height for many accounts */}
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
{accounts.map((acc) => (
<button
key={acc.id}
type="button"
onClick={() => onSelect(acc)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div>
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
{acc.email || acc.id}
{accounts.map((acc) => {
const identity = getAccountIdentityPresentation(acc.id, acc.email, acc.tokenFile);
return (
<button
key={acc.id}
type="button"
onClick={() => onSelect(acc)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div className="space-y-1">
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
{identity.email}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{identity.audienceLabel && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 border-transparent',
identity.audience === 'business'
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
)}
>
{identity.audienceLabel}
</Badge>
)}
{identity.detailLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{identity.detailLabel}
</Badge>
)}
{acc.isDefault && (
<span className="text-xs text-muted-foreground">Default account</span>
)}
</div>
</div>
{acc.isDefault && (
<div className="text-xs text-muted-foreground">Default account</div>
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
);
})}
</div>
{/* Divider */}
@@ -3,9 +3,11 @@
*/
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getAccountIdentityPresentation } from '@/lib/account-identity';
import {
Select,
SelectContent,
@@ -44,6 +46,13 @@ export function VariantStep({
selectedProvider === 'agy' && modelName.trim().length > 0
? isDeniedAgyModelId(modelName)
: false;
const selectedAccountIdentity = selectedAccount
? getAccountIdentityPresentation(
selectedAccount.id,
selectedAccount.email,
selectedAccount.tokenFile
)
: null;
const handleModelSelect = (value: string) => {
if (value === CUSTOM_MODEL_VALUE) {
@@ -58,14 +67,38 @@ export function VariantStep({
return (
<div className="space-y-4">
{selectedAccount && (
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
<div className="flex items-start gap-2 p-2 bg-muted/50 rounded-md text-sm">
<User className="w-4 h-4" />
<span>
{t('setupVariant.using')}{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccount.email || selectedAccount.id}
<div className="space-y-1">
<span>
{t('setupVariant.using')}{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccountIdentity?.email}
</span>
</span>
</span>
{(selectedAccountIdentity?.audienceLabel || selectedAccountIdentity?.detailLabel) && (
<div className="flex items-center gap-1.5 flex-wrap">
{selectedAccountIdentity?.audienceLabel && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 border-transparent',
selectedAccountIdentity.audience === 'business'
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
)}
>
{selectedAccountIdentity.audienceLabel}
</Badge>
)}
{selectedAccountIdentity?.detailLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{selectedAccountIdentity.detailLabel}
</Badge>
)}
</div>
)}
</div>
</div>
)}
+26 -11
View File
@@ -2,7 +2,7 @@
* React Query hook for CLIProxyAPI stats
*/
import { useQuery } from '@tanstack/react-query';
import { useQueries, useQuery } from '@tanstack/react-query';
import type {
ModelQuota,
QuotaResult,
@@ -334,6 +334,21 @@ async function fetchGhcpQuotaApi(accountId: string): Promise<GhcpQuotaResult> {
// Re-export unified type from utils for consumers
export type { UnifiedQuotaResult } from '@/lib/utils';
function getAccountQuotaQueryOptions(provider: string, accountId: string, enabled = true) {
const canonicalProvider = normalizeQuotaProvider(provider);
return {
queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
enabled: enabled && !!canonicalProvider && !!accountId,
staleTime: 60000,
refetchInterval: 60000,
refetchOnWindowFocus: false,
refetchOnMount: false,
retry: 1 as const,
};
}
/**
* Fetch quota by provider (dispatcher)
*/
@@ -365,16 +380,16 @@ async function fetchQuotaByProvider(
* Supports agy, codex, claude, gemini, and ghcp providers
*/
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
const canonicalProvider = normalizeQuotaProvider(provider);
return useQuery(getAccountQuotaQueryOptions(provider, accountId, enabled));
}
return useQuery({
queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
enabled: enabled && !!canonicalProvider && !!accountId,
staleTime: 60000, // Match refetchInterval to prevent early refetching
refetchInterval: 60000, // Refresh every 1 minute
refetchOnWindowFocus: false, // Don't refetch on tab switch
refetchOnMount: false, // Don't refetch on component remount (AuthMonitor re-renders)
retry: 1,
export function useAccountQuotas(
accounts: Array<{ provider: string; accountId: string }>,
enabled = true
) {
return useQueries({
queries: accounts.map((account) =>
getAccountQuotaQueryOptions(account.provider, account.accountId, enabled)
),
});
}
+213
View File
@@ -0,0 +1,213 @@
const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']);
const BUSINESS_PLAN_PARTS = new Set(['team']);
// Keep variant parsing aligned with src/cliproxy/accounts/email-account-identity.ts.
// This browser copy stays local because the server module is not bundle-safe for the UI.
export type AccountAudience = 'business' | 'personal' | 'unknown';
export interface AccountIdentityPresentation {
email: string;
audience: AccountAudience;
audienceLabel: string | null;
detailLabel: string | null;
compactDetailLabel: string | null;
inlineLabel: string | null;
}
function normalizeVariantTokenPart(value: string): string {
return value
.trim()
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/-+/g, '-')
.toLowerCase();
}
function formatVariantPart(part: string): string {
const normalized = part.trim().toLowerCase();
if (!normalized) {
return '';
}
switch (normalized) {
case 'team':
return 'Team';
case 'free':
return 'Free';
case 'plus':
return 'Plus';
case 'pro':
return 'Pro';
default:
return /^[a-f0-9]{8}$/i.test(normalized)
? normalized
: normalized
.split(/[._-]+/)
.filter(Boolean)
.map((segment) => segment[0]?.toUpperCase() + segment.slice(1))
.join(' ');
}
}
function extractCanonicalEmailFromAccountId(accountId: string): string | null {
const canonical = accountId.split('#')[0]?.trim();
return canonical && canonical.includes('@') ? canonical : null;
}
function extractVariantKeyFromAccountId(accountId: string, email?: string): string | null {
if (!email) {
return null;
}
const prefix = `${email}#`;
return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null;
}
function extractVariantKeyFromTokenFile(tokenFile?: string, email?: string): string | null {
if (!tokenFile || !email) {
return null;
}
const fileName = tokenFile.split(/[\\/]/).pop() ?? tokenFile;
const baseName = fileName.replace(/\.json$/i, '');
if (!baseName.toLowerCase().startsWith('codex-')) {
return null;
}
const firstDashIndex = baseName.indexOf('-');
const candidate =
firstDashIndex > 0 && !baseName.slice(0, firstDashIndex).includes('@')
? baseName.slice(firstDashIndex + 1)
: baseName;
const emailIndex = candidate.toLowerCase().indexOf(email.toLowerCase());
if (emailIndex === -1) {
return null;
}
const before = normalizeVariantTokenPart(candidate.slice(0, emailIndex));
const after = normalizeVariantTokenPart(candidate.slice(emailIndex + email.length));
const parts = [before, after].filter(Boolean);
return parts.length > 0 ? parts.join('-') : null;
}
function formatWorkspaceLabel(parts: string[]): {
detailLabel: string | null;
compactDetailLabel: string | null;
} {
const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part));
if (workspaceId) {
return {
detailLabel: `Workspace ${workspaceId.toLowerCase()}`,
compactDetailLabel: workspaceId.toLowerCase(),
};
}
const extraLabel = parts.map(formatVariantPart).filter(Boolean).join(' · ');
return {
detailLabel: extraLabel || 'Team',
compactDetailLabel: extraLabel || 'Team',
};
}
export function extractAccountVariantKey(
accountId: string,
email?: string,
tokenFile?: string
): string | null {
const resolvedEmail = email?.trim() || extractCanonicalEmailFromAccountId(accountId) || undefined;
return (
extractVariantKeyFromTokenFile(tokenFile, resolvedEmail) ??
extractVariantKeyFromAccountId(accountId, resolvedEmail)
);
}
export function getAccountIdentityPresentation(
accountId: string,
email?: string,
tokenFile?: string
): AccountIdentityPresentation {
const resolvedEmail = email?.trim() || extractCanonicalEmailFromAccountId(accountId) || accountId;
const variantKey = extractAccountVariantKey(accountId, resolvedEmail, tokenFile);
if (!variantKey) {
return {
email: resolvedEmail,
audience: 'unknown',
audienceLabel: null,
detailLabel: null,
compactDetailLabel: null,
inlineLabel: null,
};
}
const parts = variantKey.split('-').filter(Boolean);
if (parts.length === 0) {
return {
email: resolvedEmail,
audience: 'unknown',
audienceLabel: null,
detailLabel: null,
compactDetailLabel: null,
inlineLabel: null,
};
}
const suffix = parts[parts.length - 1]?.toLowerCase();
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
const workspace = formatWorkspaceLabel(parts.slice(0, -1));
const inlineLabel = ['Business', workspace.detailLabel].filter(Boolean).join(' · ');
return {
email: resolvedEmail,
audience: 'business',
audienceLabel: 'Business',
detailLabel: workspace.detailLabel,
compactDetailLabel: workspace.compactDetailLabel,
inlineLabel,
};
}
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
const detailParts = [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
.filter(Boolean)
.join(' · ');
const detailLabel = detailParts || formatVariantPart(suffix);
const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · ');
return {
email: resolvedEmail,
audience: 'personal',
audienceLabel: 'Personal',
detailLabel,
compactDetailLabel: detailLabel,
inlineLabel,
};
}
const fallbackLabel = parts.map(formatVariantPart).filter(Boolean).join(' · ');
return {
email: resolvedEmail,
audience: 'unknown',
audienceLabel: null,
detailLabel: fallbackLabel || null,
compactDetailLabel: fallbackLabel || null,
inlineLabel: fallbackLabel || null,
};
}
export function formatAccountVariantLabel(
accountId: string,
email?: string,
tokenFile?: string
): string | null {
return getAccountIdentityPresentation(accountId, email, tokenFile).inlineLabel;
}
export function formatAccountDisplayName(
accountId: string,
email?: string,
tokenFile?: string
): string {
const presentation = getAccountIdentityPresentation(accountId, email, tokenFile);
return presentation.inlineLabel
? `${presentation.email} (${presentation.inlineLabel})`
: presentation.email;
}
+148
View File
@@ -0,0 +1,148 @@
import type { OAuthAccount } from '@/lib/api-client';
import { getAccountIdentityPresentation, type AccountAudience } from '@/lib/account-identity';
import { getAccountStats } from '@/lib/cliproxy-account-stats';
import type { CliproxyStats } from '@/hooks/use-cliproxy-stats';
export interface AccountVisualVariant {
id: string;
email: string;
tokenFile: string;
isDefault: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
paused?: boolean;
tier?: OAuthAccount['tier'];
audience: AccountAudience;
audienceLabel: string | null;
detailLabel: string | null;
}
export interface AccountVisualGroup {
id: string;
email: string;
tokenFile: string;
provider: OAuthAccount['provider'];
isDefault: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
paused?: boolean;
tier?: OAuthAccount['tier'];
projectId?: string;
memberIds?: string[];
variants?: AccountVisualVariant[];
}
const AUDIENCE_ORDER: Record<AccountAudience, number> = {
business: 0,
personal: 1,
unknown: 2,
};
function getLatestTimestamp(current?: string, candidate?: string): string | undefined {
if (!candidate) return current;
if (!current) return candidate;
return new Date(candidate).getTime() > new Date(current).getTime() ? candidate : current;
}
function buildAccountVariant(
account: OAuthAccount,
statsData?: Pick<CliproxyStats, 'accountStats'> | null
): AccountVisualVariant {
const identity = getAccountIdentityPresentation(account.id, account.email, account.tokenFile);
const runtimeStats = getAccountStats(statsData, account);
return {
id: account.id,
email: identity.email || account.email || account.id,
tokenFile: account.tokenFile,
isDefault: account.isDefault,
successCount: runtimeStats?.successCount ?? 0,
failureCount: runtimeStats?.failureCount ?? 0,
lastUsedAt: runtimeStats?.lastUsedAt ?? account.lastUsedAt,
paused: account.paused,
tier: account.tier,
audience: identity.audience,
audienceLabel: identity.audienceLabel,
detailLabel: identity.detailLabel,
};
}
function sortAccountVariants(variants: AccountVisualVariant[]): AccountVisualVariant[] {
return [...variants].sort((left, right) => {
const audienceDelta = AUDIENCE_ORDER[left.audience] - AUDIENCE_ORDER[right.audience];
if (audienceDelta !== 0) {
return audienceDelta;
}
const leftLabel = left.audienceLabel ?? left.detailLabel ?? left.id;
const rightLabel = right.audienceLabel ?? right.detailLabel ?? right.id;
return leftLabel.localeCompare(rightLabel);
});
}
export function buildAccountVisualGroups(
accounts: OAuthAccount[],
statsData?: Pick<CliproxyStats, 'accountStats'> | null
): AccountVisualGroup[] {
const buckets = new Map<string, AccountVisualVariant[]>();
const accountMeta = new Map<string, OAuthAccount>();
for (const account of accounts) {
const variant = buildAccountVariant(account, statsData);
const isCodexProvider = account.provider.toLowerCase() === 'codex';
const bucketKey = isCodexProvider ? `${account.provider}:${variant.email}` : account.id;
if (!buckets.has(bucketKey)) {
buckets.set(bucketKey, []);
}
buckets.get(bucketKey)?.push(variant);
accountMeta.set(account.id, account);
}
return Array.from(buckets.entries()).map(([bucketKey, variants]) => {
if (variants.length === 1) {
const [variant] = variants;
const original = accountMeta.get(variant.id);
return {
id: variant.id,
email: variant.email,
tokenFile: variant.tokenFile,
provider: original?.provider ?? 'codex',
isDefault: variant.isDefault,
successCount: variant.successCount,
failureCount: variant.failureCount,
lastUsedAt: variant.lastUsedAt,
paused: variant.paused,
tier: variant.tier,
projectId: original?.projectId,
};
}
const orderedVariants = sortAccountVariants(variants);
const canonicalEmail = orderedVariants[0]?.email ?? bucketKey;
const originalProvider = accountMeta.get(orderedVariants[0]?.id ?? '')?.provider ?? 'codex';
return {
id: bucketKey,
email: canonicalEmail,
tokenFile: orderedVariants[0]?.tokenFile ?? '',
provider: originalProvider,
isDefault: orderedVariants.some((variant) => variant.isDefault),
successCount: orderedVariants.reduce((sum, variant) => sum + variant.successCount, 0),
failureCount: orderedVariants.reduce((sum, variant) => sum + variant.failureCount, 0),
lastUsedAt: orderedVariants.reduce<string | undefined>(
(latest, variant) => getLatestTimestamp(latest, variant.lastUsedAt),
undefined
),
paused: orderedVariants.every((variant) => Boolean(variant.paused)),
memberIds: orderedVariants.map((variant) => variant.id),
variants: orderedVariants,
};
});
}
+5 -3
View File
@@ -912,15 +912,17 @@ export const api = {
body: JSON.stringify({ accountId }),
}),
remove: (provider: string, accountId: string) =>
request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
request(`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}`, {
method: 'DELETE',
}),
pause: (provider: string, accountId: string) =>
request<{ provider: string; accountId: string; paused: boolean }>(
`/cliproxy/auth/accounts/${provider}/${accountId}/pause`,
`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}/pause`,
{ method: 'POST' }
),
resume: (provider: string, accountId: string) =>
request<{ provider: string; accountId: string; paused: boolean }>(
`/cliproxy/auth/accounts/${provider}/${accountId}/resume`,
`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}/resume`,
{ method: 'POST' }
),
/** Solo mode: activate one account, pause all others */
+12 -3
View File
@@ -9,8 +9,17 @@ export function getAccountStats(
stats: Pick<CliproxyStats, 'accountStats'> | null | undefined,
account: Pick<OAuthAccount, 'provider' | 'email' | 'id'>
): AccountUsageStats | undefined {
const source = account.email || account.id;
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
const sources = Array.from(
new Set([account.id, account.email].filter((value): value is string => Boolean(value?.trim())))
);
return stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source];
for (const source of sources) {
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
const match = stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source];
if (match) {
return match;
}
}
return undefined;
}
+23 -1
View File
@@ -472,8 +472,30 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
kiro: {
provider: 'kiro',
displayName: 'Kiro (AWS)',
defaultModel: 'kiro-claude-sonnet-4-5',
defaultModel: 'kiro-claude-sonnet-4-6',
models: [
{
id: 'kiro-claude-opus-4-6',
name: 'Kiro Claude Opus 4.6',
description: 'Claude Opus 4.6 via Kiro (2.2x credit)',
presetMapping: {
default: 'kiro-claude-opus-4-6',
opus: 'kiro-claude-opus-4-6',
sonnet: 'kiro-claude-sonnet-4-6',
haiku: 'kiro-claude-haiku-4-5',
},
},
{
id: 'kiro-claude-sonnet-4-6',
name: 'Kiro Claude Sonnet 4.6',
description: 'Claude Sonnet 4.6 via Kiro (1.3x credit)',
presetMapping: {
default: 'kiro-claude-sonnet-4-6',
opus: 'kiro-claude-opus-4-6',
sonnet: 'kiro-claude-sonnet-4-6',
haiku: 'kiro-claude-haiku-4-5',
},
},
{
id: 'kiro-claude-opus-4-5',
name: 'Kiro Claude Opus 4.5',
@@ -0,0 +1,184 @@
/**
* Unit tests for generateConnectionEvents() in flow-viz/utils.ts
*
* Regression coverage for the timeline single-account dominance bug:
* accounts with more recent lastUsedAt were dominating the 100-event cap
* because per-account base time was used instead of a shared max.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MAX_TIMELINE_EVENTS, generateConnectionEvents } from '@/components/account/flow-viz/utils';
import type { AccountData } from '@/components/account/flow-viz/types';
// Deterministic Math.random: cycles 0.1, 0.5, 0.9 repeatedly
function mockRandom(): void {
const values = [0.1, 0.5, 0.9];
let idx = 0;
vi.spyOn(Math, 'random').mockImplementation(() => {
const val = values[idx % values.length];
idx++;
return val;
});
}
function makeAccount(
overrides: Partial<AccountData> & Pick<AccountData, 'id' | 'email'>
): AccountData {
return {
provider: 'agy',
successCount: 0,
failureCount: 0,
color: '#000000',
...overrides,
};
}
describe('generateConnectionEvents()', () => {
beforeEach(() => {
mockRandom();
});
afterEach(() => {
vi.restoreAllMocks();
});
// -----------------------------------------------------------------------
// Core regression: multi-account interleaving
// -----------------------------------------------------------------------
it('includes events from more than one account when accounts have different lastUsedAt', () => {
const now = Date.now();
const accounts: AccountData[] = [
makeAccount({
id: 'a1',
email: 'recent@example.com',
successCount: 60,
// Most recent — was the bug trigger
lastUsedAt: new Date(now - 1_000).toISOString(),
}),
makeAccount({
id: 'a2',
email: 'older@example.com',
successCount: 60,
// 7 days older
lastUsedAt: new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(),
}),
];
const events = generateConnectionEvents(accounts);
// Slice to cap
const displayed = events.slice(0, MAX_TIMELINE_EVENTS);
const emails = new Set(displayed.map((e) => e.accountEmail));
expect(emails.size).toBeGreaterThan(1);
expect(emails.has('recent@example.com')).toBe(true);
expect(emails.has('older@example.com')).toBe(true);
});
it('returns at most MAX_TIMELINE_EVENTS events (cap respected by caller slice)', () => {
// generateConnectionEvents returns all events unsorted-by-cap; cap is applied by caller.
// But we verify total output does not exceed successCount + failureCount across accounts.
const accounts: AccountData[] = [
makeAccount({ id: 'a1', email: 'a@x.com', successCount: 50, failureCount: 10 }),
makeAccount({ id: 'a2', email: 'b@x.com', successCount: 50, failureCount: 10 }),
];
const events = generateConnectionEvents(accounts);
expect(events).toHaveLength(120); // 60 + 60 total before cap
expect(events.slice(0, MAX_TIMELINE_EVENTS)).toHaveLength(MAX_TIMELINE_EVENTS);
});
it('returns events sorted by timestamp descending', () => {
const accounts: AccountData[] = [
makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 }),
makeAccount({ id: 'a2', email: 'b@x.com', successCount: 10 }),
];
const events = generateConnectionEvents(accounts);
for (let i = 1; i < events.length; i++) {
expect(events[i - 1].timestamp.getTime()).toBeGreaterThanOrEqual(
events[i].timestamp.getTime()
);
}
});
// -----------------------------------------------------------------------
// Edge cases
// -----------------------------------------------------------------------
it('returns empty array for empty accounts input', () => {
expect(generateConnectionEvents([])).toEqual([]);
});
it('returns empty array when all accounts have zero counts', () => {
const accounts: AccountData[] = [
makeAccount({ id: 'a1', email: 'a@x.com', successCount: 0, failureCount: 0 }),
makeAccount({ id: 'a2', email: 'b@x.com', successCount: 0, failureCount: 0 }),
];
expect(generateConnectionEvents(accounts)).toEqual([]);
});
it('handles accounts with no lastUsedAt (falls back to now)', () => {
const accounts: AccountData[] = [
makeAccount({ id: 'a1', email: 'no-date@x.com', successCount: 5 }),
];
const events = generateConnectionEvents(accounts);
expect(events).toHaveLength(5);
events.forEach((e) => expect(e.accountEmail).toBe('no-date@x.com'));
});
it('handles single account — all events belong to that account', () => {
const accounts: AccountData[] = [
makeAccount({
id: 'solo',
email: 'solo@x.com',
successCount: 3,
failureCount: 2,
lastUsedAt: new Date().toISOString(),
}),
];
const events = generateConnectionEvents(accounts);
expect(events).toHaveLength(5);
events.forEach((e) => expect(e.accountEmail).toBe('solo@x.com'));
});
it('generates correct event ids and status labels', () => {
const accounts: AccountData[] = [
makeAccount({ id: 'acc1', email: 'x@x.com', successCount: 2, failureCount: 1 }),
];
const events = generateConnectionEvents(accounts);
const successes = events.filter((e) => e.status === 'success');
const failures = events.filter((e) => e.status === 'failed');
expect(successes).toHaveLength(2);
expect(failures).toHaveLength(1);
// IDs follow pattern: {accountId}-{status}-{index}
expect(successes[0].id).toMatch(/^acc1-success-\d+$/);
expect(failures[0].id).toMatch(/^acc1-failed-\d+$/);
});
it('all generated timestamps are not in the future', () => {
const now = new Date();
const accounts: AccountData[] = [makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 })];
const events = generateConnectionEvents(accounts);
events.forEach((e) => {
// Allow 1s tolerance for test execution time
expect(e.timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 1000);
});
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import {
formatAccountDisplayName,
formatAccountVariantLabel,
getAccountIdentityPresentation,
} from '@/lib/account-identity';
describe('account identity presentation', () => {
it('formats duplicate-email team accounts as business workspace labels', () => {
const presentation = getAccountIdentityPresentation(
'kaidu.kd@gmail.com#04a0f049-team',
'kaidu.kd@gmail.com'
);
expect(presentation.audience).toBe('business');
expect(presentation.audienceLabel).toBe('Business');
expect(presentation.detailLabel).toBe('Workspace 04a0f049');
expect(presentation.compactDetailLabel).toBe('04a0f049');
expect(presentation.inlineLabel).toBe('Business · Workspace 04a0f049');
});
it('can derive business workspace labels from token file when account id is plain email', () => {
const presentation = getAccountIdentityPresentation(
'kaidu.kd@gmail.com',
'kaidu.kd@gmail.com',
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
);
expect(presentation.audience).toBe('business');
expect(presentation.audienceLabel).toBe('Business');
expect(presentation.detailLabel).toBe('Workspace 04a0f049');
expect(
formatAccountVariantLabel(
'kaidu.kd@gmail.com',
'kaidu.kd@gmail.com',
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
)
).toBe('Business · Workspace 04a0f049');
});
it('formats personal codex plans deliberately instead of leaking raw free suffixes', () => {
expect(
formatAccountDisplayName(
'kaidu.kd@gmail.com',
'kaidu.kd@gmail.com',
'codex-kaidu.kd@gmail.com-free.json'
)
).toBe('kaidu.kd@gmail.com (Personal · Free)');
});
it('leaves plain accounts without inferred state untouched', () => {
const presentation = getAccountIdentityPresentation('user@example.com', 'user@example.com');
expect(presentation.audience).toBe('unknown');
expect(presentation.audienceLabel).toBeNull();
expect(presentation.detailLabel).toBeNull();
expect(formatAccountDisplayName('user@example.com', 'user@example.com')).toBe(
'user@example.com'
);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import type { OAuthAccount } from '@/lib/api-client';
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
function makeAccount(overrides: Partial<OAuthAccount> & Pick<OAuthAccount, 'id' | 'tokenFile'>) {
return {
id: overrides.id,
email: 'kaidu.kd@gmail.com',
provider: 'codex',
isDefault: false,
tokenFile: overrides.tokenFile,
createdAt: '2026-03-30T00:00:00.000Z',
...overrides,
} satisfies OAuthAccount;
}
describe('buildAccountVisualGroups', () => {
it('orders grouped codex variants by audience consistently', () => {
const groups = buildAccountVisualGroups([
makeAccount({
id: 'kaidu.kd@gmail.com#free',
tokenFile: 'codex-kaidu.kd@gmail.com-free.json',
}),
makeAccount({
id: 'kaidu.kd@gmail.com#04a0f049-team',
tokenFile: 'codex-04a0f049-kaidu.kd@gmail.com-team.json',
}),
]);
expect(groups).toHaveLength(1);
expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual([
'business',
'personal',
]);
expect(groups[0]?.memberIds).toEqual([
'kaidu.kd@gmail.com#04a0f049-team',
'kaidu.kd@gmail.com#free',
]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from '@/lib/api-client';
function createEmptyJsonResponse(status = 200): Response {
return new Response('{}', {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('cliproxy account API client', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('encodes duplicate-email account ids for account auth actions', async () => {
const fetchMock = vi.fn(() => Promise.resolve(createEmptyJsonResponse()));
vi.stubGlobal('fetch', fetchMock);
const accountId = 'kaidu.kd@gmail.com#04a0f049-team';
const encodedAccountId = encodeURIComponent(accountId);
await api.cliproxy.accounts.remove('codex', accountId);
await api.cliproxy.accounts.pause('codex', accountId);
await api.cliproxy.accounts.resume('codex', accountId);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}`
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}/pause`
);
expect(fetchMock.mock.calls[2]?.[0]).toBe(
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}/resume`
);
});
});