From d76ef5bcb30bd87ea487894586978473fcc8fead Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 09:12:54 -0400 Subject: [PATCH 01/33] fix(dashboard): use shared base time for connection timeline events Connection Timeline sidebar showed only one account because generateConnectionEvents() used per-account lastUsedAt as the base timestamp. Accounts with more recent lastUsedAt dominated the recency-sorted 100-event cap. Use the maximum lastUsedAt across all accounts so events interleave proportionally. Closes #856 --- ui/src/components/account/flow-viz/utils.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/src/components/account/flow-viz/utils.ts b/ui/src/components/account/flow-viz/utils.ts index ce101437..e6249006 100644 --- a/ui/src/components/account/flow-viz/utils.ts +++ b/ui/src/components/account/flow-viz/utils.ts @@ -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') => { From 4ab51945a412321e3e56d0c6860d0121761dcbfa Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 09:21:39 -0400 Subject: [PATCH 02/33] test(dashboard): add regression tests for connection timeline event generation Covers the shared-base-time fix to prevent one account from dominating the timeline. 9 test cases: multi-account interleaving, sort order, empty inputs, missing lastUsedAt, future-timestamp guard. --- .../components/account/flow-viz/utils.test.ts | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 ui/tests/unit/ui/components/account/flow-viz/utils.test.ts diff --git a/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts b/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts new file mode 100644 index 00000000..4a7c1eac --- /dev/null +++ b/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts @@ -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 & Pick): 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); + }); + }); +}); From 881b061dfec497d7120b15d2045cf8e1a8f04bcc Mon Sep 17 00:00:00 2001 From: User Date: Mon, 30 Mar 2026 06:25:14 -0700 Subject: [PATCH 03/33] fix(docker): proxy CLIProxy management panel through dashboard to avoid cross-origin errors In Docker, the browser cannot directly reach the container-internal CLIProxy port (8317). This adds a reverse proxy at /cliproxy-local/* that forwards requests through the dashboard Express server to 127.0.0.1:8317 internally. The control panel embed now uses same-origin API endpoints for health checks and the proxy path for the management iframe. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web-server/index.ts | 4 ++ src/web-server/routes/cliproxy-local-proxy.ts | 49 ++++++++++++++ .../cliproxy/control-panel-embed.tsx | 67 +++++++++++++------ 3 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 src/web-server/routes/cliproxy-local-proxy.ts diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 6ec39e00..6015c004 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -79,6 +79,10 @@ export async function startServer(options: ServerOptions): Promise { + // Strip the mount prefix — req.url already has it removed by Express + const targetPath = req.url || '/'; + + const options: http.RequestOptions = { + hostname: '127.0.0.1', + port: CLIPROXY_DEFAULT_PORT, + path: targetPath, + method: req.method, + headers: { + ...req.headers, + host: `127.0.0.1:${CLIPROXY_DEFAULT_PORT}`, + }, + }; + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + }); + + proxyReq.on('error', () => { + if (!res.headersSent) { + res.status(502).json({ error: 'CLIProxy is not reachable' }); + } + }); + + req.pipe(proxyReq, { end: true }); +}); + +export default router; diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index d256c1f0..8755ab44 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -78,11 +78,12 @@ 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: '/cliproxy-local/management.html', + checkUrl: '/cliproxy-local/', authToken: effectiveSecret, isRemote: false, displayHost: `localhost:${port}`, @@ -98,19 +99,36 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel 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) { + setIsConnected(true); + setError(null); + } else { + setIsConnected(false); + setError( + 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: use same-origin API to check proxy status (avoids CORS) + const status = await api.cliproxy.proxyStatus(); + if (status.running) { + setIsConnected(true); + setError(null); + } else { + setIsConnected(false); + setError('CLIProxy is not running'); + } } } catch (e) { // Ignore abort errors (component unmounting) @@ -131,7 +149,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // Cleanup: abort fetch on unmount return () => controller.abort(); - }, [checkUrl, isRemote, displayHost]); + }, [isRemote, displayHost, cliproxyConfig]); const postAutoLoginCredentials = useCallback(() => { // Auto-login can only run when iframe has loaded and authToken is available. @@ -140,12 +158,21 @@ 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: checkUrl is a relative path (/cliproxy-local/) → same origin + // Remote mode: checkUrl is an absolute URL (http://host:port/) + const isRelative = checkUrl.startsWith('/'); + const apiBase = isRelative + ? `${window.location.origin}/cliproxy-local` + : checkUrl.replace(/\/$/, ''); + const targetOrigin = isRelative ? window.location.origin : apiBase; // Security: Validate iframe src matches target origin before sending credentials const iframeSrc = iframeRef.current.src; - if (!iframeSrc.startsWith(apiBase)) { + const resolvedSrc = isRelative + ? new URL(iframeSrc, window.location.origin).href + : iframeSrc; + if (!resolvedSrc.startsWith(targetOrigin)) { console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); return; } @@ -157,7 +184,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel apiBase, managementKey: authToken, }, - apiBase + targetOrigin ); } catch (e) { // Cross-origin restriction - expected if not same origin From 69cbb7792492bb07f19b6ecbb045d238300b8165 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 13:33:37 +0000 Subject: [PATCH 04/33] chore(release): 7.63.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7f79b8e7..3d03ace1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.63.0", + "version": "7.63.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 4950be7fc025877dd6744b9c8c87b4714ec49de2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 14:22:59 -0400 Subject: [PATCH 05/33] fix(ci): harden ai review output - replace raw assistant-text fallback with structured output normalization - simplify the review prompt toward a tighter findings-only contract - add tests for malformed output, safe fallback, and markdown escaping --- .github/review-prompt.md | 157 +++----------- .github/workflows/ai-review.yml | 62 ++---- scripts/github/normalize-ai-review-output.mjs | 204 ++++++++++++++++++ .../github/normalize-ai-review-output.test.ts | 117 ++++++++++ 4 files changed, 375 insertions(+), 165 deletions(-) create mode 100644 scripts/github/normalize-ai-review-output.mjs create mode 100644 tests/unit/scripts/github/normalize-ai-review-output.test.ts diff --git a/.github/review-prompt.md b/.github/review-prompt.md index fd47b4a0..bb7a8732 100644 --- a/.github/review-prompt.md +++ b/.github/review-prompt.md @@ -1,136 +1,45 @@ -# Adversarial Code Review Prompt +# PR Review Prompt -You are a red-team code reviewer. Your job is to find every way this code can fail, be exploited, or produce incorrect results. Assume the implementer made mistakes. Prove it. +You are a pull request reviewer. Focus on correctness, security, regressions, and missing verification. -DO NOT start with strengths or praise. Start with problems. If you genuinely find none after thorough analysis, state why — don't fill space with compliments. +Follow the repository `CLAUDE.md` instructions before judging the change. -Follow the repository's CLAUDE.md for project-specific guidelines and constraints. +Review discipline: -## Review Mindset +- Read the full diff first. +- Read surrounding code before turning an observation into a finding. +- Prefer a short list of real findings over a long list of speculative ones. +- If a concern is uncertain after checking the nearby code, omit it. +- Do not pad the review with praise or generic best-practice commentary. -Phase 1 — **Understand**: Read the full diff. Understand what the PR does, what it changes, and what it touches. +Core questions: -Phase 2 — **Attack**: For every changed function, module, or code path, ask: -- How can this be null/undefined when the code assumes it isn't? -- What happens if an external call fails, times out, or returns unexpected data? -- Can user input reach this path unsanitized? -- Is there a race condition or ordering assumption? -- Does this break existing callers or backward compatibility? -- Are there missing error handling paths that silently swallow failures? +- Can this change break an existing caller, workflow, or default behavior? +- Can null, empty, or unexpected external data reach a path that assumes success? +- Does untrusted input reach a risky boundary such as shell, file paths, HTTP requests, or HTML? +- Is there an ordering, race, or stale-state assumption that can fail under real usage? +- Are tests, docs, or `--help` updates missing for newly introduced behavior? -Phase 3 — **Verify**: Cross-check findings against the actual codebase (not just the diff). Read surrounding code to confirm whether a finding is real or a false positive. +CCS-specific checks: -## Scope-Aware Review Depth +- CLI output in `src/` must stay ASCII-only: `[OK]`, `[!]`, `[X]`, `[i]` +- CCS path access must use `getCcsDir()`, not `os.homedir()` plus `.ccs` +- CLI behavior changes require matching `--help` and docs updates +- Terminal color output must respect TTY detection and `NO_COLOR` +- Code must not modify `~/.claude/settings.json` without explicit user action -Calibrate review depth based on PR scope. DO NOT give a trivial typo fix the same depth as an auth rewrite. +Severity guide: -**Quick review** (changed files <= 2 AND lines <= 30 AND no security-sensitive files): -- Focus on correctness only. Skip architecture/performance analysis. -- Still check the critical checklists below. +- `high`: security issue, data loss, broken release/install flow, or behavior that is likely wrong in normal use +- `medium`: meaningful edge case, missing guard, missing test/docs/help update, or maintainability issue that can cause user-facing bugs +- `low`: smaller follow-up worth tracking, but not a release blocker -**Standard review** (most PRs): -- Full adversarial analysis across all checklist areas. +Output expectations: -**Deep review** (ANY of these conditions): -- Files in: auth/, middleware/, security/, crypto/, commands/, shared/, .github/ -- New dependencies added (package.json/lockfile changed) -- CI/CD workflow files changed -- Environment variables added/changed -- API routes added/changed -- Database schema modified -- External contributor PR - -## Security Checklist (MUST Flag If Found) - -- **Injection & Command Safety** — string interpolation in shell commands via child_process (use argument arrays), user input in file paths (path traversal), template literal injection in SQL/DB, unsanitized HTML -- **Authentication & Authorization** — missing auth checks on new endpoints, privilege escalation (IDOR), secrets in logs/errors/client code, JWT comparison using == instead of constant-time -- **Race Conditions** — read-check-write without atomic ops, shared mutable state without sync, TOCTOU in file ops, async operations with implicit ordering -- **Supply Chain** (when deps change) — postinstall scripts, maintainer reputation, lockfile drift, transitive vulns - -## Quality Checklist (MUST Flag If Found) - -- **Error Handling** — swallowed errors (catch {} with no log), missing error handling on spawn/exec, unbounded ops from user input, missing cleanup on error paths, process.exit() without cleanup -- **False Assumptions (ACTIVELY HUNT)** — "never null" (prove it can be), "array always has elements" (find empty case), "A before B" (find out-of-order path), "config exists" (find missing env var), "API returns 200" (find failure mode) -- **AI-Generated Code** — hallucinated imports, deprecated APIs, over-abstraction, plausible but wrong logic (off-by-one, inverted conditions) -- **Performance** — O(n*m) in loops (use Map/Set), missing pagination on unbounded list endpoints, N+1 patterns - -## CCS-Specific Rules (MUST Enforce — Violations Are Automatic Findings) - -1. **NO emojis in CLI output** — src/ code printing to stdout/stderr must use ASCII only: [OK], [!], [X], [i] -2. **Test isolation** — code accessing CCS paths MUST use getCcsDir(), NOT os.homedir() + '.ccs' -3. **Cross-platform parity** — bash/PowerShell/Node.js must behave identically -4. **--help updated** — if CLI command behavior changed, respective help handler must be updated -5. **Synchronous fs APIs** — avoid in async paths (tracked by maintainability baseline) -6. **Settings format** — all env values MUST be strings (not booleans/objects) -7. **Conventional commit** — PR title must follow conventional commit format -8. **Non-invasive** — code must NOT modify ~/.claude/settings.json without explicit user confirmation -9. **TTY-aware colors** — respect NO_COLOR env var; detect TTY before using colors -10. **Idempotent installs** — all install/setup ops must be safe to run multiple times -11. **Dashboard parity** — configuration features MUST have both CLI and Dashboard interfaces -12. **Documentation mandatory** — CLI/config changes require --help update AND docs update - -## Suppressions — DO NOT Flag These - -- Style/formatting issues (linter handles this) -- "Consider using X instead of Y" when Y works correctly AND has no security/correctness/CCS implications -- Redundancy that aids readability -- Issues already addressed in the diff being reviewed (read the FULL diff first) -- "Add a comment" suggestions — code should be self-documenting -- Harmless no-ops that don't affect correctness -- Consistency-only suggestions with no functional impact - -## Output Structure - -### 📋 Summary -2-3 sentences describing what the PR does and overall assessment. - -### 🔍 Findings -Group by severity. Each finding must include `file:line` reference and concrete explanation. - -**🔴 High** (must fix before merge): -- Security vulnerabilities, data corruption risks, breaking changes without migration - -**🟡 Medium** (should fix before merge): -- Missing error handling, edge cases, test gaps for new behavior - -**🟢 Low** (track for follow-up): -- Minor improvements, non-blocking suggestions with clear rationale - -For each finding: -1. **What**: The specific problem -2. **Why**: How it can be triggered or why it matters -3. **Fix**: Concrete fix approach (describe, don't write implementation code) - -### 🔒 Security Checklist -| Check | Status | Notes | -|-------|--------|-------| -| Injection safety | ✅/❌ | ... | -| Auth checks | ✅/❌/N/A | ... | -| Race conditions | ✅/❌/N/A | ... | -| Secrets exposure | ✅/❌ | ... | -| Supply chain | ✅/❌/N/A | ... | - -### 📊 CCS Compliance -| Rule | Status | Notes | -|------|--------|-------| -| No emojis in CLI | ✅/❌/N/A | ... | -| Test isolation | ✅/❌/N/A | ... | -| Cross-platform | ✅/❌/N/A | ... | -| --help updated | ✅/❌/N/A | ... | -| Settings strings | ✅/❌/N/A | ... | -| Conventional commit | ✅/❌ | ... | -| Docs mandatory | ✅/❌/N/A | ... | - -### 💡 Informational -Non-blocking observations. - -### ✅ What's Done Well -2-3 items max, only if genuinely noteworthy. OPTIONAL — skip if nothing stands out. - -### 🎯 Overall Assessment - -**✅ APPROVED** — zero High, zero security Medium, all CCS rules respected, tests exist for new behavior. -**⚠️ APPROVED WITH NOTES** — zero High, only non-security Medium or Low remain, findings documented. -**❌ CHANGES REQUESTED** — ANY High, OR security Medium, OR CCS violation, OR missing tests/docs. - -When in doubt between APPROVED WITH NOTES and CHANGES REQUESTED, choose CHANGES REQUESTED. +- Return confirmed findings only. +- Every finding must cite a file path and, when practical, a line number. +- Keep the total finding count small unless the PR genuinely has several distinct problems. +- If there are no confirmed findings, say so in the summary and return an empty findings array. +- Use `approved` only when the diff is ready to merge as-is. +- Use `approved_with_notes` when only non-blocking follow-ups remain. +- Use `changes_requested` when any blocking issue remains. diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index cdd917ba..65980150 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -151,6 +151,8 @@ jobs: MAX_THINKING_TOKENS: '16000' REVIEW_OUTPUT_FILE: pr_review.md REVIEW_COMMENT_FILE: .ccs-ai-review-comment.md + REVIEW_OUTPUT_SCHEMA: >- + {"type":"object","additionalProperties":false,"properties":{"summary":{"type":"string","minLength":1},"findings":{"type":"array","maxItems":6,"items":{"type":"object","additionalProperties":false,"properties":{"severity":{"type":"string","enum":["high","medium","low"]},"title":{"type":"string","minLength":1},"file":{"type":"string","minLength":1},"line":{"type":["integer","null"],"minimum":1},"what":{"type":"string","minLength":1},"why":{"type":"string","minLength":1},"fix":{"type":"string","minLength":1}},"required":["severity","title","file","what","why","fix"]}},"overallAssessment":{"type":"string","enum":["approved","approved_with_notes","changes_requested"]},"overallRationale":{"type":"string","minLength":1},"notes":{"type":"array","maxItems":4,"items":{"type":"string","minLength":1}}},"required":["summary","findings","overallAssessment","overallRationale"]} steps: - name: Prepare isolated Claude runtime @@ -224,7 +226,8 @@ jobs: anthropic_api_key: ${{ secrets.GLM_API_KEY }} github_token: ${{ steps.app-token.outputs.token }} allowed_non_write_users: ${{ needs.prepare.outputs.contributor_source == 'external' && '*' || '' }} - show_full_output: true # Visible logs for debugging slow/failing reviews + display_report: false # Keep all public review output on the normalized comment path + show_full_output: false # Keep scratch output out of public logs track_progress: false # Disabled - no progress comments, just final review prompt: | think @@ -242,55 +245,32 @@ jobs: ${{ steps.review-prompt.outputs.content }} - ## IMPORTANT: Writing the Review - After completing your analysis, use the `Write` tool to write the final review markdown to `${{ env.REVIEW_OUTPUT_FILE }}`. - Do NOT use `Edit` tool — use `Write` tool directly to create the file in one shot. - Do NOT post any GitHub comments yourself. The workflow will publish the saved file. - Do NOT modify any source code files — this is a READ-ONLY review. - - End your review with: - > 🤖 Reviewed by `${{ env.REVIEW_MODEL }}` - - IMPORTANT RULES: - - Use `Write` tool to overwrite `${{ env.REVIEW_OUTPUT_FILE }}` with the complete review - - Do NOT use shell operators like || or && in bash commands - - Do NOT use heredoc (<<) syntax in bash commands - - Use simple, single-purpose bash commands only + ## Runtime Rules + - This is a READ-ONLY review. Do not edit files. + - Use the checked-out PR branch plus surrounding repository context before reporting a finding. + - Return only structured output that matches the provided JSON schema. + - Do NOT write files. + - Do NOT post GitHub comments yourself. + - If no confirmed issues remain, return an empty findings array instead of inventing low-value feedback. claude_args: | --bare --model ${{ env.REVIEW_MODEL }} --permission-mode bypassPermissions --max-turns 40 - --allowedTools "Glob,Grep,Read,Write,Bash(gh pr diff *),Bash(gh pr view *),Bash(git diff *),Bash(git log *),Bash(git show *),Bash(cat *),Bash(ls *),Bash(wc *),Bash(head *),Bash(tail *),Bash(find *)" + --allowedTools "Read,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(cat:*),Bash(ls:*),Bash(wc:*),Bash(head:*),Bash(tail:*),Bash(find:*),Bash(grep:*)" + --json-schema '${{ env.REVIEW_OUTPUT_SCHEMA }}' - # Fallback: if Claude didn't write the review file, extract from execution output - - name: Extract review from execution output (fallback) + - name: Render review comment if: always() && steps.claude-review.outcome != 'cancelled' run: | - EXEC_LOG="$RUNNER_TEMP/claude-execution-output.json" - if [ -s "$REVIEW_OUTPUT_FILE" ]; then - echo "[i] Review file exists, skipping fallback extraction" - exit 0 - fi - if [ ! -f "$EXEC_LOG" ]; then - echo "::warning::No execution output found at $EXEC_LOG" - exit 0 - fi - # Extract last assistant text message as fallback review - EXTRACTED=$(jq -r ' - [.[] | select(.type == "assistant") | .message.content[]? - | select(.type == "text") | .text] | last // empty - ' "$EXEC_LOG" 2>/dev/null || true) - if [ -z "$EXTRACTED" ]; then - echo "::warning::Could not extract review content from execution output" - printf '## AI Review (incomplete)\n\nClaude completed but did not produce a structured review.\nCheck the [execution log artifact](%s) for details.\n\n> Reviewed by `%s` (fallback extraction)\n' \ - "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ - "$REVIEW_MODEL" > "$REVIEW_OUTPUT_FILE" - else - printf '%s\n' "$EXTRACTED" > "$REVIEW_OUTPUT_FILE" - fi - echo "[i] Fallback review extracted from execution output" + node scripts/github/normalize-ai-review-output.mjs + env: + AI_REVIEW_EXECUTION_FILE: ${{ runner.temp }}/claude-execution-output.json + AI_REVIEW_MODEL: ${{ env.REVIEW_MODEL }} + AI_REVIEW_OUTPUT_FILE: ${{ env.REVIEW_OUTPUT_FILE }} + AI_REVIEW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + AI_REVIEW_STRUCTURED_OUTPUT: ${{ steps.claude-review.outputs.structured_output }} - name: Publish review comment if: always() && steps.claude-review.outcome != 'cancelled' diff --git a/scripts/github/normalize-ai-review-output.mjs b/scripts/github/normalize-ai-review-output.mjs new file mode 100644 index 00000000..a01211a4 --- /dev/null +++ b/scripts/github/normalize-ai-review-output.mjs @@ -0,0 +1,204 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ASSESSMENTS = { + approved: '✅ APPROVED', + approved_with_notes: '⚠️ APPROVED WITH NOTES', + changes_requested: '❌ CHANGES REQUESTED', +}; + +const SEVERITY_ORDER = ['high', 'medium', 'low']; +const SEVERITY_HEADERS = { + high: '### 🔴 High', + medium: '### 🟡 Medium', + low: '### 🟢 Low', +}; + +function cleanText(value) { + return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : ''; +} + +function escapeMarkdownText(value) { + return cleanText(value).replace(/\\/g, '\\\\').replace(/([`*_{}\[\]<>])/g, '\\$1'); +} + +function renderCode(value) { + const text = cleanText(value); + const longestFence = Math.max(...[...text.matchAll(/`+/g)].map((match) => match[0].length), 0); + const fence = '`'.repeat(longestFence + 1); + return `${fence}${text}${fence}`; +} + +function readExecutionMetadata(executionFile) { + if (!executionFile || !fs.existsSync(executionFile)) { + return {}; + } + + try { + const turns = JSON.parse(fs.readFileSync(executionFile, 'utf8')); + const init = turns.find((turn) => turn?.type === 'system' && turn?.subtype === 'init'); + const result = [...turns].reverse().find((turn) => turn?.type === 'result'); + return { + runtimeTools: Array.isArray(init?.tools) ? init.tools : [], + turnsUsed: typeof result?.num_turns === 'number' ? result.num_turns : null, + }; + } catch { + return {}; + } +} + +export function normalizeStructuredOutput(raw) { + if (!raw) { + return { ok: false, reason: 'missing structured output' }; + } + + let parsed; + try { + parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch { + return { ok: false, reason: 'structured output is not valid JSON' }; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, reason: 'structured output must be an object' }; + } + + const summary = cleanText(parsed.summary); + const overallAssessment = cleanText(parsed.overallAssessment); + const overallRationale = cleanText(parsed.overallRationale); + const notes = Array.isArray(parsed.notes) ? parsed.notes.map(cleanText).filter(Boolean) : []; + const findings = Array.isArray(parsed.findings) ? parsed.findings : null; + + if (!summary || !ASSESSMENTS[overallAssessment] || !overallRationale || findings === null) { + return { ok: false, reason: 'structured output is missing required review fields' }; + } + + const normalizedFindings = []; + for (const finding of findings) { + const severity = cleanText(finding?.severity); + const title = cleanText(finding?.title); + const file = cleanText(finding?.file); + const what = cleanText(finding?.what); + const why = cleanText(finding?.why); + const fix = cleanText(finding?.fix); + const line = + typeof finding?.line === 'number' && Number.isInteger(finding.line) && finding.line > 0 + ? finding.line + : null; + + if (!SEVERITY_HEADERS[severity] || !title || !file || !what || !why || !fix) { + return { ok: false, reason: 'structured output contains an invalid finding' }; + } + + normalizedFindings.push({ severity, title, file, line, what, why, fix }); + } + + return { + ok: true, + value: { + summary, + findings: normalizedFindings, + overallAssessment, + overallRationale, + notes, + }, + }; +} + +export function renderStructuredReview(review, { model }) { + const lines = ['## Summary', escapeMarkdownText(review.summary), '', '## Findings']; + + if (review.findings.length === 0) { + lines.push('No confirmed issues found after reviewing the diff and surrounding code.'); + } else { + for (const severity of SEVERITY_ORDER) { + const findings = review.findings.filter((finding) => finding.severity === severity); + if (findings.length === 0) continue; + + lines.push(SEVERITY_HEADERS[severity]); + for (const finding of findings) { + const location = finding.line ? `${finding.file}:${finding.line}` : finding.file; + lines.push(`- **${renderCode(location)} — ${escapeMarkdownText(finding.title)}**`); + lines.push(` Problem: ${escapeMarkdownText(finding.what)}`); + lines.push(` Why it matters: ${escapeMarkdownText(finding.why)}`); + lines.push(` Suggested fix: ${escapeMarkdownText(finding.fix)}`); + } + lines.push(''); + } + if (lines[lines.length - 1] === '') { + lines.pop(); + } + } + + if (review.notes.length > 0) { + lines.push('', '## Notes'); + for (const note of review.notes) { + lines.push(`- ${escapeMarkdownText(note)}`); + } + } + + lines.push( + '', + '## Overall Assessment', + `**${ASSESSMENTS[review.overallAssessment]}** — ${escapeMarkdownText(review.overallRationale)}`, + '', + `> Reviewed by \`${model}\`` + ); + + return lines.join('\n'); +} + +export function renderIncompleteReview({ model, reason, runUrl, runtimeTools, turnsUsed }) { + const lines = [ + '## AI Review Incomplete', + '', + 'Claude did not return validated structured review output, so this workflow did not publish raw scratch text.', + '', + `- Reason: ${escapeMarkdownText(reason)}`, + ]; + + if (runtimeTools?.length) { + lines.push(`- Runtime tools: ${runtimeTools.map(renderCode).join(', ')}`); + } + if (typeof turnsUsed === 'number') { + lines.push(`- Turns used: ${turnsUsed}`); + } + + lines.push('', `Re-run \`/review\` or inspect [the workflow run](${runUrl}).`, '', `> Reviewed by \`${model}\``); + return lines.join('\n'); +} + +export function writeReviewFromEnv(env = process.env) { + const outputFile = env.AI_REVIEW_OUTPUT_FILE || 'pr_review.md'; + const model = env.AI_REVIEW_MODEL || 'unknown-model'; + const runUrl = env.AI_REVIEW_RUN_URL || '#'; + const validation = normalizeStructuredOutput(env.AI_REVIEW_STRUCTURED_OUTPUT); + const metadata = readExecutionMetadata(env.AI_REVIEW_EXECUTION_FILE); + const content = validation.ok + ? renderStructuredReview(validation.value, { model }) + : renderIncompleteReview({ + model, + reason: validation.reason, + runUrl, + runtimeTools: metadata.runtimeTools, + turnsUsed: metadata.turnsUsed, + }); + + fs.mkdirSync(path.dirname(outputFile), { recursive: true }); + fs.writeFileSync(outputFile, `${content}\n`, 'utf8'); + + if (!validation.ok) { + console.warn(`::warning::AI review output normalization fell back to incomplete comment: ${validation.reason}`); + } + + return { usedFallback: !validation.ok, content }; +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + writeReviewFromEnv(); +} diff --git a/tests/unit/scripts/github/normalize-ai-review-output.test.ts b/tests/unit/scripts/github/normalize-ai-review-output.test.ts new file mode 100644 index 00000000..b81f3a22 --- /dev/null +++ b/tests/unit/scripts/github/normalize-ai-review-output.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const reviewOutput = await import('../../../../scripts/github/normalize-ai-review-output.mjs'); + +describe('normalize-ai-review-output', () => { + test('renders validated structured output into stable markdown', () => { + const validation = reviewOutput.normalizeStructuredOutput( + JSON.stringify({ + summary: 'The PR is mostly correct, but one blocking regression remains.', + findings: [ + { + severity: 'high', + title: 'Ambiguous account lookup drops valid matches', + file: 'src/cliproxy/accounts/query.ts', + line: 61, + what: 'Exact email matches can return null when duplicate accounts exist.', + why: 'That breaks normal selection flows for users with multiple Codex sessions.', + fix: 'Match by stable account identity first and keep ambiguous email lookups out of exact-match paths.', + }, + ], + overallAssessment: 'changes_requested', + overallRationale: 'The blocking lookup regression should be fixed before merge.', + notes: ['Docs update is present and looks aligned with the code changes.'], + }) + ); + + expect(validation.ok).toBe(true); + const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5.1' }); + + expect(markdown).toContain('## Summary'); + expect(markdown).toContain('### 🔴 High'); + expect(markdown).toContain('`src/cliproxy/accounts/query.ts:61`'); + expect(markdown).toContain('**❌ CHANGES REQUESTED**'); + expect(markdown).toContain('Why it matters: That breaks normal selection flows for users with multiple Codex sessions.'); + expect(markdown).toContain('> Reviewed by `glm-5.1`'); + }); + + test('writes a safe incomplete comment instead of leaking raw assistant text', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-review-')); + const executionFile = path.join(tempDir, 'claude-execution-output.json'); + const outputFile = path.join(tempDir, 'pr_review.md'); + + fs.writeFileSync( + executionFile, + JSON.stringify([ + { type: 'system', subtype: 'init', tools: ['Bash', 'Edit', 'Read'] }, + { + type: 'result', + subtype: 'success', + num_turns: 25, + result: 'Now let me verify the findings before I finalize the review...', + }, + ]) + ); + + const result = reviewOutput.writeReviewFromEnv({ + AI_REVIEW_EXECUTION_FILE: executionFile, + AI_REVIEW_MODEL: 'glm-5.1', + AI_REVIEW_OUTPUT_FILE: outputFile, + AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592', + AI_REVIEW_STRUCTURED_OUTPUT: '', + }); + + expect(result.usedFallback).toBe(true); + + const markdown = fs.readFileSync(outputFile, 'utf8'); + expect(markdown).toContain('## AI Review Incomplete'); + expect(markdown).toContain('Runtime tools: `Bash`, `Edit`, `Read`'); + expect(markdown).toContain('Turns used: 25'); + expect(markdown).not.toContain('Now let me verify the findings'); + }); + + test('escapes markdown-looking content and ignores malformed execution metadata', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-review-')); + const executionFile = path.join(tempDir, 'claude-execution-output.json'); + const outputFile = path.join(tempDir, 'pr_review.md'); + + fs.writeFileSync(executionFile, '{not valid json'); + + const result = reviewOutput.writeReviewFromEnv({ + AI_REVIEW_EXECUTION_FILE: executionFile, + AI_REVIEW_MODEL: 'glm-5.1', + AI_REVIEW_OUTPUT_FILE: outputFile, + AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/1', + AI_REVIEW_STRUCTURED_OUTPUT: JSON.stringify({ + summary: 'Summary with `code` and ## heading markers.', + findings: [ + { + severity: 'low', + title: 'Title with `ticks`', + file: 'src/example.ts', + line: 9, + what: 'Problem text uses **bold** markers.', + why: 'Why text uses [link] syntax.', + fix: 'Fix text uses markers.', + }, + ], + overallAssessment: 'approved_with_notes', + overallRationale: 'Rationale keeps `_formatting_` stable.', + notes: ['Note with `inline code`.'], + }), + }); + + expect(result.usedFallback).toBe(false); + + const markdown = fs.readFileSync(outputFile, 'utf8'); + expect(markdown).toContain('Summary with \\`code\\` and ## heading markers.'); + expect(markdown).toContain('**`src/example.ts:9` — Title with \\`ticks\\`**'); + expect(markdown).toContain('Problem: Problem text uses \\*\\*bold\\*\\* markers.'); + expect(markdown).toContain('Why it matters: Why text uses \\[link\\] syntax.'); + expect(markdown).toContain('Suggested fix: Fix text uses \\ markers.'); + expect(markdown).toContain('**⚠️ APPROVED WITH NOTES**'); + }); +}); From 39ba295d5f1244c9b3fb2d6f931ee5c40a3937ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 18:34:34 +0000 Subject: [PATCH 06/33] chore(release): 7.63.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3d03ace1..20ebe75e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.63.0-dev.1", + "version": "7.63.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From a5d71e35606ebaf16f97d860405800e9a9454659 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 15:10:30 -0400 Subject: [PATCH 07/33] fix: normalize websearch hook output for local providers --- lib/hooks/websearch-transformer.cjs | 74 +++++---- .../unit/hooks/websearch-transformer.test.ts | 148 +++++++++++++++++- 2 files changed, 189 insertions(+), 33 deletions(-) diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 0e09d48f..95311502 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -183,23 +183,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); @@ -534,34 +569,11 @@ 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}`, - }, - }; - - console.log(JSON.stringify(output)); - process.exit(2); + emitHookOutput(buildFailureHookOutput(query, errors)); } async function processHook(input) { @@ -675,6 +687,8 @@ if (require.main === module) { } module.exports = { + buildFailureHookOutput, + buildSuccessHookOutput, extractDuckDuckGoResults, formatStructuredSearchResults, tryExaSearch, diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index ee91863f..e731e197 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -1,6 +1,29 @@ import { describe, expect, it } from 'bun:test'; +import { mkdtempSync, 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; @@ -13,6 +36,45 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { ) => string; }; +function runHookWithMockedFetch(mode: 'success' | 'failure') { + const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-')); + const preloadPath = join(tempDir, 'mock-fetch.cjs'); + const html = ` + Example title + Example snippet + `.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('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => { const html = ` @@ -46,9 +108,89 @@ 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'); }); }); From 43ea5554e660dd77afc54e12d7d792037077eea0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 19:25:43 +0000 Subject: [PATCH 08/33] chore(release): 7.63.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 20ebe75e..eb336fab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.63.0-dev.2", + "version": "7.63.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From c73f33872abc165aaaba8a240c06cc4ccbb6f5b4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 11:45:43 -0400 Subject: [PATCH 09/33] feat(cliproxy): support duplicate-email codex accounts - keep Codex team and personal auth files as separate identities - resolve quota and live monitor stats by token file-backed account id - surface duplicate-aware account labels across the dashboard and variant UI --- .../accounts/email-account-identity.ts | 149 ++++++++++ src/cliproxy/accounts/query.ts | 24 +- src/cliproxy/accounts/registry.ts | 254 ++++++++++++++---- src/cliproxy/auth/token-manager.ts | 66 ++++- src/cliproxy/executor/index.ts | 12 +- src/cliproxy/quota-fetcher-codex.ts | 58 ++-- src/cliproxy/stats-transformer.ts | 40 ++- src/commands/cliproxy/quota-subcommand.ts | 48 ++-- src/commands/cliproxy/variant-subcommand.ts | 26 +- src/web-server/routes/account-routes.ts | 3 +- src/web-server/routes/cliproxy-auth-routes.ts | 3 +- ...account-registry-optional-nickname.test.ts | 37 +++ .../unit/cliproxy/quota-fetcher-codex.test.ts | 51 ++++ tests/unit/cliproxy/stats-transformer.test.ts | 41 +++ .../account/flow-viz/account-card.tsx | 7 + .../components/cliproxy/cliproxy-dialog.tsx | 3 +- .../cliproxy/provider-editor/account-item.tsx | 7 + .../auth-monitor/components/provider-card.tsx | 5 +- .../setup/wizard/steps/account-step.tsx | 3 +- .../setup/wizard/steps/variant-step.tsx | 3 +- ui/src/lib/account-identity.ts | 61 +++++ ui/src/lib/cliproxy-account-stats.ts | 15 +- .../components/account/flow-viz/utils.test.ts | 8 +- 23 files changed, 781 insertions(+), 143 deletions(-) create mode 100644 src/cliproxy/accounts/email-account-identity.ts create mode 100644 ui/src/lib/account-identity.ts diff --git a/src/cliproxy/accounts/email-account-identity.ts b/src/cliproxy/accounts/email-account-identity.ts new file mode 100644 index 00000000..a6a07a6d --- /dev/null +++ b/src/cliproxy/accounts/email-account-identity.ts @@ -0,0 +1,149 @@ +import type { CLIProxyProvider } from '../types'; + +const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set(['codex']); + +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; +} diff --git a/src/cliproxy/accounts/query.ts b/src/cliproxy/accounts/query.ts index 4cee1123..22748d4d 100644 --- a/src/cliproxy/accounts/query.ts +++ b/src/cliproxy/accounts/query.ts @@ -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( diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index c230c89e..7d755229 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -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 = { - 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 { + const counts = new Map(); + + 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 = { + 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; diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 788ebe73..1a4a1e16 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -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; const { registerAccount } = require('../account-manager'); let selectedCandidate: Omit | 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(); + const duplicateEmailTokenSets = new Map>(); + for (const account of existingAccounts) { + if (!account.email) continue; + const key = account.email.toLowerCase(); + const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set(); + 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(); + 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 diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index dfef56f5..1715d1bc 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -51,6 +51,7 @@ import { renameAccount, getDefaultAccount, } from '../account-manager'; +import { formatAccountDisplayName } from '../accounts/email-account-identity'; import { ensureMcpWebSearch, installWebSearchHook, @@ -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 " 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) diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 52ef60cc..6adf5e72 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -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,44 @@ 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); + 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 = extractCanonicalEmailFromAccountId(accountId) ?? accountId; + const sanitizedId = sanitizeEmail(legacyEmail); const expectedFile = `codex-${sanitizedId}.json`; for (const authDir of authDirs) { @@ -184,19 +220,9 @@ 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; } } @@ -208,7 +234,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 || '', diff --git a/src/cliproxy/stats-transformer.ts b/src/cliproxy/stats-transformer.ts index 74c10aa0..a525b1d8 100644 --- a/src/cliproxy/stats-transformer.ts +++ b/src/cliproxy/stats-transformer.ts @@ -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 { const lookup = new Map(); + const duplicateEmailCounts = new Map(); + + 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 { + 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; diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index ae0c8573..2ff268ed 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -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 { 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 { 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 { 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 { } 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 { 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 { } 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')); diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 99dcef7f..be9cae13 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -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 ) ); diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index f8c74e6d..cd98a259 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -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) { diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 471174c7..c893324a 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -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 { 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'); + }); }); diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index 23473109..dd4a4c04 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -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, @@ -409,6 +411,55 @@ 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('maps 403 responses to forbidden metadata', async () => { createValidCodexAccount('forbidden@example.com', 'workspace-forbidden'); diff --git a/tests/unit/cliproxy/stats-transformer.test.ts b/tests/unit/cliproxy/stats-transformer.test.ts index a58482be..dd32aeab 100644 --- a/tests/unit/cliproxy/stats-transformer.test.ts +++ b/tests/unit/cliproxy/stats-transformer.test.ts @@ -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, + }); + }); }); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 2d8bbfe3..af63b795 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -12,6 +12,7 @@ import { isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; +import { formatAccountVariantLabel } from '@/lib/account-identity'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, @@ -188,6 +189,7 @@ export function AccountCard({ account.tier && account.tier !== 'unknown' && account.tier !== 'free'; + const variantLabel = formatAccountVariantLabel(account.id, account.email); return (
{cleanEmail(account.email)} + {variantLabel && ( + + {variantLabel} + + )} {showTierBadge && ( {t('cliproxyDialog.useDefaultAccount')} {providerAccounts.map((acc) => ( ))} diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 85ae03c5..c749656b 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -40,6 +40,7 @@ import { isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; +import { formatAccountVariantLabel } from '@/lib/account-identity'; import { getAccountStats } from '@/lib/cliproxy-account-stats'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; @@ -187,6 +188,7 @@ export function AccountItem({ : failureInfo?.tone === 'destructive' ? 'border-destructive/50 text-destructive' : 'border-muted-foreground/50 text-muted-foreground'; + const variantLabel = formatAccountVariantLabel(account.id, account.email); return (
{account.email || account.id} + {variantLabel && ( + + {variantLabel} + + )} {account.isDefault && ( diff --git a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx index 59af0ac4..04ad2534 100644 --- a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx +++ b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx @@ -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,7 @@ export function ProviderCard({
{isMissingProjectId && ( diff --git a/ui/src/components/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx index 0d9da4c6..339b1ae7 100644 --- a/ui/src/components/setup/wizard/steps/account-step.tsx +++ b/ui/src/components/setup/wizard/steps/account-step.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react'; +import { formatAccountDisplayName } from '@/lib/account-identity'; import { cn } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import type { AccountStepProps } from '../types'; @@ -37,7 +38,7 @@ export function AccountStep({
- {acc.email || acc.id} + {formatAccountDisplayName(acc.id, acc.email)}
{acc.isDefault && (
Default account
diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 1e185081..0961fb37 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -6,6 +6,7 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { formatAccountDisplayName } from '@/lib/account-identity'; import { Select, SelectContent, @@ -63,7 +64,7 @@ export function VariantStep({ {t('setupVariant.using')}{' '} - {selectedAccount.email || selectedAccount.id} + {formatAccountDisplayName(selectedAccount.id, selectedAccount.email)}
diff --git a/ui/src/lib/account-identity.ts b/ui/src/lib/account-identity.ts new file mode 100644 index 00000000..d28e9a29 --- /dev/null +++ b/ui/src/lib/account-identity.ts @@ -0,0 +1,61 @@ +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(' '); + } +} + +export function extractAccountVariantKey(accountId: string, email?: string): string | null { + if (!email) { + return null; + } + + const prefix = `${email}#`; + return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null; +} + +export function formatAccountVariantLabel(accountId: string, email?: string): string | null { + const variantKey = extractAccountVariantKey(accountId, email); + if (!variantKey) { + return null; + } + + const parts = variantKey.split('-').filter(Boolean); + if (parts.length === 0) { + return null; + } + + const genericSuffix = parts[parts.length - 1]; + if (['team', 'free', 'plus', 'pro'].includes(genericSuffix)) { + return [formatVariantPart(genericSuffix), ...parts.slice(0, -1).map(formatVariantPart)] + .filter(Boolean) + .join(' · '); + } + + return parts.map(formatVariantPart).filter(Boolean).join(' · '); +} + +export function formatAccountDisplayName(accountId: string, email?: string): string { + const base = email || accountId; + const variantLabel = formatAccountVariantLabel(accountId, email); + return variantLabel ? `${base} (${variantLabel})` : base; +} diff --git a/ui/src/lib/cliproxy-account-stats.ts b/ui/src/lib/cliproxy-account-stats.ts index a9acd8ec..043fbad7 100644 --- a/ui/src/lib/cliproxy-account-stats.ts +++ b/ui/src/lib/cliproxy-account-stats.ts @@ -9,8 +9,17 @@ export function getAccountStats( stats: Pick | null | undefined, account: Pick ): 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; } diff --git a/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts b/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts index 4a7c1eac..3d70a166 100644 --- a/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts +++ b/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts @@ -22,7 +22,9 @@ function mockRandom(): void { }); } -function makeAccount(overrides: Partial & Pick): AccountData { +function makeAccount( + overrides: Partial & Pick +): AccountData { return { provider: 'agy', successCount: 0, @@ -170,9 +172,7 @@ describe('generateConnectionEvents()', () => { 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 accounts: AccountData[] = [makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 })]; const events = generateConnectionEvents(accounts); From be0c597d255a21a7e935f033dfcc4de4e19c8c8e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 11:46:12 -0400 Subject: [PATCH 10/33] docs(architecture): document codex duplicate-email identity --- docs/system-architecture/provider-flows.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 69d9f317..a9e4c8a1 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -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()`. From 80341f18c3c93dd4cdca92f6965136cff90be6f7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 15:24:45 -0400 Subject: [PATCH 11/33] feat(ui): unify duplicate-email account surfaces --- .../account/flow-viz/account-card.tsx | 481 +++++++------- ui/src/components/account/flow-viz/types.ts | 10 +- .../account/shared/account-quota-panel.tsx | 330 ++++++++++ .../account/shared/account-surface-card.tsx | 231 +++++++ .../components/cliproxy/cliproxy-dialog.tsx | 4 +- .../cliproxy/provider-editor/account-item.tsx | 597 +++++------------- .../auth-monitor/components/provider-card.tsx | 6 +- .../monitoring/auth-monitor/hooks.ts | 41 +- .../monitoring/auth-monitor/index.tsx | 41 +- .../monitoring/auth-monitor/types.ts | 7 + .../setup/wizard/steps/account-step.tsx | 68 +- .../setup/wizard/steps/variant-step.tsx | 46 +- ui/src/hooks/use-cliproxy-stats.ts | 37 +- ui/src/lib/account-identity.ts | 175 ++++- ui/src/lib/account-visual-groups.ts | 127 ++++ ui/tests/unit/ui/lib/account-identity.test.ts | 62 ++ 16 files changed, 1471 insertions(+), 792 deletions(-) create mode 100644 ui/src/components/account/shared/account-quota-panel.tsx create mode 100644 ui/src/components/account/shared/account-surface-card.tsx create mode 100644 ui/src/lib/account-visual-groups.ts create mode 100644 ui/tests/unit/ui/lib/account-identity.test.ts diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index af63b795..b77f6dd7 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -2,39 +2,25 @@ * Account Card Component for Flow Visualization */ -import { - cn, - formatQuotaPercent, - getCodexQuotaBreakdown, - getQuotaFailureInfo, - getProviderMinQuota, - getProviderResetTime, - isClaudeQuotaResult, - isCodexQuotaResult, -} from '@/lib/utils'; -import { formatAccountVariantLabel } from '@/lib/account-identity'; -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', @@ -57,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; } @@ -88,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, @@ -109,87 +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 ? ( + <> +
variant.audienceLabel ?? variant.detailLabel ?? 'Variant') + .join(' • ')} + > + {groupedVariantSummaryLabel ? ( + + {groupedVariantSummaryLabel} + + ) : ( + groupedHeaderVariants.map((variant, 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 + )} + + )) + )} +
+ {account.paused && ( + + Paused + + )} + + ) : 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 ( +
+
+ {label} + + {quotaQuery?.isLoading + ? t('accountCard.quotaLoading') + : quotaValue !== null + ? `${quotaLabel}%` + : failureInfo?.label || t('accountCard.quotaUnavailable')} + +
+ {quotaValue !== null && ( +
+
+
+ )} +
+ ); + }) : 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 variantLabel = formatAccountVariantLabel(account.id, account.email); + const headerEnd = ( + <> + {onPauseToggle && ( + + + + + + + {account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')} + + + + )} + + + ); return (
- {/* Header row: Email + Tier | Pause button | Drag handle */} -
- {/* Email with tier badge inline */} -
- - {cleanEmail(account.email)} - - {variantLabel && ( - - {variantLabel} - - )} - {showTierBadge && ( - - {account.tier} - - )} -
- - {/* Pause/Resume button */} - {onPauseToggle && ( - - - - - - - {account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')} - - - - )} - - {/* Drag handle */} - -
- - + + {groupedQuotaRows &&
{groupedQuotaRows}
} + + } /> - {/* Quota bar for CLIProxy accounts */} - {isCliproxyProvider && ( -
- {quotaLoading ? ( -
- - {t('accountCard.quotaLoading')} -
- ) : minQuotaValue !== null ? ( - - - -
-
- - {t('accountCard.quota')} - - 50 - ? 'text-emerald-600 dark:text-emerald-400' - : minQuotaValue > 20 - ? 'text-amber-500' - : 'text-red-500' - )} - > - {minQuotaLabel}% - -
- {compactQuotaRows.length > 0 && ( -
- {compactQuotaRows.map((row) => ( - - {row.label} {row.value}% - - ))} -
- )} -
-
50 - ? 'bg-emerald-500' - : minQuotaValue > 20 - ? 'bg-amber-500' - : 'bg-red-500' - )} - style={{ width: `${minQuotaValue}%` }} - /> -
-
- - - - - - - ) : quota?.success ? ( -
- {t('accountCard.quotaUnavailable')} -
- ) : failureInfo ? ( - - - -
- - {failureInfo.label} -
-
- -
-

{failureInfo.summary}

- {failureInfo.actionHint && ( -

{failureInfo.actionHint}

- )} - {failureInfo.technicalDetail && ( -

- {failureInfo.technicalDetail} -

- )} - {failureInfo.rawDetail && ( -
-                        {failureInfo.rawDetail}
-                      
- )} -
-
-
-
- ) : null} -
- )} +
void; - onPauseToggle?: (accountId: string, paused: boolean) => void; + onPauseToggle?: (accountIds: string[], paused: boolean) => void; isPausingAccount?: boolean; } diff --git a/ui/src/components/account/shared/account-quota-panel.tsx b/ui/src/components/account/shared/account-quota-panel.tsx new file mode 100644 index 00000000..95c4fb57 --- /dev/null +++ b/ui/src/components/account/shared/account-quota-panel.tsx @@ -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 ( +
+ + {mode === 'compact' ? t('accountCard.quotaLoading') : 'Loading quota...'} +
+ ); + } + + if (minQuotaValue !== null) { + return ( +
+ + + + {mode === 'compact' ? ( +
+
+ + {t('accountCard.quota')} + + 50 + ? 'text-emerald-600 dark:text-emerald-400' + : minQuotaValue > 20 + ? 'text-amber-500' + : 'text-red-500' + )} + > + {minQuotaLabel}% + +
+ {quotaRows.length > 0 && ( +
+ {quotaRows.map((row) => ( + + {row.label} {row.value}% + + ))} +
+ )} +
+
50 + ? 'bg-emerald-500' + : minQuotaValue > 20 + ? 'bg-amber-500' + : 'bg-red-500' + )} + style={{ width: `${minQuotaValue}%` }} + /> +
+
+ ) : ( +
+
+ {isRecentlyUsed(runtimeLastUsed) ? ( + <> + + + Active · {formatRelativeTime(runtimeLastUsed)} + + + ) : runtimeLastUsed ? ( + <> + + + Last used {formatRelativeTime(runtimeLastUsed)} + + + ) : ( + <> + + Not used yet + + )} +
+ {quotaRows.length > 0 ? ( +
+ {quotaRows.map((row) => ( +
+ + {row.label} + + + {row.value}% +
+ ))} +
+ ) : ( +
+ + {minQuotaLabel}% +
+ )} +
+ )} + + + + + + +
+ ); + } + + if (quota?.success) { + return mode === 'compact' ? ( +
+ {t('accountCard.quotaUnavailable')} +
+ ) : ( +
+ + + {t('accountCard.quotaUnavailable')} + +
+ ); + } + + 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 ( + + + + {mode === 'compact' ? ( +
+ + {failureInfo.label} +
+ ) : ( +
+ + + {failureInfo.label} + +
+ )} +
+ +
+

{failureInfo.summary}

+ {failureInfo.actionHint && ( +

{failureInfo.actionHint}

+ )} + {failureInfo.technicalDetail && ( +

+ {failureInfo.technicalDetail} +

+ )} + {failureInfo.rawDetail && ( +
+                {failureInfo.rawDetail}
+              
+ )} +
+
+
+
+ ); +} diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx new file mode 100644 index 00000000..64a1795c --- /dev/null +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -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 && ( + + {tier} + + )} + {identity.audienceLabel && ( + + {getCompactAudienceBadgeLabel(identity.audience)} + + )} + {paused && ( + + Paused + + )} + + ); + + return ( +
+
+
+ {beforeIdentity} + {!isCompact && ( +
+
+ +
+ {showTierBadge && ( + + {tier === 'ultra' ? 'U' : 'P'} + + )} +
+ )} + +
+
+ + {title} + + {isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)} + {!isCompact && identity.audienceLabel && ( + + {identity.audienceLabel} + + )} + {!isCompact && identity.detailLabel && ( + + {identity.detailLabel} + + )} + {!isCompact && isDefault && ( + + + Default + + )} + {!isCompact && paused && ( + + + Paused + + )} +
+ + {bodySlot &&
{bodySlot}
} +
+
+ + {headerEnd && ( +
+ {headerEnd} +
+ )} +
+ + {footerSlot} + + {showQuota && ( + + )} +
+ ); +} diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index 044b0284..4af6f2bd 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -221,7 +221,9 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { {providerAccounts.map((acc) => ( ))} diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index c749656b..59a401df 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -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,88 +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 { formatAccountVariantLabel } from '@/lib/account-identity'; -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 ( + + + +
+
+
+ +

GCP Project ID (read-only)

+
+
+
+ ); } -} -/** - * 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 ( + + + +
+ + Project ID: N/A +
+
+ +
+

Missing Project ID

+

This may cause errors. Remove the account and re-add it to fetch the project ID.

+
+
+
+
+ ); } export function AccountItem({ @@ -110,396 +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 variantLabel = formatAccountVariantLabel(account.id, account.email); + const beforeIdentity = + selectable || onPauseToggle ? ( +
+ {selectable && ( + + )} + {onPauseToggle && ( + + + + + + + {account.paused ? 'Resume account' : 'Pause account'} + + + + )} +
+ ) : undefined; + + const headerEnd = ( + + + + + + {!account.isDefault && ( + + + Set as default + + )} + + + {isRemoving ? 'Removing...' : 'Remove account'} + + + + ); return (
-
-
- {/* Selection checkbox for bulk actions */} - {selectable && ( - - )} - {/* Pause/Resume toggle button - visible left of avatar */} - {onPauseToggle && ( - - - - - - - {account.paused ? 'Resume account' : 'Pause account'} - - - - )} - {/* Avatar with tier badge overlay */} -
-
- -
- {/* Tier badge - fixed position on avatar */} - {account.tier && account.tier !== 'unknown' && account.tier !== 'free' && ( - - {account.tier === 'ultra' ? 'U' : 'P'} - - )} -
-
-
- - {account.email || account.id} - - {variantLabel && ( - - {variantLabel} - - )} - {account.isDefault && ( - - - Default - - )} - {account.paused && ( - - - Paused - - )} -
- {/* Project ID for Antigravity accounts - read-only */} - {account.provider === 'agy' && ( -
- {account.projectId ? ( - - - -
-
-
- -

GCP Project ID (read-only)

-
-
-
- ) : ( - - - -
- - Project ID: N/A -
-
- -
-

Missing Project ID

-

- This may cause errors. Remove the account and re-add it to fetch the - project ID. -

-
-
-
-
- )} -
- )} - {account.lastUsedAt && ( -
- - Last used: {new Date(account.lastUsedAt).toLocaleDateString()} -
- )} -
-
- - - - - - - {!account.isDefault && ( - - - Set as default - - )} - - - {isRemoving ? 'Removing...' : 'Remove account'} - - - -
- - {/* Quota bar - supports all providers with quota API */} - {showQuota && ( -
- {quotaLoading ? ( -
- - Loading quota... -
- ) : minQuotaValue !== null ? ( -
- {/* Status indicator based on runtime usage, not file state */} -
- {wasRecentlyUsed ? ( - <> - - - Active · {formatRelativeTime(runtimeLastUsed)} - - - ) : runtimeLastUsed ? ( - <> - - - Last used {formatRelativeTime(runtimeLastUsed)} - - - ) : ( - <> - - Not used yet - - )} -
- {/* Quota bar */} - - - - {dualWindowQuotaRows.length > 0 ? ( -
- {dualWindowQuotaRows.map((row) => ( -
- - {row.label} - - - - {row.value}% - -
- ))} -
- ) : ( -
- - - {minQuotaLabel}% - -
- )} -
- - - -
-
-
- ) : quota?.success ? ( -
- - - {t('accountCard.quotaUnavailable')} - -
- ) : failureInfo ? ( - - - -
- - - {failureInfo.label} - -
-
- -
-

{failureInfo.summary}

- {failureInfo.actionHint && ( -

{failureInfo.actionHint}

- )} - {failureInfo.technicalDetail && ( -

- {failureInfo.technicalDetail} -

- )} - {failureInfo.rawDetail && ( -
-                        {failureInfo.rawDetail}
-                      
- )} -
-
-
-
- ) : null} -
- )} +
); } diff --git a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx index 04ad2534..dc0ea26e 100644 --- a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx +++ b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx @@ -113,7 +113,11 @@ export function ProviderCard({
{isMissingProjectId && ( diff --git a/ui/src/components/monitoring/auth-monitor/hooks.ts b/ui/src/components/monitoring/auth-monitor/hooks.ts index a077a07e..0ed653d0 100644 --- a/ui/src/components/monitoring/auth-monitor/hooks.ts +++ b/ui/src/components/monitoring/auth-monitor/hooks.ts @@ -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); diff --git a/ui/src/components/monitoring/auth-monitor/index.tsx b/ui/src/components/monitoring/auth-monitor/index.tsx index 1e40e6d0..04f05f5a 100644 --- a/ui/src/components/monitoring/auth-monitor/index.tsx +++ b/ui/src/components/monitoring/auth-monitor/index.tsx @@ -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 + } /> ) : (
diff --git a/ui/src/components/monitoring/auth-monitor/types.ts b/ui/src/components/monitoring/auth-monitor/types.ts index 1ddbf44a..793b8c6f 100644 --- a/ui/src/components/monitoring/auth-monitor/types.ts +++ b/ui/src/components/monitoring/auth-monitor/types.ts @@ -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 { diff --git a/ui/src/components/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx index 339b1ae7..15f1bdce 100644 --- a/ui/src/components/setup/wizard/steps/account-step.tsx +++ b/ui/src/components/setup/wizard/steps/account-step.tsx @@ -3,8 +3,9 @@ */ import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react'; -import { formatAccountDisplayName } from '@/lib/account-identity'; +import { getAccountIdentityPresentation } from '@/lib/account-identity'; import { cn } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import type { AccountStepProps } from '../types'; @@ -25,29 +26,52 @@ export function AccountStep({ {/* Scrollable account list with max-height for many accounts */}
- {accounts.map((acc) => ( -
- - - ))} + + + ); + })}
{/* Divider */} diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 0961fb37..e1d4d366 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -3,10 +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 { formatAccountDisplayName } from '@/lib/account-identity'; +import { getAccountIdentityPresentation } from '@/lib/account-identity'; import { Select, SelectContent, @@ -45,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) { @@ -59,14 +67,38 @@ export function VariantStep({ return (
{selectedAccount && ( -
+
- - {t('setupVariant.using')}{' '} - - {formatAccountDisplayName(selectedAccount.id, selectedAccount.email)} +
+ + {t('setupVariant.using')}{' '} + + {selectedAccountIdentity?.email} + - + {(selectedAccountIdentity?.audienceLabel || selectedAccountIdentity?.detailLabel) && ( +
+ {selectedAccountIdentity?.audienceLabel && ( + + {selectedAccountIdentity.audienceLabel} + + )} + {selectedAccountIdentity?.detailLabel && ( + + {selectedAccountIdentity.detailLabel} + + )} +
+ )} +
)} diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index ceeb6ce0..22c00983 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -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 { // 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) + ), }); } diff --git a/ui/src/lib/account-identity.ts b/ui/src/lib/account-identity.ts index d28e9a29..167e7ad3 100644 --- a/ui/src/lib/account-identity.ts +++ b/ui/src/lib/account-identity.ts @@ -1,3 +1,26 @@ +const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']); +const BUSINESS_PLAN_PARTS = new Set(['team']); + +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) { @@ -24,7 +47,12 @@ function formatVariantPart(part: string): string { } } -export function extractAccountVariantKey(accountId: string, email?: string): string | null { +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; } @@ -33,29 +61,150 @@ export function extractAccountVariantKey(accountId: string, email?: string): str return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null; } -export function formatAccountVariantLabel(accountId: string, email?: string): string | null { - const variantKey = extractAccountVariantKey(accountId, email); - if (!variantKey) { +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 null; + return { + email: resolvedEmail, + audience: 'unknown', + audienceLabel: null, + detailLabel: null, + compactDetailLabel: null, + inlineLabel: null, + }; } - const genericSuffix = parts[parts.length - 1]; - if (['team', 'free', 'plus', 'pro'].includes(genericSuffix)) { - return [formatVariantPart(genericSuffix), ...parts.slice(0, -1).map(formatVariantPart)] + 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, + }; } - return parts.map(formatVariantPart).filter(Boolean).join(' · '); + 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 formatAccountDisplayName(accountId: string, email?: string): string { - const base = email || accountId; - const variantLabel = formatAccountVariantLabel(accountId, email); - return variantLabel ? `${base} (${variantLabel})` : base; +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; } diff --git a/ui/src/lib/account-visual-groups.ts b/ui/src/lib/account-visual-groups.ts new file mode 100644 index 00000000..7e578d92 --- /dev/null +++ b/ui/src/lib/account-visual-groups.ts @@ -0,0 +1,127 @@ +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[]; +} + +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 | 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, + }; +} + +export function buildAccountVisualGroups( + accounts: OAuthAccount[], + statsData?: Pick | null +): AccountVisualGroup[] { + const buckets = new Map(); + const accountMeta = new Map(); + + 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 canonicalEmail = variants[0]?.email ?? bucketKey; + const originalProvider = accountMeta.get(variants[0]?.id ?? '')?.provider ?? 'codex'; + + return { + id: bucketKey, + email: canonicalEmail, + tokenFile: variants[0]?.tokenFile ?? '', + provider: originalProvider, + isDefault: variants.some((variant) => variant.isDefault), + successCount: variants.reduce((sum, variant) => sum + variant.successCount, 0), + failureCount: variants.reduce((sum, variant) => sum + variant.failureCount, 0), + lastUsedAt: variants.reduce( + (latest, variant) => getLatestTimestamp(latest, variant.lastUsedAt), + undefined + ), + paused: variants.every((variant) => Boolean(variant.paused)), + memberIds: variants.map((variant) => variant.id), + variants, + }; + }); +} diff --git a/ui/tests/unit/ui/lib/account-identity.test.ts b/ui/tests/unit/ui/lib/account-identity.test.ts new file mode 100644 index 00000000..9ebc93fc --- /dev/null +++ b/ui/tests/unit/ui/lib/account-identity.test.ts @@ -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' + ); + }); +}); From 22f091689fadf485d24fc46510f211a489680a2b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 15:18:47 -0400 Subject: [PATCH 12/33] fix(codex): harden duplicate-email account actions --- .../accounts/email-account-identity.ts | 3 ++ src/cliproxy/quota-fetcher-codex.ts | 12 ++++- .../unit/cliproxy/quota-fetcher-codex.test.ts | 45 ++++++++++++++++++- ui/src/lib/account-identity.ts | 3 ++ ui/src/lib/api-client.ts | 8 ++-- ui/tests/unit/ui/lib/api-client.test.ts | 39 ++++++++++++++++ 6 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 ui/tests/unit/ui/lib/api-client.test.ts diff --git a/src/cliproxy/accounts/email-account-identity.ts b/src/cliproxy/accounts/email-account-identity.ts index a6a07a6d..35c36bd1 100644 --- a/src/cliproxy/accounts/email-account-identity.ts +++ b/src/cliproxy/accounts/email-account-identity.ts @@ -2,6 +2,9 @@ import type { CLIProxyProvider } from '../types'; const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set(['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(); } diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 6adf5e72..43fdd606 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -197,6 +197,8 @@ function readCodexAuthFile(filePath: string): CodexAuthData | null { function readCodexAuthData(accountId: string): CodexAuthData | null { const authDirs = [getAuthDir(), getPausedDir()]; 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); @@ -211,7 +213,7 @@ function readCodexAuthData(accountId: string): CodexAuthData | null { } } - const legacyEmail = extractCanonicalEmailFromAccountId(accountId) ?? accountId; + const legacyEmail = canonicalEmail ?? accountId; const sanitizedId = sanitizeEmail(legacyEmail); const expectedFile = `codex-${sanitizedId}.json`; @@ -226,7 +228,13 @@ function readCodexAuthData(accountId: string): CodexAuthData | null { } } - // 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')) { diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index dd4a4c04..af6bc479 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -365,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 () => { @@ -460,6 +464,43 @@ describe('Codex Quota Fetcher', () => { 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'); diff --git a/ui/src/lib/account-identity.ts b/ui/src/lib/account-identity.ts index 167e7ad3..eac2beda 100644 --- a/ui/src/lib/account-identity.ts +++ b/ui/src/lib/account-identity.ts @@ -1,6 +1,9 @@ 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 { diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index e6c18c2c..749c63f3 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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 */ diff --git a/ui/tests/unit/ui/lib/api-client.test.ts b/ui/tests/unit/ui/lib/api-client.test.ts new file mode 100644 index 00000000..de5baf87 --- /dev/null +++ b/ui/tests/unit/ui/lib/api-client.test.ts @@ -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` + ); + }); +}); From 498448a43181280dc06035d80b694f7522a1b28a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 15:27:57 -0400 Subject: [PATCH 13/33] fix(ui): normalize grouped account audience order --- ui/src/lib/account-visual-groups.ts | 41 ++++++++++++++----- .../unit/ui/lib/account-visual-groups.test.ts | 41 +++++++++++++++++++ 2 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 ui/tests/unit/ui/lib/account-visual-groups.test.ts diff --git a/ui/src/lib/account-visual-groups.ts b/ui/src/lib/account-visual-groups.ts index 7e578d92..7d9a1ec7 100644 --- a/ui/src/lib/account-visual-groups.ts +++ b/ui/src/lib/account-visual-groups.ts @@ -34,6 +34,12 @@ export interface AccountVisualGroup { variants?: AccountVisualVariant[]; } +const AUDIENCE_ORDER: Record = { + business: 0, + personal: 1, + unknown: 2, +}; + function getLatestTimestamp(current?: string, candidate?: string): string | undefined { if (!candidate) return current; if (!current) return candidate; @@ -64,6 +70,20 @@ function buildAccountVariant( }; } +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 | null @@ -104,24 +124,25 @@ export function buildAccountVisualGroups( }; } - const canonicalEmail = variants[0]?.email ?? bucketKey; - const originalProvider = accountMeta.get(variants[0]?.id ?? '')?.provider ?? 'codex'; + 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: variants[0]?.tokenFile ?? '', + tokenFile: orderedVariants[0]?.tokenFile ?? '', provider: originalProvider, - isDefault: variants.some((variant) => variant.isDefault), - successCount: variants.reduce((sum, variant) => sum + variant.successCount, 0), - failureCount: variants.reduce((sum, variant) => sum + variant.failureCount, 0), - lastUsedAt: variants.reduce( + 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( (latest, variant) => getLatestTimestamp(latest, variant.lastUsedAt), undefined ), - paused: variants.every((variant) => Boolean(variant.paused)), - memberIds: variants.map((variant) => variant.id), - variants, + paused: orderedVariants.every((variant) => Boolean(variant.paused)), + memberIds: orderedVariants.map((variant) => variant.id), + variants: orderedVariants, }; }); } diff --git a/ui/tests/unit/ui/lib/account-visual-groups.test.ts b/ui/tests/unit/ui/lib/account-visual-groups.test.ts new file mode 100644 index 00000000..2c654071 --- /dev/null +++ b/ui/tests/unit/ui/lib/account-visual-groups.test.ts @@ -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 & Pick) { + 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', + ]); + }); +}); From c45b2a11389df7fbe27e967d440a32f3cd32359a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 19:51:55 +0000 Subject: [PATCH 14/33] chore(release): 7.63.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eb336fab..ffa5244a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.63.0-dev.3", + "version": "7.63.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From fa37f391c17c51c74a5bfe109225caf01798dc96 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 16:07:34 -0400 Subject: [PATCH 15/33] fix: harden ai review comment formatting --- .github/review-prompt.md | 7 + .github/workflows/ai-review.yml | 47 ++- scripts/github/normalize-ai-review-output.mjs | 204 ++++++++-- .../github/normalize-ai-review-output.test.ts | 374 +++++++++++++++--- 4 files changed, 524 insertions(+), 108 deletions(-) diff --git a/.github/review-prompt.md b/.github/review-prompt.md index bb7a8732..bd0c802b 100644 --- a/.github/review-prompt.md +++ b/.github/review-prompt.md @@ -43,3 +43,10 @@ Output expectations: - Use `approved` only when the diff is ready to merge as-is. - Use `approved_with_notes` when only non-blocking follow-ups remain. - Use `changes_requested` when any blocking issue remains. +- Fill the structured fields only. The renderer owns the markdown layout. +- Keep `summary` to plain prose only. Do not include the PR title, a separate verdict line, markdown tables, file inventories, or custom section headings there. +- Keep `what`, `why`, and `fix` concise plain text. Do not emit headings, tables, or fenced code blocks inside those fields. +- Use `securityChecklist` for concise review rows about security-sensitive checks. Provide at least 1 row, and use 2-5 when possible. `status` = `pass` | `fail` | `na`. +- Use `ccsCompliance` for concise CCS-specific rule checks. Provide at least 1 row, and use 2-5 when possible. `status` = `pass` | `fail` | `na`. +- Use `informational` for small non-blocking observations that are worth calling out. +- Use `strengths` for specific things done well. No generic praise. diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 65980150..581be040 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -152,7 +152,7 @@ jobs: REVIEW_OUTPUT_FILE: pr_review.md REVIEW_COMMENT_FILE: .ccs-ai-review-comment.md REVIEW_OUTPUT_SCHEMA: >- - {"type":"object","additionalProperties":false,"properties":{"summary":{"type":"string","minLength":1},"findings":{"type":"array","maxItems":6,"items":{"type":"object","additionalProperties":false,"properties":{"severity":{"type":"string","enum":["high","medium","low"]},"title":{"type":"string","minLength":1},"file":{"type":"string","minLength":1},"line":{"type":["integer","null"],"minimum":1},"what":{"type":"string","minLength":1},"why":{"type":"string","minLength":1},"fix":{"type":"string","minLength":1}},"required":["severity","title","file","what","why","fix"]}},"overallAssessment":{"type":"string","enum":["approved","approved_with_notes","changes_requested"]},"overallRationale":{"type":"string","minLength":1},"notes":{"type":"array","maxItems":4,"items":{"type":"string","minLength":1}}},"required":["summary","findings","overallAssessment","overallRationale"]} + {"type":"object","additionalProperties":false,"properties":{"summary":{"type":"string","minLength":1,"maxLength":600},"findings":{"type":"array","maxItems":6,"items":{"type":"object","additionalProperties":false,"properties":{"severity":{"type":"string","enum":["high","medium","low"]},"title":{"type":"string","minLength":1,"maxLength":180},"file":{"type":"string","minLength":1,"maxLength":240},"line":{"type":["integer","null"],"minimum":1},"what":{"type":"string","minLength":1,"maxLength":500},"why":{"type":"string","minLength":1,"maxLength":500},"fix":{"type":"string","minLength":1,"maxLength":500}},"required":["severity","title","file","what","why","fix"]}},"securityChecklist":{"type":"array","minItems":1,"maxItems":5,"items":{"type":"object","additionalProperties":false,"properties":{"check":{"type":"string","minLength":1,"maxLength":80},"status":{"type":"string","enum":["pass","fail","na"]},"notes":{"type":"string","minLength":1,"maxLength":180}},"required":["check","status","notes"]}},"ccsCompliance":{"type":"array","minItems":1,"maxItems":5,"items":{"type":"object","additionalProperties":false,"properties":{"rule":{"type":"string","minLength":1,"maxLength":80},"status":{"type":"string","enum":["pass","fail","na"]},"notes":{"type":"string","minLength":1,"maxLength":180}},"required":["rule","status","notes"]}},"informational":{"type":"array","maxItems":4,"items":{"type":"string","minLength":1,"maxLength":220}},"strengths":{"type":"array","maxItems":4,"items":{"type":"string","minLength":1,"maxLength":220}},"overallAssessment":{"type":"string","enum":["approved","approved_with_notes","changes_requested"]},"overallRationale":{"type":"string","minLength":1,"maxLength":320}},"required":["summary","findings","securityChecklist","ccsCompliance","informational","strengths","overallAssessment","overallRationale"]} steps: - name: Prepare isolated Claude runtime @@ -202,16 +202,51 @@ jobs: env: CONTRIBUTOR_SOURCE: ${{ needs.prepare.outputs.contributor_source }} BASE_REF: ${{ github.base_ref || 'dev' }} + USE_CHECKED_OUT_REVIEW_ASSETS: >- + ${{ github.event_name == 'workflow_dispatch' && needs.prepare.outputs.contributor_source == 'internal' && '1' || '' }} run: | - # Always load prompt from base branch to prevent PR-controlled prompt injection. - # External PRs could modify review-prompt.md to suppress security findings. PROMPT_CONTENT="" - git fetch origin "$BASE_REF" --depth=1 2>/dev/null || true - PROMPT_CONTENT=$(git show "origin/${BASE_REF}:.github/review-prompt.md" 2>/dev/null || echo "") + if [ -n "$USE_CHECKED_OUT_REVIEW_ASSETS" ]; then + # workflow_dispatch on an internal PR is the trusted pre-merge replay path. + # Use the checked-out branch assets so maintainers can verify the exact formatter under test. + PROMPT_CONTENT=$(cat .github/review-prompt.md 2>/dev/null || echo "") + else + # pull_request_target and issue_comment must stay pinned to the base branch to prevent prompt injection. + git fetch origin "$BASE_REF" --depth=1 2>/dev/null || true + PROMPT_CONTENT=$(git show "origin/${BASE_REF}:.github/review-prompt.md" 2>/dev/null || echo "") + fi if [ -z "$PROMPT_CONTENT" ]; then echo "::warning::.github/review-prompt.md not found on base branch ${BASE_REF} — using fallback" PROMPT_CONTENT="You are a red-team code reviewer. Find every way this code can fail, be exploited, or produce incorrect results. Flag security issues, logic errors, missing error handling, race conditions, and injection risks. Follow the repository CLAUDE.md for project-specific guidelines. Output findings grouped by severity: High (must fix), Medium (should fix), Low (track). Use strict approval criteria." fi + + NORMALIZER_PATH="$RUNNER_TEMP/normalize-ai-review-output.mjs" + if [ -n "$USE_CHECKED_OUT_REVIEW_ASSETS" ]; then + cp scripts/github/normalize-ai-review-output.mjs "$NORMALIZER_PATH" + elif ! git show "origin/${BASE_REF}:scripts/github/normalize-ai-review-output.mjs" > "$NORMALIZER_PATH" 2>/dev/null; then + echo "::warning::scripts/github/normalize-ai-review-output.mjs not found on base branch ${BASE_REF} — using safe fallback normalizer" + printf '%s\n' \ + "import fs from 'node:fs';" \ + "" \ + "const outputFile = process.env.AI_REVIEW_OUTPUT_FILE || 'pr_review.md';" \ + "const model = process.env.AI_REVIEW_MODEL || 'unknown-model';" \ + "const runUrl = process.env.AI_REVIEW_RUN_URL || '#';" \ + "const content = [" \ + " '### ⚠️ AI Review Incomplete'," \ + " ''," \ + " 'The trusted base-branch normalizer was unavailable, so this workflow skipped rendering any PR-controlled review output.'," \ + " ''," \ + " '- Reason: trusted normalizer missing on base branch'," \ + " ''," \ + " \`Re-run \\\`/review\\\` or inspect [the workflow run](\${runUrl}).\`," \ + " ''," \ + " \`> 🤖 Reviewed by \\\`\${model}\\\`\`," \ + "].join('\\n');" \ + "fs.writeFileSync(outputFile, \`\${content}\\n\`, 'utf8');" \ + > "$NORMALIZER_PATH" + fi + echo "AI_REVIEW_NORMALIZER=$NORMALIZER_PATH" >> "$GITHUB_ENV" + DELIMITER="REVIEW_PROMPT_$(openssl rand -hex 16)" { echo "content<<${DELIMITER}" @@ -264,7 +299,7 @@ jobs: - name: Render review comment if: always() && steps.claude-review.outcome != 'cancelled' run: | - node scripts/github/normalize-ai-review-output.mjs + node "$AI_REVIEW_NORMALIZER" env: AI_REVIEW_EXECUTION_FILE: ${{ runner.temp }}/claude-execution-output.json AI_REVIEW_MODEL: ${{ env.REVIEW_MODEL }} diff --git a/scripts/github/normalize-ai-review-output.mjs b/scripts/github/normalize-ai-review-output.mjs index a01211a4..9ece60c6 100644 --- a/scripts/github/normalize-ai-review-output.mjs +++ b/scripts/github/normalize-ai-review-output.mjs @@ -15,12 +15,26 @@ const SEVERITY_HEADERS = { low: '### 🟢 Low', }; +const STATUS_LABELS = { + pass: '✅', + fail: '⚠️', + na: 'N/A', +}; + +const RENDERER_OWNED_MARKUP_PATTERNS = [ + { pattern: /^#{1,6}\s/u, reason: 'markdown heading' }, + { pattern: /^\s*Verdict\s*:/iu, reason: 'verdict label' }, + { pattern: /^\s*PR\s*#?\d+\s*Review(?:\s*[:.-]|$)/iu, reason: 'ad hoc PR heading' }, + { pattern: /\|\s*[-:]+\s*\|/u, reason: 'markdown table' }, + { pattern: /```/u, reason: 'code fence' }, +]; + function cleanText(value) { return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : ''; } function escapeMarkdownText(value) { - return cleanText(value).replace(/\\/g, '\\\\').replace(/([`*_{}\[\]<>])/g, '\\$1'); + return cleanText(value).replace(/\\/g, '\\\\').replace(/([`*_{}[\]<>|])/g, '\\$1'); } function renderCode(value) { @@ -30,6 +44,63 @@ function renderCode(value) { return `${fence}${text}${fence}`; } +function validatePlainTextField(fieldName, value) { + const text = cleanText(value); + if (!text) { + return { ok: false, reason: `${fieldName} is required` }; + } + + const match = RENDERER_OWNED_MARKUP_PATTERNS.find(({ pattern }) => pattern.test(text)); + if (match) { + return { ok: false, reason: `${fieldName} contains ${match.reason}` }; + } + + return { ok: true, value: text }; +} + +function normalizeStringList(fieldName, raw) { + if (!Array.isArray(raw)) { + return { ok: false, reason: `${fieldName} must be an array` }; + } + + const values = []; + for (const [index, item] of raw.entries()) { + const validation = validatePlainTextField(`${fieldName}[${index}]`, item); + if (!validation.ok) return validation; + values.push(validation.value); + } + + return { ok: true, value: values }; +} + +function normalizeChecklistRows(fieldName, labelField, raw) { + if (!Array.isArray(raw)) { + return { ok: false, reason: `${fieldName} must be an array` }; + } + + const rows = []; + for (const [index, item] of raw.entries()) { + const label = validatePlainTextField(`${fieldName}[${index}].${labelField}`, item?.[labelField]); + if (!label.ok) return label; + + const notes = validatePlainTextField(`${fieldName}[${index}].notes`, item?.notes); + if (!notes.ok) return notes; + + const status = cleanText(item?.status).toLowerCase(); + if (!STATUS_LABELS[status]) { + return { ok: false, reason: `${fieldName}[${index}].status is invalid` }; + } + + rows.push({ [labelField]: label.value, status, notes: notes.value }); + } + + if (rows.length === 0) { + return { ok: false, reason: `${fieldName} must contain at least 1 item` }; + } + + return { ok: true, value: rows }; +} + function readExecutionMetadata(executionFile) { if (!executionFile || !fs.existsSync(executionFile)) { return {}; @@ -64,50 +135,106 @@ export function normalizeStructuredOutput(raw) { return { ok: false, reason: 'structured output must be an object' }; } - const summary = cleanText(parsed.summary); - const overallAssessment = cleanText(parsed.overallAssessment); - const overallRationale = cleanText(parsed.overallRationale); - const notes = Array.isArray(parsed.notes) ? parsed.notes.map(cleanText).filter(Boolean) : []; - const findings = Array.isArray(parsed.findings) ? parsed.findings : null; + const summary = validatePlainTextField('summary', parsed.summary); + if (!summary.ok) return summary; - if (!summary || !ASSESSMENTS[overallAssessment] || !overallRationale || findings === null) { + const overallAssessment = cleanText(parsed.overallAssessment).toLowerCase(); + const overallRationale = validatePlainTextField('overallRationale', parsed.overallRationale); + if (!overallRationale.ok) return overallRationale; + + const findings = Array.isArray(parsed.findings) ? parsed.findings : null; + const securityChecklist = normalizeChecklistRows('securityChecklist', 'check', parsed.securityChecklist); + if (!securityChecklist.ok) return securityChecklist; + + const ccsCompliance = normalizeChecklistRows('ccsCompliance', 'rule', parsed.ccsCompliance); + if (!ccsCompliance.ok) return ccsCompliance; + + const informational = normalizeStringList('informational', parsed.informational); + if (!informational.ok) return informational; + + const strengths = normalizeStringList('strengths', parsed.strengths); + if (!strengths.ok) return strengths; + + if (!ASSESSMENTS[overallAssessment] || findings === null) { return { ok: false, reason: 'structured output is missing required review fields' }; } const normalizedFindings = []; - for (const finding of findings) { - const severity = cleanText(finding?.severity); - const title = cleanText(finding?.title); - const file = cleanText(finding?.file); - const what = cleanText(finding?.what); - const why = cleanText(finding?.why); - const fix = cleanText(finding?.fix); - const line = - typeof finding?.line === 'number' && Number.isInteger(finding.line) && finding.line > 0 - ? finding.line - : null; + for (const [index, finding] of findings.entries()) { + const severity = cleanText(finding?.severity).toLowerCase(); + const title = validatePlainTextField(`findings[${index}].title`, finding?.title); + if (!title.ok) return title; - if (!SEVERITY_HEADERS[severity] || !title || !file || !what || !why || !fix) { - return { ok: false, reason: 'structured output contains an invalid finding' }; + const file = validatePlainTextField(`findings[${index}].file`, finding?.file); + if (!file.ok) return file; + + const what = validatePlainTextField(`findings[${index}].what`, finding?.what); + if (!what.ok) return what; + + const why = validatePlainTextField(`findings[${index}].why`, finding?.why); + if (!why.ok) return why; + + const fix = validatePlainTextField(`findings[${index}].fix`, finding?.fix); + if (!fix.ok) return fix; + + let line = null; + if (finding && Object.hasOwn(finding, 'line')) { + if (finding.line === null) { + line = null; + } else if (typeof finding.line === 'number' && Number.isInteger(finding.line) && finding.line > 0) { + line = finding.line; + } else { + return { ok: false, reason: `findings[${index}].line is invalid` }; + } } - normalizedFindings.push({ severity, title, file, line, what, why, fix }); + if (!SEVERITY_HEADERS[severity]) { + return { ok: false, reason: `findings[${index}].severity is invalid` }; + } + + normalizedFindings.push({ + severity, + title: title.value, + file: file.value, + line, + what: what.value, + why: why.value, + fix: fix.value, + }); } return { ok: true, value: { - summary, + summary: summary.value, findings: normalizedFindings, overallAssessment, - overallRationale, - notes, + overallRationale: overallRationale.value, + securityChecklist: securityChecklist.value, + ccsCompliance: ccsCompliance.value, + informational: informational.value, + strengths: strengths.value, }, }; } +function renderChecklistTable(title, labelHeader, labelKey, rows) { + const lines = ['', title, '', `| ${labelHeader} | Status | Notes |`, '|---|---|---|']; + for (const row of rows) { + lines.push( + `| ${escapeMarkdownText(row[labelKey])} | ${STATUS_LABELS[row.status]} | ${escapeMarkdownText(row.notes)} |` + ); + } + return lines; +} + +function renderBulletSection(title, items) { + if (items.length === 0) return []; + return ['', title, ...items.map((item) => `- ${escapeMarkdownText(item)}`)]; +} + export function renderStructuredReview(review, { model }) { - const lines = ['## Summary', escapeMarkdownText(review.summary), '', '## Findings']; + const lines = ['### 📋 Summary', '', escapeMarkdownText(review.summary), '', '### 🔍 Findings']; if (review.findings.length === 0) { lines.push('No confirmed issues found after reviewing the diff and surrounding code.'); @@ -116,34 +243,31 @@ export function renderStructuredReview(review, { model }) { const findings = review.findings.filter((finding) => finding.severity === severity); if (findings.length === 0) continue; - lines.push(SEVERITY_HEADERS[severity]); + lines.push('', SEVERITY_HEADERS[severity], ''); for (const finding of findings) { const location = finding.line ? `${finding.file}:${finding.line}` : finding.file; lines.push(`- **${renderCode(location)} — ${escapeMarkdownText(finding.title)}**`); lines.push(` Problem: ${escapeMarkdownText(finding.what)}`); lines.push(` Why it matters: ${escapeMarkdownText(finding.why)}`); lines.push(` Suggested fix: ${escapeMarkdownText(finding.fix)}`); + lines.push(''); } - lines.push(''); - } - if (lines[lines.length - 1] === '') { - lines.pop(); } + if (lines[lines.length - 1] === '') lines.pop(); } - if (review.notes.length > 0) { - lines.push('', '## Notes'); - for (const note of review.notes) { - lines.push(`- ${escapeMarkdownText(note)}`); - } - } + lines.push(...renderChecklistTable('### 🔒 Security Checklist', 'Check', 'check', review.securityChecklist)); + lines.push(...renderChecklistTable('### 📊 CCS Compliance', 'Rule', 'rule', review.ccsCompliance)); + lines.push(...renderBulletSection('### 💡 Informational', review.informational)); + lines.push(...renderBulletSection("### ✅ What's Done Well", review.strengths)); lines.push( '', - '## Overall Assessment', + '### 🎯 Overall Assessment', + '', `**${ASSESSMENTS[review.overallAssessment]}** — ${escapeMarkdownText(review.overallRationale)}`, '', - `> Reviewed by \`${model}\`` + `> 🤖 Reviewed by \`${model}\`` ); return lines.join('\n'); @@ -151,7 +275,7 @@ export function renderStructuredReview(review, { model }) { export function renderIncompleteReview({ model, reason, runUrl, runtimeTools, turnsUsed }) { const lines = [ - '## AI Review Incomplete', + '### ⚠️ AI Review Incomplete', '', 'Claude did not return validated structured review output, so this workflow did not publish raw scratch text.', '', @@ -165,7 +289,7 @@ export function renderIncompleteReview({ model, reason, runUrl, runtimeTools, tu lines.push(`- Turns used: ${turnsUsed}`); } - lines.push('', `Re-run \`/review\` or inspect [the workflow run](${runUrl}).`, '', `> Reviewed by \`${model}\``); + lines.push('', `Re-run \`/review\` or inspect [the workflow run](${runUrl}).`, '', `> 🤖 Reviewed by \`${model}\``); return lines.join('\n'); } diff --git a/tests/unit/scripts/github/normalize-ai-review-output.test.ts b/tests/unit/scripts/github/normalize-ai-review-output.test.ts index b81f3a22..b3a8c11d 100644 --- a/tests/unit/scripts/github/normalize-ai-review-output.test.ts +++ b/tests/unit/scripts/github/normalize-ai-review-output.test.ts @@ -5,6 +5,15 @@ import path from 'node:path'; const reviewOutput = await import('../../../../scripts/github/normalize-ai-review-output.mjs'); +function withTempDir(prefix: string, run: (tempDir: string) => void) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + try { + run(tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + describe('normalize-ai-review-output', () => { test('renders validated structured output into stable markdown', () => { const validation = reviewOutput.normalizeStructuredOutput( @@ -21,97 +30,338 @@ describe('normalize-ai-review-output', () => { fix: 'Match by stable account identity first and keep ambiguous email lookups out of exact-match paths.', }, ], + securityChecklist: [ + { + check: 'Injection safety', + status: 'pass', + notes: 'No user-controlled input reaches a shell, SQL, or HTML boundary in this diff.', + }, + ], + ccsCompliance: [ + { + rule: 'No emojis in CLI', + status: 'na', + notes: 'This change affects GitHub PR comments only, not CLI stdout.', + }, + ], + informational: ['The renderer still escapes markdown before publishing comment content.'], + strengths: ['The formatter owns the output shape instead of trusting the model to author markdown.'], overallAssessment: 'changes_requested', overallRationale: 'The blocking lookup regression should be fixed before merge.', - notes: ['Docs update is present and looks aligned with the code changes.'], }) ); expect(validation.ok).toBe(true); const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5.1' }); - expect(markdown).toContain('## Summary'); + expect(markdown).toContain('### 📋 Summary'); expect(markdown).toContain('### 🔴 High'); - expect(markdown).toContain('`src/cliproxy/accounts/query.ts:61`'); + expect(markdown).toContain('**`src/cliproxy/accounts/query.ts:61` — Ambiguous account lookup drops valid matches**'); + expect(markdown).toContain('### 🔒 Security Checklist'); + expect(markdown).toContain('| Injection safety | ✅ | No user-controlled input reaches a shell, SQL, or HTML boundary in this diff. |'); + expect(markdown).toContain('### 📊 CCS Compliance'); + expect(markdown).toContain('| No emojis in CLI | N/A | This change affects GitHub PR comments only, not CLI stdout. |'); + expect(markdown).toContain('### 💡 Informational'); + expect(markdown).toContain('### ✅ What\'s Done Well'); + expect(markdown).toContain('### 🎯 Overall Assessment'); expect(markdown).toContain('**❌ CHANGES REQUESTED**'); expect(markdown).toContain('Why it matters: That breaks normal selection flows for users with multiple Codex sessions.'); - expect(markdown).toContain('> Reviewed by `glm-5.1`'); + expect(markdown).toContain('> 🤖 Reviewed by `glm-5.1`'); }); test('writes a safe incomplete comment instead of leaking raw assistant text', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-review-')); - const executionFile = path.join(tempDir, 'claude-execution-output.json'); - const outputFile = path.join(tempDir, 'pr_review.md'); + withTempDir('ai-review-', (tempDir) => { + const executionFile = path.join(tempDir, 'claude-execution-output.json'); + const outputFile = path.join(tempDir, 'pr_review.md'); - fs.writeFileSync( - executionFile, - JSON.stringify([ - { type: 'system', subtype: 'init', tools: ['Bash', 'Edit', 'Read'] }, - { - type: 'result', - subtype: 'success', - num_turns: 25, - result: 'Now let me verify the findings before I finalize the review...', - }, - ]) - ); + fs.writeFileSync( + executionFile, + JSON.stringify([ + { type: 'system', subtype: 'init', tools: ['Bash', 'Edit', 'Read'] }, + { + type: 'result', + subtype: 'success', + num_turns: 25, + result: 'Now let me verify the findings before I finalize the review...', + }, + ]) + ); - const result = reviewOutput.writeReviewFromEnv({ - AI_REVIEW_EXECUTION_FILE: executionFile, - AI_REVIEW_MODEL: 'glm-5.1', - AI_REVIEW_OUTPUT_FILE: outputFile, - AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592', - AI_REVIEW_STRUCTURED_OUTPUT: '', + const result = reviewOutput.writeReviewFromEnv({ + AI_REVIEW_EXECUTION_FILE: executionFile, + AI_REVIEW_MODEL: 'glm-5.1', + AI_REVIEW_OUTPUT_FILE: outputFile, + AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592', + AI_REVIEW_STRUCTURED_OUTPUT: '', + }); + + expect(result.usedFallback).toBe(true); + + const markdown = fs.readFileSync(outputFile, 'utf8'); + expect(markdown).toContain('### ⚠️ AI Review Incomplete'); + expect(markdown).toContain('Runtime tools: `Bash`, `Edit`, `Read`'); + expect(markdown).toContain('Turns used: 25'); + expect(markdown).not.toContain('Now let me verify the findings'); }); - - expect(result.usedFallback).toBe(true); - - const markdown = fs.readFileSync(outputFile, 'utf8'); - expect(markdown).toContain('## AI Review Incomplete'); - expect(markdown).toContain('Runtime tools: `Bash`, `Edit`, `Read`'); - expect(markdown).toContain('Turns used: 25'); - expect(markdown).not.toContain('Now let me verify the findings'); }); test('escapes markdown-looking content and ignores malformed execution metadata', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-review-')); - const executionFile = path.join(tempDir, 'claude-execution-output.json'); - const outputFile = path.join(tempDir, 'pr_review.md'); + withTempDir('ai-review-', (tempDir) => { + const executionFile = path.join(tempDir, 'claude-execution-output.json'); + const outputFile = path.join(tempDir, 'pr_review.md'); - fs.writeFileSync(executionFile, '{not valid json'); + fs.writeFileSync(executionFile, '{not valid json'); - const result = reviewOutput.writeReviewFromEnv({ - AI_REVIEW_EXECUTION_FILE: executionFile, - AI_REVIEW_MODEL: 'glm-5.1', - AI_REVIEW_OUTPUT_FILE: outputFile, - AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/1', - AI_REVIEW_STRUCTURED_OUTPUT: JSON.stringify({ - summary: 'Summary with `code` and ## heading markers.', + const result = reviewOutput.writeReviewFromEnv({ + AI_REVIEW_EXECUTION_FILE: executionFile, + AI_REVIEW_MODEL: 'glm-5.1', + AI_REVIEW_OUTPUT_FILE: outputFile, + AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/1', + AI_REVIEW_STRUCTURED_OUTPUT: JSON.stringify({ + summary: 'Summary with `code` and ## heading markers.', + findings: [ + { + severity: 'low', + title: 'Title with `ticks`', + file: 'src/example.ts', + line: 9, + what: 'Problem text uses **bold** markers.', + why: 'Why text uses [link] syntax.', + fix: 'Fix text uses markers.', + }, + ], + securityChecklist: [ + { + check: 'Injection safety', + status: 'pass', + notes: 'Notes with a pipe | still render safely in table cells.', + }, + ], + ccsCompliance: [ + { + rule: 'Cross-platform', + status: 'pass', + notes: 'Applies equally across macOS, Linux, and Windows.', + }, + ], + informational: ['Informational item with `inline code`.'], + strengths: ['Strength with **bold** markers.'], + overallAssessment: 'approved_with_notes', + overallRationale: 'Rationale keeps `_formatting_` stable.', + }), + }); + + expect(result.usedFallback).toBe(false); + + const markdown = fs.readFileSync(outputFile, 'utf8'); + expect(markdown).toContain('Summary with \\`code\\` and ## heading markers.'); + expect(markdown).toContain('**`src/example.ts:9` — Title with \\`ticks\\`**'); + expect(markdown).toContain('Problem: Problem text uses \\*\\*bold\\*\\* markers.'); + expect(markdown).toContain('Why it matters: Why text uses \\[link\\] syntax.'); + expect(markdown).toContain('Suggested fix: Fix text uses \\ markers.'); + expect(markdown).toContain('Notes with a pipe \\| still render safely in table cells.'); + expect(markdown).toContain('- Informational item with \\`inline code\\`.'); + expect(markdown).toContain('- Strength with \\*\\*bold\\*\\* markers.'); + expect(markdown).toContain('**⚠️ APPROVED WITH NOTES** — Rationale keeps \\`\\_formatting\\_\\` stable.'); + }); + }); + + test('rejects ad hoc layout markup inside structured fields', () => { + const validation = reviewOutput.normalizeStructuredOutput( + JSON.stringify({ + summary: '# PR #860 Review', + findings: [], + securityChecklist: [{ check: 'Injection safety', status: 'pass', notes: 'Covered.' }], + ccsCompliance: [{ rule: 'ASCII-only CLI output', status: 'pass', notes: 'Unaffected.' }], + informational: [], + strengths: [], + overallAssessment: 'approved_with_notes', + overallRationale: 'The review is otherwise valid.', + }) + ); + + expect(validation.ok).toBe(false); + expect(validation.reason).toContain('summary contains'); + }); + + test('renders approved reviews with substantive checklist rows when optional arrays are empty', () => { + const validation = reviewOutput.normalizeStructuredOutput( + JSON.stringify({ + summary: 'The diff is ready to merge as-is.', + findings: [], + securityChecklist: [ + { + check: 'Injection safety', + status: 'pass', + notes: 'No user-controlled data crosses a risky boundary in the reviewed diff.', + }, + ], + ccsCompliance: [ + { + rule: 'Help/docs alignment', + status: 'na', + notes: 'No CLI behavior changed, so there was nothing to update.', + }, + ], + informational: [], + strengths: [], + overallAssessment: 'approved', + overallRationale: 'No confirmed regressions or missing verification remain.', + }) + ); + + expect(validation.ok).toBe(true); + const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5.1' }); + + expect(markdown).toContain('### 🔍 Findings'); + expect(markdown).toContain('No confirmed issues found after reviewing the diff and surrounding code.'); + expect(markdown).toContain('### 🔒 Security Checklist'); + expect(markdown).toContain( + '| Injection safety | ✅ | No user-controlled data crosses a risky boundary in the reviewed diff. |' + ); + expect(markdown).toContain('### 📊 CCS Compliance'); + expect(markdown).toContain('| Help/docs alignment | N/A | No CLI behavior changed, so there was nothing to update. |'); + expect(markdown).toContain('**✅ APPROVED** — No confirmed regressions or missing verification remain.'); + }); + + test('renders findings without line numbers using the file path only', () => { + const validation = reviewOutput.normalizeStructuredOutput( + JSON.stringify({ + summary: 'One follow-up remains.', + findings: [ + { + severity: 'medium', + title: 'Missing empty-state coverage', + file: 'tests/unit/scripts/github/normalize-ai-review-output.test.ts', + line: null, + what: 'The empty-findings branch is not covered by a regression test.', + why: 'That leaves the highest-frequency render path vulnerable to silent regressions.', + fix: 'Add a test that passes an approved review with an empty findings array.', + }, + ], + securityChecklist: [{ check: 'Injection safety', status: 'pass', notes: 'Covered.' }], + ccsCompliance: [{ rule: 'ASCII-only CLI output', status: 'pass', notes: 'Unaffected.' }], + informational: [], + strengths: [], + overallAssessment: 'approved_with_notes', + overallRationale: 'The remaining gap is test coverage only.', + }) + ); + + expect(validation.ok).toBe(true); + const markdown = reviewOutput.renderStructuredReview(validation.value, { model: 'glm-5.1' }); + + expect(markdown).toContain( + '**`tests/unit/scripts/github/normalize-ai-review-output.test.ts` — Missing empty-state coverage**' + ); + expect(markdown).not.toContain('normalize-ai-review-output.test.ts:`'); + }); + + test('renders inline code safely when the location includes backticks', () => { + const markdown = reviewOutput.renderStructuredReview( + { + summary: 'Rendering stays stable.', findings: [ { severity: 'low', - title: 'Title with `ticks`', - file: 'src/example.ts', - line: 9, - what: 'Problem text uses **bold** markers.', - why: 'Why text uses [link] syntax.', - fix: 'Fix text uses markers.', + title: 'Backtick-safe locations stay readable', + file: 'src/weird`path.ts', + line: null, + what: 'Location formatting needs a longer fence when input contains backticks.', + why: 'Otherwise GitHub markdown can break the inline code span.', + fix: 'Pick a fence one tick longer than the longest run in the input.', }, ], + securityChecklist: [{ check: 'Injection safety', status: 'pass', notes: 'Covered.' }], + ccsCompliance: [{ rule: 'ASCII-only CLI output', status: 'pass', notes: 'Unaffected.' }], + informational: [], + strengths: [], overallAssessment: 'approved_with_notes', - overallRationale: 'Rationale keeps `_formatting_` stable.', - notes: ['Note with `inline code`.'], - }), - }); + overallRationale: 'This is a formatting-only follow-up.', + }, + { model: 'glm-5.1' } + ); - expect(result.usedFallback).toBe(false); + expect(markdown).toContain('**``src/weird`path.ts`` — Backtick-safe locations stay readable**'); + }); - const markdown = fs.readFileSync(outputFile, 'utf8'); - expect(markdown).toContain('Summary with \\`code\\` and ## heading markers.'); - expect(markdown).toContain('**`src/example.ts:9` — Title with \\`ticks\\`**'); - expect(markdown).toContain('Problem: Problem text uses \\*\\*bold\\*\\* markers.'); - expect(markdown).toContain('Why it matters: Why text uses \\[link\\] syntax.'); - expect(markdown).toContain('Suggested fix: Fix text uses \\ markers.'); - expect(markdown).toContain('**⚠️ APPROVED WITH NOTES**'); + test('rejects empty checklist sections instead of synthesizing placeholder rows', () => { + const validation = reviewOutput.normalizeStructuredOutput( + JSON.stringify({ + summary: 'The diff is ready to merge as-is.', + findings: [], + securityChecklist: [], + ccsCompliance: [], + informational: [], + strengths: [], + overallAssessment: 'approved', + overallRationale: 'No confirmed regressions remain.', + }) + ); + + expect(validation.ok).toBe(false); + expect(validation.reason).toContain('securityChecklist must contain at least 1 item'); + }); + + test('allows plain prose that references section labels without starting with them', () => { + const validation = reviewOutput.normalizeStructuredOutput( + JSON.stringify({ + summary: 'The Security Checklist: row is now required, but the prose summary remains valid.', + findings: [], + securityChecklist: [{ check: 'Injection safety', status: 'pass', notes: 'Covered.' }], + ccsCompliance: [{ rule: 'ASCII-only CLI output', status: 'pass', notes: 'Unaffected.' }], + informational: ['PR #860 review logic is unchanged after this formatter-only update.'], + strengths: [], + overallAssessment: 'approved_with_notes', + overallRationale: 'The renderer still blocks actual ad hoc headings.', + }) + ); + + expect(validation.ok).toBe(true); + }); + + test('allows plain prose that starts with natural language label phrases', () => { + const validation = reviewOutput.normalizeStructuredOutput( + JSON.stringify({ + summary: 'Overall assessment: ready to merge after the renderer applies the shared layout.', + findings: [], + securityChecklist: [{ check: 'Injection safety', status: 'pass', notes: 'Covered.' }], + ccsCompliance: [{ rule: 'ASCII-only CLI output', status: 'pass', notes: 'Unaffected.' }], + informational: ['Security Checklist: rows still escape pipes safely in markdown tables.'], + strengths: [], + overallAssessment: 'approved_with_notes', + overallRationale: 'The prose can mention those phrases without becoming layout markup.', + }) + ); + + expect(validation.ok).toBe(true); + }); + + test('rejects invalid non-null finding line numbers', () => { + const validation = reviewOutput.normalizeStructuredOutput( + JSON.stringify({ + summary: 'One finding remains.', + findings: [ + { + severity: 'medium', + title: 'Location data must stay valid', + file: 'src/example.ts', + line: 0, + what: 'The location line number is not a positive integer.', + why: 'Bad location data weakens the review signal and can hide where the issue lives.', + fix: 'Reject malformed non-null line values during normalization.', + }, + ], + securityChecklist: [{ check: 'Injection safety', status: 'pass', notes: 'Covered.' }], + ccsCompliance: [{ rule: 'ASCII-only CLI output', status: 'pass', notes: 'Unaffected.' }], + informational: [], + strengths: [], + overallAssessment: 'changes_requested', + overallRationale: 'Malformed location data should not pass validation.', + }) + ); + + expect(validation.ok).toBe(false); + expect(validation.reason).toContain('findings[0].line is invalid'); }); }); From e260df7178dd030efe3d490b4cd9421b386c43c2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 16:10:26 -0400 Subject: [PATCH 16/33] feat: add first-class CCS WebSearch runtime --- README.md | 7 +- docs/project-overview-pdr.md | 5 +- docs/project-roadmap.md | 3 +- docs/websearch.md | 43 ++- lib/hooks/websearch-transformer.cjs | 155 +++++---- lib/mcp/ccs-websearch-server.cjs | 207 +++++++++++ src/api/services/profile-lifecycle-service.ts | 8 +- src/api/services/profile-writer.ts | 8 +- src/ccs.ts | 29 +- src/cliproxy/executor/index.ts | 30 +- src/cliproxy/services/variant-settings.ts | 10 +- src/commands/install-command.ts | 13 +- src/copilot/copilot-executor.ts | 10 +- src/utils/claude-config-path.ts | 5 + src/utils/websearch-manager.ts | 42 ++- src/utils/websearch/claude-tool-args.ts | 51 +++ src/utils/websearch/hook-installer.ts | 5 +- src/utils/websearch/index.ts | 19 +- src/utils/websearch/mcp-installer.ts | 329 ++++++++++++++++++ src/utils/websearch/profile-hook-injector.ts | 8 +- .../api/profile-lifecycle-service.test.ts | 12 +- .../unit/api/profile-writer-anthropic.test.ts | 4 +- .../hooks/ccs-websearch-mcp-server.test.ts | 203 +++++++++++ .../settings-profile-websearch-launch.test.ts | 8 +- .../utils/websearch/claude-tool-args.test.ts | 24 ++ .../utils/websearch/mcp-installer.test.ts | 161 +++++++++ 26 files changed, 1241 insertions(+), 158 deletions(-) create mode 100644 lib/mcp/ccs-websearch-server.cjs create mode 100644 src/utils/websearch/claude-tool-args.ts create mode 100644 src/utils/websearch/mcp-installer.ts create mode 100644 tests/unit/hooks/ccs-websearch-mcp-server.test.ts create mode 100644 tests/unit/utils/websearch/claude-tool-args.test.ts create mode 100644 tests/unit/utils/websearch/mcp-installer.test.ts diff --git a/README.md b/README.md index 3bc3f421..34c10a75 100644 --- a/README.md +++ b/README.md @@ -630,18 +630,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, disables native `WebSearch` on third-party launches, and routes search through 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 search tool | ### Local Search Backend Chain -CCS intercepts WebSearch requests and routes them through deterministic search providers: +For third-party profiles, Claude uses the managed `ccs-websearch` MCP tool. CCS then routes that request through deterministic search providers in this order: | Priority | Provider | Setup | Notes | |----------|----------|-------|-------| @@ -673,6 +673,7 @@ 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. See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting. diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 744de589..5680a2d2 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -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 so Claude uses the CCS-owned path - Support Exa, Tavily, Brave, and DuckDuckGo real search backends - Keep Gemini CLI, OpenCode, and Grok as optional legacy fallback - Graceful fallback chain diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index eee18ec2..5ff51ac3 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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. diff --git a/docs/websearch.md b/docs/websearch.md index 914200b1..7c83632b 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -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 solves that by provisioning a first-class local MCP tool, suppressing native `WebSearch` for those launches, and running real local search providers directly. ## Architecture @@ -20,30 +20,37 @@ 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 │ +│ ccs-websearch.search │ +│ │ │ +│ ├── 1. Exa │ +│ ├── 2. Tavily │ +│ ├── 3. Brave │ +│ ├── 4. DuckDuckGo│ +│ └── 5. Legacy CLI│ +│ fallback │ +│ (Gemini/ │ +│ OpenCode/ │ +│ Grok) │ └──────────────────────────────────────────────────────────────┘ ``` ## 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. ## Providers @@ -109,8 +116,14 @@ 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 | +| `CCS_DEBUG` | Verbose WebSearch runtime logging | + +## 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 @@ -140,7 +153,7 @@ Those providers remain supported, but they are no longer the primary path. Enabl 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 ## Security Considerations diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 95311502..9b7faf42 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -576,6 +576,90 @@ function outputAllFailedMessage(query, errors) { emitHookOutput(buildFailureHookOutput(query, errors)); } +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 getActiveProviders() { + return getConfiguredProviders().filter((provider) => provider.available()); +} + +function hasAnyActiveProviders() { + return getActiveProviders().length > 0; +} + +async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + const activeProviders = getActiveProviders(); + + debug( + `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}` + ); + + if (activeProviders.length === 0) { + return { success: false, noActiveProviders: true, errors: [] }; + } + + const errors = []; + for (const provider of activeProviders) { + debug(`Trying ${provider.name}`); + const result = await provider.fn(query, timeoutSec); + if (result.success) { + return { + success: true, + providerId: provider.id, + providerName: provider.name, + content: result.content, + }; + } + errors.push({ provider: provider.name, error: result.error }); + } + + return { success: false, noActiveProviders: false, errors }; +} + async function processHook(input) { try { if (shouldSkipHook()) { @@ -596,72 +680,17 @@ async function processHook(input) { 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) { 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) { + outputSuccess(query, result.content, result.providerName); + return; } - outputAllFailedMessage(query, errors); + outputAllFailedMessage(query, result.errors); } catch (error) { debug(`Hook error: ${error.message}`); process.exit(0); @@ -691,6 +720,10 @@ module.exports = { buildSuccessHookOutput, extractDuckDuckGoResults, formatStructuredSearchResults, + getActiveProviders, + hasAnyActiveProviders, + runLocalWebSearch, + shouldSkipHook, tryExaSearch, tryTavilySearch, tryDuckDuckGoSearch, diff --git a/lib/mcp/ccs-websearch-server.cjs b/lib/mcp/ccs-websearch-server.cjs new file mode 100644 index 00000000..4d891899 --- /dev/null +++ b/lib/mcp/ccs-websearch-server.cjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node + +const { + hasAnyActiveProviders, + runLocalWebSearch, + shouldSkipHook, +} = 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 = 'search'; +const TOOL_DESCRIPTION = + 'Search the web through CCS-managed providers. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.'; + +let inputBuffer = Buffer.alloc(0); + +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: 'The search query to run against CCS WebSearch providers.', + }, + }, + required: ['query'], + additionalProperties: false, + }, + }, + ]; +} + +function writeMessage(message) { + const body = Buffer.from(JSON.stringify(message), 'utf8'); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); +} + +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 || {}; + + if (params.name !== TOOL_NAME) { + writeError(id, -32602, `Unknown tool: ${params.name || ''}`); + return; + } + + if (!shouldExposeTools()) { + writeResponse(id, { + content: [ + { + type: 'text', + text: 'CCS WebSearch is unavailable for this profile or no providers are ready.', + }, + ], + isError: true, + }); + return; + } + + const query = typeof toolArgs.query === 'string' ? toolArgs.query.trim() : ''; + if (!query) { + writeError(id, -32602, 'Tool "search" requires a non-empty string query.'); + return; + } + + const result = await runLocalWebSearch(query); + if (result.success) { + writeResponse(id, { + content: [{ type: 'text', text: result.content }], + }); + return; + } + + 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': + writeResponse(message.id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { + tools: {}, + }, + serverInfo: { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + }); + return; + case 'notifications/initialized': + return; + case 'ping': + writeResponse(message.id, {}); + return; + case 'tools/list': + writeResponse(message.id, { tools: getTools() }); + return; + case 'tools/call': + await handleToolCall(message); + return; + default: + if (message.id !== undefined) { + writeError(message.id, -32601, `Method not found: ${message.method}`); + } + } +} + +function parseMessages() { + while (true) { + 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; + } + + const body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8'); + inputBuffer = inputBuffer.slice(messageEnd); + + 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.stdin.resume(); diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index e54a3e92..50f3b86b 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -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; diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 912dc5b3..efb70c32 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -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; diff --git a/src/ccs.ts b/src/ccs.ts index ca2625fb..ff2f99dc 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -24,10 +24,11 @@ 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, } 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 +678,9 @@ async function main(): Promise { 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 +837,7 @@ async function main(): Promise { }); } 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); @@ -858,6 +859,7 @@ async function main(): Promise { ) ); } + syncWebSearchMcpToConfigDir(continuityInheritance.claudeConfigDir); const exitCode = await executeCopilotProfile( copilotConfig, remainingArgs, @@ -867,14 +869,12 @@ async function main(): Promise { 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 { ); } const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; + syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); const expandedSettingsPath = resolvedSettingsPath ?? (profileInfo.settingsPath @@ -1053,7 +1054,11 @@ async function main(): Promise { return; } - execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars); + execClaude( + claudeCli, + ['--settings', expandedSettingsPath, ...appendThirdPartyWebSearchToolArgs(remainingArgs)], + envVars + ); } else if (profileInfo.type === 'account') { // NEW FLOW: Account-based profile (work, personal) // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 1715d1bc..e8e07862 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -53,9 +53,9 @@ import { } from '../account-manager'; import { formatAccountDisplayName } from '../accounts/email-account-identity'; import { - ensureMcpWebSearch, - installWebSearchHook, + ensureWebSearchMcpOrThrow, displayWebSearchStatus, + appendThirdPartyWebSearchToolArgs, } from '../../utils/websearch-manager'; import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader'; import { installImageAnalyzerHook } from '../../utils/hooks'; @@ -197,9 +197,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/ @@ -1036,7 +1035,12 @@ export async function execClaudeWithCLIProxy( let claude: ChildProcess; if (needsShell) { - const cmdString = [claudeCli, '--settings', settingsPath, ...claudeArgs] + const cmdString = [ + claudeCli, + '--settings', + settingsPath, + ...appendThirdPartyWebSearchToolArgs(claudeArgs), + ] .map(escapeShellArg) .join(' '); claude = spawn(cmdString, { @@ -1046,11 +1050,15 @@ export async function execClaudeWithCLIProxy( env, }); } else { - claude = spawn(claudeCli, ['--settings', settingsPath, ...claudeArgs], { - stdio: 'inherit', - windowsHide: true, - env, - }); + claude = spawn( + claudeCli, + ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(claudeArgs)], + { + stdio: 'inherit', + windowsHide: true, + env, + } + ); } // 12b. Start runtime quota monitor (adaptive polling during session) diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index e55bb866..6e982528 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -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/.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; diff --git a/src/commands/install-command.ts b/src/commands/install-command.ts index 076e97fb..fb45f770 100644 --- a/src/commands/install-command.ts +++ b/src/commands/install-command.ts @@ -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 { 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!')); diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 8f885cbd..47a2df5e 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -14,7 +14,11 @@ 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, + syncWebSearchMcpToConfigDir, +} from '../utils/websearch-manager'; import { getImageAnalysisHookEnv } from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; @@ -173,9 +177,11 @@ 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 proc = spawn(claudeCliPath, appendThirdPartyWebSearchToolArgs(claudeArgs), { stdio: 'inherit', env, shell: process.platform === 'win32', diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts index 351c5be4..1c840041 100644 --- a/src/utils/claude-config-path.ts +++ b/src/utils/claude-config-path.ts @@ -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'); diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 9c4a6dc7..1503695a 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -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,23 @@ 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 status and readiness functions export { getWebSearchCliProviders, @@ -62,7 +79,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 +94,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'; diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts new file mode 100644 index 00000000..8e9b7584 --- /dev/null +++ b/src/utils/websearch/claude-tool-args.ts @@ -0,0 +1,51 @@ +/** + * Claude launch argument helpers for third-party WebSearch. + */ + +const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; +const DISALLOWED_TOOLS_FLAG = '--disallowedTools'; + +function parseToolValue(rawValue: string): string[] { + return rawValue + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +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) { + for (let cursor = index + 1; cursor < args.length; cursor += 1) { + const value = args[cursor]; + if (value.startsWith('--')) { + break; + } + if (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; +} + +export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] { + if (hasToolInFlag(args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) { + return args; + } + + return [...args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL]; +} diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index a2c7149a..dc5c1f70 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -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 */ diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts index 83c240de..5cd4cd27 100644 --- a/src/utils/websearch/index.ts +++ b/src/utils/websearch/index.ts @@ -50,6 +50,23 @@ 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'; + // Status and Readiness export { getWebSearchCliProviders, @@ -61,5 +78,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'; diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts new file mode 100644 index 00000000..ff45b31f --- /dev/null +++ b/src/utils/websearch/mcp-installer.ts @@ -0,0 +1,329 @@ +/** + * 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'; + +const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs'; +const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch'; + +interface ClaudeUserConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +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); + + try { + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + 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) } + : {}; + + 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) { + return false; + } + + if (!installWebSearchHook()) { + 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) { + 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)) { + return true; + } + + const tempPath = getTempPath(serverPath); + + try { + fs.copyFileSync(sourcePath, tempPath); + fs.chmodSync(tempPath, 0o755); + fs.renameSync(tempPath, serverPath); + return true; + } catch (error) { + 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) { + return false; + } + + const claudeUserConfigPath = getClaudeUserConfigPath(); + const claudeUserConfigDir = path.dirname(claudeUserConfigPath); + const config = readClaudeUserConfig(claudeUserConfigPath); + + if (config === null) { + 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) + : {}; + const desiredServerConfig = { + command: 'node', + args: [getWebSearchMcpServerPath()], + }; + + const currentConfig = existingServers[WEBSEARCH_MCP_SERVER_NAME]; + if ( + typeof currentConfig === 'object' && + currentConfig !== null && + JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig) + ) { + return true; + } + + const nextConfig: ClaudeUserConfig = { + ...config, + mcpServers: { + ...existingServers, + [WEBSEARCH_MCP_SERVER_NAME]: desiredServerConfig, + }, + }; + + try { + writeClaudeUserConfig(claudeUserConfigPath, nextConfig); + if (process.env.CCS_DEBUG) { + console.error(info(`Ensured WebSearch MCP config in ${claudeUserConfigPath}`)); + } + return true; + } catch (error) { + 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) { + return false; + } + + return installWebSearchMcpServer() && ensureWebSearchMcpConfig(); +} + +export function syncWebSearchMcpToConfigDir(claudeConfigDir: string | undefined): boolean { + if (!claudeConfigDir) { + return false; + } + + return new InstanceManager().syncMcpServers(claudeConfigDir); +} + +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.'); + } +} diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index cb98412d..62bbed64 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -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) diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 758c9aec..bd832b47 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -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); }); diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index fe1d8973..d3c52fa7 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -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); }); diff --git a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts new file mode 100644 index 00000000..9b0e35df --- /dev/null +++ b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'bun:test'; +import { spawn } from 'child_process'; +import { mkdtempSync, 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 { + const body = JSON.stringify(message); + return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; +} + +function collectResponses( + child: ReturnType, + expectedCount: number +): Promise>> { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const responses: Array> = []; + const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 5000); + + function tryParse(): void { + while (true) { + 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; + } + + const body = buffer.slice(headerEnd + 4, messageEnd).toString('utf8'); + buffer = buffer.slice(messageEnd); + responses.push(JSON.parse(body) as Record); + + 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. + }); + }); +} + +describe('ccs-websearch MCP server', () => { + it('lists the CCS search 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 = ` + Example title + Example snippet + `.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: 'search', 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: 'search', + description: + 'Search the web through CCS-managed providers. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query to run against CCS WebSearch providers.', + }, + }, + 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('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(); + } + }); +}); diff --git a/tests/unit/targets/settings-profile-websearch-launch.test.ts b/tests/unit/targets/settings-profile-websearch-launch.test.ts index 3753a218..e0ab84f4 100644 --- a/tests/unit/targets/settings-profile-websearch-launch.test.ts +++ b/tests/unit/targets/settings-profile-websearch-launch.test.ts @@ -92,7 +92,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 +100,7 @@ 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); }); @@ -117,7 +117,9 @@ 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); + expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain('--disallowedTools'); + expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain('WebSearch'); }); }); diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts new file mode 100644 index 00000000..e96d4de0 --- /dev/null +++ b/tests/unit/utils/websearch/claude-tool-args.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'bun:test'; +import { appendThirdPartyWebSearchToolArgs } from '../../../../src/utils/websearch/claude-tool-args'; + +describe('appendThirdPartyWebSearchToolArgs', () => { + it('appends native WebSearch suppression when no tool flags are present', () => { + expect(appendThirdPartyWebSearchToolArgs(['smoke'])).toEqual([ + 'smoke', + '--disallowedTools', + 'WebSearch', + ]); + }); + + it('does not append duplicate suppression when WebSearch is already disallowed', () => { + expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools', 'WebSearch'])).toEqual( + ['smoke', '--disallowedTools', 'WebSearch'] + ); + }); + + it('detects comma-separated disallowed tool values', () => { + expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read,WebSearch'])).toEqual( + ['smoke', '--disallowedTools=Read,WebSearch'] + ); + }); +}); diff --git a/tests/unit/utils/websearch/mcp-installer.test.ts b/tests/unit/utils/websearch/mcp-installer.test.ts new file mode 100644 index 00000000..53b3b10b --- /dev/null +++ b/tests/unit/utils/websearch/mcp-installer.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it } 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' + ); + } + + afterEach(() => { + 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; + }; + + expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] }); + expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual({ + command: 'node', + args: [getWebSearchMcpServerPath()], + }); + }); + + 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; + }; + expect(globalConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['some-server'] }, + }); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') + ) as { + otherKey: string; + mcpServers: Record; + }; + expect(instanceConfig.otherKey).toBe('keep-me'); + expect(instanceConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['instance-server'] }, + }); + }); +}); From f0cd44e016c2c4a33ad06edcf6b13d8cbf1920bf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 16:12:30 -0400 Subject: [PATCH 17/33] fix: use type-only import in instance manager --- src/management/instance-manager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 576bc828..08a3552d 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -10,7 +10,8 @@ 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'; /** Options for instance creation */ From 5ba575f00e78da55e944213b47597a03ac158db5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 20:15:22 +0000 Subject: [PATCH 18/33] chore(release): 7.63.0-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ffa5244a..7711f339 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.63.0-dev.4", + "version": "7.63.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 2c153045f33e02cc821a623f6594fb4a6bebb321 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 17:20:37 -0400 Subject: [PATCH 19/33] refactor: remove duplicate copilot websearch sync --- src/ccs.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ccs.ts b/src/ccs.ts index ff2f99dc..32d94649 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -859,7 +859,6 @@ async function main(): Promise { ) ); } - syncWebSearchMcpToConfigDir(continuityInheritance.claudeConfigDir); const exitCode = await executeCopilotProfile( copilotConfig, remainingArgs, From b087c02738c7e588491f84c8f2318c2ef5b03eca Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 17:39:21 -0400 Subject: [PATCH 20/33] fix: merge existing disallowed tool flags for websearch --- src/utils/websearch/claude-tool-args.ts | 37 +++++++++++++++++++ .../utils/websearch/claude-tool-args.test.ts | 15 ++++++++ 2 files changed, 52 insertions(+) diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index 8e9b7584..62de05ce 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -12,6 +12,14 @@ function parseToolValue(rawValue: string): string[] { .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 hasToolInFlag(args: string[], flag: string, toolName: string): boolean { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -47,5 +55,34 @@ export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] { return args; } + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === DISALLOWED_TOOLS_FLAG) { + let cursor = index + 1; + const rawValues: string[] = []; + + while (cursor < args.length && !args[cursor].startsWith('--')) { + rawValues.push(args[cursor]); + cursor += 1; + } + + return [ + ...args.slice(0, index + 1), + mergeToolValues(rawValues, NATIVE_WEBSEARCH_TOOL), + ...args.slice(cursor), + ]; + } + + if (arg.startsWith(`${DISALLOWED_TOOLS_FLAG}=`)) { + const rawValue = arg.slice(DISALLOWED_TOOLS_FLAG.length + 1); + return [ + ...args.slice(0, index), + `${DISALLOWED_TOOLS_FLAG}=${mergeToolValues([rawValue], NATIVE_WEBSEARCH_TOOL)}`, + ...args.slice(index + 1), + ]; + } + } + return [...args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL]; } diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts index e96d4de0..6eb2367c 100644 --- a/tests/unit/utils/websearch/claude-tool-args.test.ts +++ b/tests/unit/utils/websearch/claude-tool-args.test.ts @@ -21,4 +21,19 @@ describe('appendThirdPartyWebSearchToolArgs', () => { ['smoke', '--disallowedTools=Read,WebSearch'] ); }); + + it('merges WebSearch into an existing space-separated disallowed tool flag', () => { + expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools', 'Read'])).toEqual([ + 'smoke', + '--disallowedTools', + 'Read,WebSearch', + ]); + }); + + it('merges WebSearch into an existing equals-form disallowed tool flag', () => { + expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read'])).toEqual([ + 'smoke', + '--disallowedTools=Read,WebSearch', + ]); + }); }); From 6471cc55d72deeebcbca70d54983b90ae32c999a Mon Sep 17 00:00:00 2001 From: User Date: Mon, 30 Mar 2026 16:33:32 -0700 Subject: [PATCH 21/33] fix(docker): harden cliproxy local proxy with auth guard, dynamic port, and body handling Move reverse proxy under /api/cliproxy-local so it sits behind auth middleware. Enforce localhost-only access when dashboard auth is disabled. Resolve the target port from unified config instead of hardcoding 8317. Re-serialize parsed JSON bodies before forwarding so writes still work behind express.json(). Clean up proxy connections on client abort. On the frontend, point the iframe and health check at the new same-origin API path, probe the proxy directly, and tighten iframe origin/path validation before sending the auto-login secret. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web-server/index.ts | 8 +- src/web-server/routes/cliproxy-local-proxy.ts | 147 ++++++++++++++---- .../web-server/cliproxy-local-proxy.test.ts | 121 ++++++++++++++ .../cliproxy/control-panel-embed.tsx | 69 ++++---- 4 files changed, 279 insertions(+), 66 deletions(-) create mode 100644 tests/unit/web-server/cliproxy-local-proxy.test.ts diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 6015c004..9aa1ae0d 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -63,6 +63,10 @@ export async function startServer(options: ServerOptions): Promise http://127.0.0.1:{port}/* */ -import { Router, Request, Response } from 'express'; import http from 'http'; -import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +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'; -const router = Router(); +export interface CliproxyLocalProxyDeps { + enforceAccess?: (req: Request, res: Response) => boolean; + request?: typeof http.request; + resolveTargetPort?: () => number; +} -router.all('/*', (req: Request, res: Response) => { - // Strip the mount prefix — req.url already has it removed by Express - const targetPath = req.url || '/'; +function resolveLocalCliproxyPort(): number { + const config = loadOrCreateUnifiedConfig(); + return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); +} - const options: http.RequestOptions = { - hostname: '127.0.0.1', - port: CLIPROXY_DEFAULT_PORT, - path: targetPath, - method: req.method, - headers: { - ...req.headers, - host: `127.0.0.1:${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 (!isJsonContentType(req.headers['content-type']) || req.body === undefined) { + return undefined; + } + + const contentLengthHeader = req.headers['content-length']; + const contentLength = Array.isArray(contentLengthHeader) + ? contentLengthHeader[0] + : contentLengthHeader; + const hasTransferEncoding = req.headers['transfer-encoding'] !== undefined; + const parsedContentLength = + typeof contentLength === 'string' ? Number.parseInt(contentLength, 10) : NaN; + + if (!hasTransferEncoding && (!Number.isFinite(parsedContentLength) || parsedContentLength <= 0)) { + return undefined; + } + + 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}`, }; - const proxyReq = http.request(options, (proxyRes) => { - res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); - proxyRes.pipe(res, { end: true }); - }); + delete proxyHeaders.connection; - proxyReq.on('error', () => { - if (!res.headersSent) { - res.status(502).json({ error: 'CLIProxy is not reachable' }); + 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(); } }); - req.pipe(proxyReq, { end: true }); -}); + router.all('/*', (req: Request, res: Response) => { + const targetPort = resolveTargetPort(); + const targetPath = req.url || '/'; + const bodyBuffer = buildProxyBody(req); -export default router; + const proxyReq = createRequest( + { + hostname: '127.0.0.1', + port: targetPort, + path: targetPath, + method: req.method, + headers: buildProxyHeaders(req.headers, targetPort, bodyBuffer), + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + } + ); + + proxyReq.on('error', () => { + if (!res.headersSent) { + res.status(502).json({ error: 'CLIProxy is not reachable' }); + } + }); + + req.on('aborted', () => proxyReq.destroy()); + res.on('close', () => { + if (!res.writableEnded) { + proxyReq.destroy(); + } + }); + + if (bodyBuffer) { + proxyReq.end(bodyBuffer); + return; + } + + req.pipe(proxyReq, { end: true }); + }); + + return router; +} + +export default createCliproxyLocalProxyRouter(); diff --git a/tests/unit/web-server/cliproxy-local-proxy.test.ts b/tests/unit/web-server/cliproxy-local-proxy.test.ts new file mode 100644 index 00000000..57f39755 --- /dev/null +++ b/tests/unit/web-server/cliproxy-local-proxy.test.ts @@ -0,0 +1,121 @@ +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 { + servers.push(server); + + return await new Promise((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; + } + + await new Promise((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', + }); + }); +}); diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 8755ab44..67abc74e 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -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'; @@ -82,11 +83,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // (e.g., in Docker the browser cannot reach the internal CLIProxy port directly) const effectiveSecret = authTokens?.managementSecret?.value || 'ccs'; return { - managementUrl: '/cliproxy-local/management.html', - checkUrl: '/cliproxy-local/', + managementUrl: withApiBase('/cliproxy-local/management.html'), + checkUrl: withApiBase('/cliproxy-local/'), authToken: effectiveSecret, isRemote: false, - displayHost: `localhost:${port}`, + displayHost: `localhost:${localPort}`, }; }, [cliproxyConfig, authTokens, port]); @@ -96,6 +97,13 @@ 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 { @@ -109,33 +117,30 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel authToken: remote?.auth_token, }); if (result?.reachable) { - setIsConnected(true); - setError(null); + updateConnectionState(true, null); } else { - setIsConnected(false); - setError( + updateConnectionState( + false, result?.error ? `Remote CLIProxy at ${displayHost}: ${result.error}` : `Remote CLIProxy at ${displayHost} returned an error` ); } } else { - // Local mode: use same-origin API to check proxy status (avoids CORS) - const status = await api.cliproxy.proxyStatus(); - if (status.running) { - setIsConnected(true); - setError(null); + // Local mode: probe the proxied control panel root directly. + const response = await fetch(checkUrl, { signal: controller.signal }); + if (response.ok) { + updateConnectionState(true, null); } else { - setIsConnected(false); - setError('CLIProxy is not running'); + 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' @@ -148,8 +153,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel checkConnection().finally(() => clearTimeout(timeoutId)); // Cleanup: abort fetch on unmount - return () => controller.abort(); - }, [isRemote, displayHost, cliproxyConfig]); + 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. @@ -158,21 +166,20 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel } try { - // Derive apiBase and targetOrigin from checkUrl - // Local mode: checkUrl is a relative path (/cliproxy-local/) → same origin - // Remote mode: checkUrl is an absolute URL (http://host:port/) - const isRelative = checkUrl.startsWith('/'); - const apiBase = isRelative - ? `${window.location.origin}/cliproxy-local` + // 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 targetOrigin = isRelative ? window.location.origin : apiBase; + const apiBaseUrl = new URL(`${apiBase}/`); + const targetOrigin = apiBaseUrl.origin; - // Security: Validate iframe src matches target origin before sending credentials - const iframeSrc = iframeRef.current.src; - const resolvedSrc = isRelative - ? new URL(iframeSrc, window.location.origin).href - : iframeSrc; - if (!resolvedSrc.startsWith(targetOrigin)) { + // 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; } From de7171d81076ecfd4e5e6474ce062c44a4c8be9a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 22:41:51 -0400 Subject: [PATCH 22/33] feat(websearch): finish managed third-party rollout - steer third-party launches toward the managed WebSearch MCP tool - add opt-in trace diagnostics across launch, MCP, provider, and headless paths - extend docs and regression coverage for the first-class runtime Refs #862 --- README.md | 10 +- docs/websearch.md | 28 ++- lib/hooks/websearch-transformer.cjs | 169 ++++++++++++- lib/mcp/ccs-websearch-server.cjs | 130 +++++++++- src/ccs.ts | 19 +- src/cliproxy/executor/index.ts | 35 ++- src/copilot/copilot-executor.ts | 14 +- src/delegation/executor/result-aggregator.ts | 27 ++- src/delegation/executor/types.ts | 7 + src/delegation/headless-executor.ts | 73 +++++- src/utils/websearch-manager.ts | 8 + src/utils/websearch/claude-tool-args.ts | 117 +++++++-- src/utils/websearch/hook-env.ts | 7 + src/utils/websearch/index.ts | 8 + src/utils/websearch/mcp-installer.ts | 30 ++- src/utils/websearch/trace.ts | 223 ++++++++++++++++++ .../hooks/ccs-websearch-mcp-server.test.ts | 186 ++++++++++++++- .../unit/hooks/websearch-transformer.test.ts | 76 +++++- .../settings-profile-websearch-launch.test.ts | 48 +++- .../utils/claudecode-env-stripping.test.ts | 49 ++++ .../utils/websearch/claude-tool-args.test.ts | 109 ++++++++- 21 files changed, 1281 insertions(+), 92 deletions(-) create mode 100644 src/utils/websearch/trace.ts diff --git a/README.md b/README.md index 34c10a75..7de245fd 100644 --- a/README.md +++ b/README.md @@ -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, expose `ccs-websearch.WebSearch`, 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. @@ -637,11 +640,11 @@ Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native We | Profile Type | WebSearch Method | |--------------|------------------| | Claude (native) | Anthropic WebSearch API | -| Third-party profiles | CCS local MCP search tool | +| Third-party profiles | CCS local MCP `WebSearch` tool | ### Local Search Backend Chain -For third-party profiles, Claude uses the managed `ccs-websearch` MCP tool. CCS then routes that request through deterministic search providers in this order: +For third-party profiles, Claude uses the managed `ccs-websearch.WebSearch` MCP tool. The tool is intentionally named to match the native `WebSearch` concept, which helps Claude prefer it over ad hoc Bash or `curl` fetches. CCS then routes that request through deterministic search providers in this order: | Priority | Provider | Setup | Notes | |----------|----------|-------|-------| @@ -675,6 +678,9 @@ websearch: > **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.
diff --git a/docs/websearch.md b/docs/websearch.md index 7c83632b..a3976e38 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -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 provisioning a first-class local MCP tool, suppressing native `WebSearch` for those launches, 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 solves that by provisioning a first-class local MCP tool, suppressing native `WebSearch` for those launches, appending a short launch-time steering hint, and running real local search providers directly. ## Architecture @@ -27,7 +27,7 @@ Third-party profiles cannot execute Anthropic's server-side WebSearch because th │ └── Third-party Profile? → native WebSearch disabled │ │ │ │ │ └── CCS MCP tool │ -│ ccs-websearch.search │ +│ ccs-websearch.WebSearch │ │ │ │ │ ├── 1. Exa │ │ ├── 2. Tavily │ @@ -52,6 +52,13 @@ The previous design asked another model CLI to perform web search and summarize 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. +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 | Provider | Type | Setup | Default | Notes | @@ -108,6 +115,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 | @@ -118,6 +127,8 @@ websearch: | `GROK_API_KEY` | Required only for legacy Grok CLI fallback | | `CCS_WEBSEARCH_SKIP` | Disable the CCS local WebSearch runtime for the current process | | `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/`, `/tmp`, or `/var/log`) | ## Managed Runtime Files @@ -148,12 +159,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 runtime logs +4. Run with `CCS_DEBUG=1` for runtime logs, or `CCS_WEBSEARCH_TRACE=1` for correlated launch/MCP/provider traces ## Security Considerations diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 9b7faf42..6b8e023d 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -15,6 +15,9 @@ */ const { spawnSync } = require('child_process'); +const { createHash } = require('crypto'); +const fs = require('fs'); +const path = require('path'); const isWindows = process.platform === 'win32'; const DEFAULT_TIMEOUT_SEC = 55; @@ -64,12 +67,96 @@ 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 getTraceFilePath() { + const fallback = path.join(getCcsDirPath(), 'logs', 'websearch-trace.jsonl'); + const configured = (process.env.CCS_WEBSEARCH_TRACE_FILE || '').trim(); + if (!configured) { + return fallback; + } + + const resolved = path.resolve(configured); + const safePrefixes = [ + `${path.resolve(getCcsDirPath())}${path.sep}`, + `${path.resolve(process.env.HOME || process.env.USERPROFILE || process.cwd())}${path.sep}`, + '/tmp/', + '/var/log/', + ]; + + if (safePrefixes.some((prefix) => resolved.startsWith(prefix))) { + return resolved; + } + + return 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 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) { @@ -627,26 +714,53 @@ function getActiveProviders() { return getConfiguredProviders().filter((provider) => 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 activeProviders = getActiveProviders(); + const fingerprint = getQueryFingerprint(query); 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}`); + traceWebSearchEvent('websearch_provider_attempt', { + source: 'provider', + providerId: provider.id, + providerName: provider.name, + ...fingerprint, + }); const result = await provider.fn(query, timeoutSec); if (result.success) { + traceWebSearchEvent('websearch_provider_success', { + source: 'provider', + providerId: provider.id, + providerName: provider.name, + ...fingerprint, + }); return { success: true, providerId: provider.id, @@ -654,15 +768,32 @@ async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { 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); } @@ -676,23 +807,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 result = await runLocalWebSearch(query, timeout); if (result.noActiveProviders) { + traceWebSearchEvent('websearch_hook_no_active_providers', { + source: 'hook', + ...getQueryFingerprint(query), + }); process.exit(0); } if (result.success) { + traceWebSearchEvent('websearch_hook_success', { + source: 'hook', + providerId: result.providerId, + providerName: result.providerName, + ...getQueryFingerprint(query), + }); outputSuccess(query, result.content, result.providerName); return; } + 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); } } @@ -724,6 +879,10 @@ module.exports = { hasAnyActiveProviders, runLocalWebSearch, shouldSkipHook, + getActiveProviderIds, + getQueryFingerprint, + getSkipReason, + traceWebSearchEvent, tryExaSearch, tryTavilySearch, tryDuckDuckGoSearch, diff --git a/lib/mcp/ccs-websearch-server.cjs b/lib/mcp/ccs-websearch-server.cjs index 4d891899..c3f7c137 100644 --- a/lib/mcp/ccs-websearch-server.cjs +++ b/lib/mcp/ccs-websearch-server.cjs @@ -1,19 +1,35 @@ #!/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 = 'search'; +const TOOL_NAME = 'WebSearch'; +const TOOL_ALIASES = ['search']; const TOOL_DESCRIPTION = - 'Search the web through CCS-managed providers. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.'; + 'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, 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(); @@ -33,7 +49,8 @@ function getTools() { properties: { query: { type: 'string', - description: 'The search query to run against CCS WebSearch providers.', + 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'], @@ -72,13 +89,36 @@ async function handleToolCall(message) { const id = message.id; const params = message.params || {}; const toolArgs = params.arguments || {}; + const toolName = params.name || ''; + const query = typeof toolArgs.query === 'string' ? toolArgs.query.trim() : ''; + const fingerprint = getQueryFingerprint(query); - if (params.name !== TOOL_NAME) { - writeError(id, -32602, `Unknown tool: ${params.name || ''}`); + 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: [ { @@ -91,20 +131,41 @@ async function handleToolCall(message) { return; } - const query = typeof toolArgs.query === 'string' ? toolArgs.query.trim() : ''; if (!query) { - writeError(id, -32602, 'Tool "search" requires a non-empty string 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.' @@ -128,6 +189,14 @@ async function handleMessage(message) { 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: { @@ -145,7 +214,20 @@ async function handleMessage(message) { writeResponse(message.id, {}); return; case 'tools/list': - writeResponse(message.id, { tools: getTools() }); + 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); @@ -157,6 +239,27 @@ async function handleMessage(message) { } } +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) { const headerEnd = inputBuffer.indexOf('\r\n\r\n'); @@ -204,4 +307,15 @@ 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(); diff --git a/src/ccs.ts b/src/ccs.ts index 32d94649..d427d234 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -29,6 +29,7 @@ import { getWebSearchHookEnv, 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'; @@ -1053,11 +1054,19 @@ async function main(): Promise { return; } - execClaude( - claudeCli, - ['--settings', expandedSettingsPath, ...appendThirdPartyWebSearchToolArgs(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 diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index e8e07862..74efd266 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -56,6 +56,7 @@ import { ensureWebSearchMcpOrThrow, displayWebSearchStatus, appendThirdPartyWebSearchToolArgs, + createWebSearchTraceContext, } from '../../utils/websearch-manager'; import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader'; import { installImageAnalyzerHook } from '../../utils/hooks'; @@ -1034,31 +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: provider, + profileType: 'settings', + settingsPath, + }); + const tracedEnv = { ...env, ...traceEnv }; if (needsShell) { - const cmdString = [ - claudeCli, - '--settings', - settingsPath, - ...appendThirdPartyWebSearchToolArgs(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, ...appendThirdPartyWebSearchToolArgs(claudeArgs)], - { - stdio: 'inherit', - windowsHide: true, - env, - } - ); + claude = spawn(claudeCli, launchArgs, { + stdio: 'inherit', + windowsHide: true, + env: tracedEnv, + }); } // 12b. Start runtime quota monitor (adaptive polling during session) diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 47a2df5e..d1487cbf 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -17,6 +17,7 @@ import { fail, info, ok, warn } from '../utils/ui'; import { getWebSearchHookEnv, appendThirdPartyWebSearchToolArgs, + createWebSearchTraceContext, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; import { getImageAnalysisHookEnv } from '../utils/hooks'; @@ -181,9 +182,18 @@ export async function executeCopilotProfile( // Spawn Claude CLI return new Promise((resolve) => { - const proc = spawn(claudeCliPath, appendThirdPartyWebSearchToolArgs(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', }); diff --git a/src/delegation/executor/result-aggregator.ts b/src/delegation/executor/result-aggregator.ts index c7415ba6..821313cd 100644 --- a/src/delegation/executor/result-aggregator.ts +++ b/src/delegation/executor/result-aggregator.ts @@ -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(); + + 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 diff --git a/src/delegation/executor/types.ts b/src/delegation/executor/types.ts index 0bb0b922..00162015 100644 --- a/src/delegation/executor/types.ts +++ b/src/delegation/executor/types.ts @@ -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; } /** diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 85265534..2386b6de 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -18,6 +18,13 @@ 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 { + appendThirdPartyWebSearchToolArgs, + appendWebSearchTrace, + createWebSearchTraceContext, + getWebSearchHookEnv, + readWebSearchTraceRecords, +} from '../utils/websearch-manager'; // Re-export types for consumers export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types'; @@ -163,21 +170,32 @@ export class HeadlessExecutor { } } + const launchArgs = appendThirdPartyWebSearchToolArgs(args); + const traceEnv = createWebSearchTraceContext({ + launcher: 'delegation.headless-executor', + args: launchArgs, + cwd, + profile, + profileType: 'settings', + settingsPath, + }); + 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, + traceEnv, }); } @@ -194,9 +212,10 @@ export class HeadlessExecutor { resumeSession: boolean; sessionId: string | null; sessionMgr: SessionManager; + traceEnv?: Record; } ): Promise { - const { cwd, profile, timeout, resumeSession, sessionId, sessionMgr } = ctx; + const { cwd, profile, timeout, resumeSession, sessionId, sessionMgr, traceEnv = {} } = ctx; return new Promise((resolve, reject) => { const startTime = Date.now(); @@ -213,6 +232,9 @@ export class HeadlessExecutor { const cleanEnv = stripClaudeCodeEnv({ ...process.env, ...getClaudeLaunchEnvOverrides(), + ...getWebSearchHookEnv(), + ...traceEnv, + CCS_PROFILE_TYPE: 'settings', }); const proc = spawn(claudeCli, args, { @@ -313,6 +335,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) { diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 1503695a..e78bf4f8 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -70,6 +70,14 @@ export { // 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, diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index 62de05ce..15028178 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -4,6 +4,9 @@ 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 @@ -20,19 +23,34 @@ function mergeToolValues(rawValues: string[], toolName: string): string { return merged.join(','); } +function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } { + const terminatorIndex = args.indexOf('--'); + if (terminatorIndex === -1) { + return { optionArgs: args, trailingArgs: [] }; + } + + return { + optionArgs: args.slice(0, terminatorIndex), + trailingArgs: args.slice(terminatorIndex), + }; +} + +function getImmediateFlagValue(args: string[], index: number): string | null { + const value = args[index + 1]; + if (value === undefined || value === '--' || value.startsWith('--')) { + return null; + } + return value; +} + function hasToolInFlag(args: string[], flag: string, toolName: string): boolean { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === flag) { - for (let cursor = index + 1; cursor < args.length; cursor += 1) { - const value = args[cursor]; - if (value.startsWith('--')) { - break; - } - if (parseToolValue(value).includes(toolName)) { - return true; - } + const value = getImmediateFlagValue(args, index); + if (value && parseToolValue(value).includes(toolName)) { + return true; } continue; } @@ -50,39 +68,86 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean return false; } -export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] { - if (hasToolInFlag(args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) { - return args; - } - +function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; - if (arg === DISALLOWED_TOOLS_FLAG) { - let cursor = index + 1; - const rawValues: string[] = []; - - while (cursor < args.length && !args[cursor].startsWith('--')) { - rawValues.push(args[cursor]); - cursor += 1; + 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 [ - ...args.slice(0, index + 1), - mergeToolValues(rawValues, NATIVE_WEBSEARCH_TOOL), - ...args.slice(cursor), + ...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 [ - ...args.slice(0, index), + ...optionArgs.slice(0, index), `${DISALLOWED_TOOLS_FLAG}=${mergeToolValues([rawValue], NATIVE_WEBSEARCH_TOOL)}`, - ...args.slice(index + 1), + ...optionArgs.slice(index + 1), + ...trailingArgs, ]; } } - return [...args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL]; + 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)); } diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index ac0e9e1b..78fff9ca 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -19,6 +19,13 @@ export function getWebSearchHookEnv(): Record { const wsConfig = getWebSearchConfig(); const env: Record = {}; + if (process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1') { + env.CCS_WEBSEARCH_TRACE = '1'; + } + if (process.env.CCS_WEBSEARCH_TRACE_FILE) { + env.CCS_WEBSEARCH_TRACE_FILE = process.env.CCS_WEBSEARCH_TRACE_FILE; + } + // Skip hook entirely if disabled if (!wsConfig.enabled) { env.CCS_WEBSEARCH_SKIP = '1'; diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts index 5cd4cd27..0d375dfc 100644 --- a/src/utils/websearch/index.ts +++ b/src/utils/websearch/index.ts @@ -67,6 +67,14 @@ export { // Claude launch args export { appendThirdPartyWebSearchToolArgs } from './claude-tool-args'; +// Trace helpers +export { + appendWebSearchTrace, + createWebSearchTraceContext, + isWebSearchTraceEnabled, + readWebSearchTraceRecords, +} from './trace'; + // Status and Readiness export { getWebSearchCliProviders, diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts index ff45b31f..a86e0900 100644 --- a/src/utils/websearch/mcp-installer.ts +++ b/src/utils/websearch/mcp-installer.ts @@ -10,6 +10,7 @@ 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'; @@ -154,10 +155,12 @@ function removeManagedServerConfig(configPath: string): boolean { 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') @@ -168,6 +171,7 @@ export function installWebSearchMcpServer(): boolean { 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}`)); } @@ -181,6 +185,7 @@ export function installWebSearchMcpServer(): boolean { const serverPath = getWebSearchMcpServerPath(); if (hasMatchingContents(sourcePath, serverPath)) { + appendWebSearchTrace('websearch_mcp_install_ready', { serverPath }); return true; } @@ -190,8 +195,13 @@ export function installWebSearchMcpServer(): boolean { fs.copyFileSync(sourcePath, tempPath); fs.chmodSync(tempPath, 0o755); fs.renameSync(tempPath, serverPath); + 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}`)); } @@ -206,6 +216,7 @@ export function installWebSearchMcpServer(): boolean { export function ensureWebSearchMcpConfig(): boolean { const wsConfig = getWebSearchConfig(); if (!wsConfig.enabled) { + appendWebSearchTrace('websearch_mcp_config_skipped', { reason: 'disabled' }); return false; } @@ -214,6 +225,7 @@ export function ensureWebSearchMcpConfig(): boolean { 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')); } @@ -239,6 +251,7 @@ export function ensureWebSearchMcpConfig(): boolean { currentConfig !== null && JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig) ) { + appendWebSearchTrace('websearch_mcp_config_ready', { configPath: claudeUserConfigPath }); return true; } @@ -252,11 +265,17 @@ export function ensureWebSearchMcpConfig(): boolean { 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}`)); } @@ -267,18 +286,25 @@ export function ensureWebSearchMcpConfig(): boolean { export function ensureWebSearchMcp(): boolean { const wsConfig = getWebSearchConfig(); if (!wsConfig.enabled) { + appendWebSearchTrace('websearch_mcp_ensure_skipped', { reason: 'disabled' }); return false; } - return installWebSearchMcpServer() && ensureWebSearchMcpConfig(); + 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; } - return new InstanceManager().syncMcpServers(claudeConfigDir); + const synced = new InstanceManager().syncMcpServers(claudeConfigDir); + appendWebSearchTrace('websearch_mcp_sync_result', { claudeConfigDir, synced }); + return synced; } export function uninstallWebSearchMcpServer(): boolean { diff --git a/src/utils/websearch/trace.ts b/src/utils/websearch/trace.ts new file mode 100644 index 00000000..86b4ce2c --- /dev/null +++ b/src/utils/websearch/trace.ts @@ -0,0 +1,223 @@ +/** + * 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 path from 'path'; +import { getCcsDir } from '../config-manager'; + +const TRACE_FILE_NAME = 'websearch-trace.jsonl'; +const SAFE_SYSTEM_TRACE_PREFIXES = ['/tmp/', '/var/log/']; +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 getTraceFilePath(env: NodeJS.ProcessEnv): string { + const configured = env.CCS_WEBSEARCH_TRACE_FILE?.trim(); + if (!configured) { + return path.join(getCcsDir(), 'logs', TRACE_FILE_NAME); + } + + const resolved = path.resolve(configured); + const home = env.HOME || env.USERPROFILE || ''; + const normalizedHome = home ? `${path.resolve(home)}${path.sep}` : ''; + const normalizedCcsDir = `${path.resolve(getCcsDir())}${path.sep}`; + + if ( + resolved.startsWith(normalizedCcsDir) || + (normalizedHome && resolved.startsWith(normalizedHome)) || + SAFE_SYSTEM_TRACE_PREFIXES.some((prefix) => resolved.startsWith(prefix)) + ) { + return resolved; + } + + return 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 = {}, + 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> { + 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) + .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 { + 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 { + const env = params.env ?? process.env; + if (!isWebSearchTraceEnabled(env)) { + return {}; + } + + const launchId = buildLaunchId(); + const traceEnv: Record = { + CCS_WEBSEARCH_TRACE: '1', + CCS_WEBSEARCH_TRACE_LAUNCH_ID: launchId, + CCS_WEBSEARCH_TRACE_LAUNCHER: params.launcher, + }; + + if (env.CCS_WEBSEARCH_TRACE_FILE) { + traceEnv.CCS_WEBSEARCH_TRACE_FILE = env.CCS_WEBSEARCH_TRACE_FILE; + } + + 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; +} diff --git a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts index 9b0e35df..79f4fdaf 100644 --- a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { spawn } from 'child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -73,8 +73,15 @@ function collectResponses( }); } +function waitForClose(child: ReturnType): Promise { + return new Promise((resolve, reject) => { + child.once('close', (code) => resolve(code)); + child.once('error', reject); + }); +} + describe('ccs-websearch MCP server', () => { - it('lists the CCS search tool and returns provider-backed results', async () => { + 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 = ` @@ -124,7 +131,7 @@ describe('ccs-websearch MCP server', () => { jsonrpc: '2.0', id: 3, method: 'tools/call', - params: { name: 'search', arguments: { query: 'btc price' } }, + params: { name: 'WebSearch', arguments: { query: 'btc price' } }, }) ); @@ -135,15 +142,16 @@ describe('ccs-websearch MCP server', () => { expect(toolsList?.result).toEqual({ tools: [ { - name: 'search', + name: 'WebSearch', description: - 'Search the web through CCS-managed providers. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.', + 'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.', inputSchema: { type: 'object', properties: { query: { type: 'string', - description: 'The search query to run against CCS WebSearch providers.', + 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'], @@ -165,6 +173,75 @@ describe('ccs-websearch MCP server', () => { } }); + 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 = ` + Example title + Example snippet + `.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: { @@ -200,4 +277,101 @@ describe('ccs-websearch MCP server', () => { 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 = ` + Example title + Example snippet + `.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); + + 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 }); + }); }); diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index e731e197..2c25302e 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; @@ -193,4 +193,78 @@ describe('websearch-transformer hook helpers', () => { ); 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 = ` + Example title + Example snippet + `.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); + + 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 }); + } + }); }); diff --git a/tests/unit/targets/settings-profile-websearch-launch.test.ts b/tests/unit/targets/settings-profile-websearch-launch.test.ts index e0ab84f4..045757ef 100644 --- a/tests/unit/targets/settings-profile-websearch-launch.test.ts +++ b/tests/unit/targets/settings-profile-websearch-launch.test.ts @@ -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> { + return fs + .readFileSync(tracePath, 'utf8') + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + describe('settings profile WebSearch launch', () => { let tmpHome = ''; let ccsDir = ''; @@ -119,7 +130,40 @@ exit 0 expect(result.status).toBe(0); expect(result.stderr).not.toContain('could not prepare the local WebSearch tool'); expect(fs.existsSync(claudeArgsLogPath)).toBe(true); - expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain('--disallowedTools'); - expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain('WebSearch'); + 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); }); }); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 0d10ebe0..38109ecb 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -21,12 +21,14 @@ type SpawnCall = { options: Record | 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,47 @@ 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'); + process.env.CCS_CLAUDE_PATH = 'claude'; + + 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 launch = spawnCalls[0]; + expect(launch.args).toContain('--disallowedTools'); + expect(launch.args).toContain('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(); + }); + + 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'); + }); }); diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts index 6eb2367c..9f902b80 100644 --- a/tests/unit/utils/websearch/claude-tool-args.test.ts +++ b/tests/unit/utils/websearch/claude-tool-args.test.ts @@ -1,25 +1,47 @@ 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 when no tool flags are present', () => { + 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 when WebSearch is already disallowed', () => { - expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools', 'WebSearch'])).toEqual( - ['smoke', '--disallowedTools', 'WebSearch'] - ); + 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'] - ); + 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', () => { @@ -27,6 +49,8 @@ describe('appendThirdPartyWebSearchToolArgs', () => { 'smoke', '--disallowedTools', 'Read,WebSearch', + '--append-system-prompt', + STEERING_PROMPT, ]); }); @@ -34,6 +58,75 @@ describe('appendThirdPartyWebSearchToolArgs', () => { 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, ]); }); }); From cae5b710b86a6bd985424cce028a502265051d09 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 23:02:20 -0400 Subject: [PATCH 23/33] fix(websearch): close review gaps in managed runtime --- README.md | 8 +-- docs/project-overview-pdr.md | 2 +- docs/websearch.md | 33 +++++------ lib/hooks/websearch-transformer.cjs | 34 ++++++++---- src/cliproxy/executor/index.ts | 5 +- src/delegation/headless-executor.ts | 37 ++++++++++++- src/management/instance-manager.ts | 13 ++++- src/utils/websearch/hook-env.ts | 6 +- src/utils/websearch/mcp-installer.ts | 14 ++++- src/utils/websearch/trace.ts | 41 +++++++++----- .../unit/hooks/websearch-transformer.test.ts | 55 ++++++++++++++++++- tests/unit/instance-manager-mcp-sync.test.ts | 47 ++++++++++++++++ .../settings-profile-websearch-launch.test.ts | 12 ++++ .../utils/claudecode-env-stripping.test.ts | 29 +++++++++- .../utils/websearch/mcp-installer.test.ts | 23 +++++++- 15 files changed, 299 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 7de245fd..a75d3d6f 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ 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, expose `ccs-websearch.WebSearch`, 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. +> **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. @@ -633,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 now provisions a first-class local `ccs-websearch` MCP tool, disables native `WebSearch` on third-party launches, and routes search through real local providers instead of surfacing a denied native-tool call. +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 | CCS local MCP `WebSearch` tool | +| Third-party profiles | CCS local MCP `WebSearch` tool when available; otherwise Bash/network fallback | ### Local Search Backend Chain -For third-party profiles, Claude uses the managed `ccs-websearch.WebSearch` MCP tool. The tool is intentionally named to match the native `WebSearch` concept, which helps Claude prefer it over ad hoc Bash or `curl` fetches. CCS then routes that request through deterministic search providers in this order: +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 | |----------|----------|-------|-------| diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 5680a2d2..9df5d38a 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -94,7 +94,7 @@ CCS provides: ### FR-007: WebSearch Fallback - 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 so Claude uses the CCS-owned path +- 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 diff --git a/docs/websearch.md b/docs/websearch.md index a3976e38..5421d0eb 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -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 provisioning a first-class local MCP tool, suppressing native `WebSearch` for those launches, appending a short launch-time steering hint, 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 @@ -26,18 +26,19 @@ Third-party profiles cannot execute Anthropic's server-side WebSearch because th │ │ │ │ └── Third-party Profile? → native WebSearch disabled │ │ │ │ -│ └── CCS MCP tool │ -│ ccs-websearch.WebSearch │ -│ │ │ -│ ├── 1. Exa │ -│ ├── 2. Tavily │ -│ ├── 3. Brave │ -│ ├── 4. DuckDuckGo│ -│ └── 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 │ └──────────────────────────────────────────────────────────────┘ ``` @@ -52,7 +53,7 @@ The previous design asked another model CLI to perform web search and summarize 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. -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. +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. @@ -125,10 +126,10 @@ Note: `enabled: false` stops provisioning the managed local `ccs-websearch.WebSe | `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` | Disable the CCS local WebSearch runtime for the current process | +| `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/`, `/tmp`, or `/var/log`) | +| `CCS_WEBSEARCH_TRACE_FILE` | Override the trace file path (must stay inside `~/.ccs/`, your system temp directory, or `/var/log`) | ## Managed Runtime Files diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 6b8e023d..42d2022b 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -17,6 +17,7 @@ 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'; @@ -88,26 +89,35 @@ function isTraceEnabled() { return process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1'; } -function getTraceFilePath() { - const fallback = path.join(getCcsDirPath(), 'logs', 'websearch-trace.jsonl'); +function normalizeSafePrefix(inputPath) { + return `${path.resolve(inputPath)}${path.sep}`; +} + +function getSafeTracePrefixes() { + return [ + normalizeSafePrefix(path.join(getCcsDirPath(), 'logs')), + normalizeSafePrefix(os.tmpdir()), + normalizeSafePrefix('/var/log'), + ]; +} + +function getAllowedTraceFileOverride() { const configured = (process.env.CCS_WEBSEARCH_TRACE_FILE || '').trim(); if (!configured) { - return fallback; + return null; } const resolved = path.resolve(configured); - const safePrefixes = [ - `${path.resolve(getCcsDirPath())}${path.sep}`, - `${path.resolve(process.env.HOME || process.env.USERPROFILE || process.cwd())}${path.sep}`, - '/tmp/', - '/var/log/', - ]; - - if (safePrefixes.some((prefix) => resolved.startsWith(prefix))) { + if (getSafeTracePrefixes().some((prefix) => resolved.startsWith(prefix))) { return resolved; } - return fallback; + return null; +} + +function getTraceFilePath() { + const fallback = path.join(getCcsDirPath(), 'logs', 'websearch-trace.jsonl'); + return getAllowedTraceFileOverride() || fallback; } function traceWebSearchEvent(event, payload = {}) { diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 74efd266..6d08788f 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -1039,9 +1039,10 @@ export async function execClaudeWithCLIProxy( const traceEnv = createWebSearchTraceContext({ launcher: 'cliproxy.executor', args: launchArgs, - profile: provider, - profileType: 'settings', + profile: cfg.profileName || provider, + profileType: 'cliproxy', settingsPath, + claudeConfigDir: inheritedClaudeConfigDir, }); const tracedEnv = { ...env, ...traceEnv }; if (needsShell) { diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 2386b6de..074016b9 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -18,12 +18,15 @@ 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 @@ -87,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); @@ -132,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) @@ -178,6 +198,7 @@ export class HeadlessExecutor { profile, profileType: 'settings', settingsPath, + claudeConfigDir: inheritedClaudeConfigDir, }); if (process.env.CCS_DEBUG) { @@ -195,6 +216,7 @@ export class HeadlessExecutor { resumeSession, sessionId, sessionMgr, + claudeConfigDir: inheritedClaudeConfigDir, traceEnv, }); } @@ -212,10 +234,20 @@ export class HeadlessExecutor { resumeSession: boolean; sessionId: string | null; sessionMgr: SessionManager; + claudeConfigDir?: string; traceEnv?: Record; } ): Promise { - const { cwd, profile, timeout, resumeSession, sessionId, sessionMgr, traceEnv = {} } = ctx; + const { + cwd, + profile, + timeout, + resumeSession, + sessionId, + sessionMgr, + claudeConfigDir, + traceEnv = {}, + } = ctx; return new Promise((resolve, reject) => { const startTime = Date.now(); @@ -234,6 +266,7 @@ export class HeadlessExecutor { ...getClaudeLaunchEnvOverrides(), ...getWebSearchHookEnv(), ...traceEnv, + ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), CCS_PROFILE_TYPE: 'settings', }); diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 08a3552d..776ebf98 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -14,6 +14,8 @@ 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) */ @@ -235,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) : {}; - 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', diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index 78fff9ca..d5e69018 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -7,6 +7,7 @@ */ import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { resolveAllowedWebSearchTraceFile } from './trace'; /** * Get environment variables for WebSearch hook configuration. @@ -22,8 +23,9 @@ export function getWebSearchHookEnv(): Record { if (process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1') { env.CCS_WEBSEARCH_TRACE = '1'; } - if (process.env.CCS_WEBSEARCH_TRACE_FILE) { - env.CCS_WEBSEARCH_TRACE_FILE = process.env.CCS_WEBSEARCH_TRACE_FILE; + const traceFileOverride = resolveAllowedWebSearchTraceFile(process.env); + if (traceFileOverride) { + env.CCS_WEBSEARCH_TRACE_FILE = traceFileOverride; } // Skip hook entirely if disabled diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts index a86e0900..8466b08e 100644 --- a/src/utils/websearch/mcp-installer.ts +++ b/src/utils/websearch/mcp-installer.ts @@ -194,7 +194,19 @@ export function installWebSearchMcpServer(): boolean { try { fs.copyFileSync(sourcePath, tempPath); fs.chmodSync(tempPath, 0o755); - fs.renameSync(tempPath, serverPath); + 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) { diff --git a/src/utils/websearch/trace.ts b/src/utils/websearch/trace.ts index 86b4ce2c..6f4f9880 100644 --- a/src/utils/websearch/trace.ts +++ b/src/utils/websearch/trace.ts @@ -7,11 +7,11 @@ */ 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 SAFE_SYSTEM_TRACE_PREFIXES = ['/tmp/', '/var/log/']; const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; const DISALLOWED_TOOLS_FLAG = '--disallowedTools'; const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; @@ -75,26 +75,36 @@ function hasExactFlagValue(args: string[], flag: string, expectedValue: string): return false; } -function getTraceFilePath(env: NodeJS.ProcessEnv): string { +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 path.join(getCcsDir(), 'logs', TRACE_FILE_NAME); + return null; } const resolved = path.resolve(configured); - const home = env.HOME || env.USERPROFILE || ''; - const normalizedHome = home ? `${path.resolve(home)}${path.sep}` : ''; - const normalizedCcsDir = `${path.resolve(getCcsDir())}${path.sep}`; - - if ( - resolved.startsWith(normalizedCcsDir) || - (normalizedHome && resolved.startsWith(normalizedHome)) || - SAFE_SYSTEM_TRACE_PREFIXES.some((prefix) => resolved.startsWith(prefix)) - ) { + if (getSafeTracePrefixes().some((prefix) => resolved.startsWith(prefix))) { return resolved; } - return path.join(getCcsDir(), 'logs', TRACE_FILE_NAME); + 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 { @@ -197,8 +207,9 @@ export function createWebSearchTraceContext(params: { CCS_WEBSEARCH_TRACE_LAUNCHER: params.launcher, }; - if (env.CCS_WEBSEARCH_TRACE_FILE) { - traceEnv.CCS_WEBSEARCH_TRACE_FILE = env.CCS_WEBSEARCH_TRACE_FILE; + const traceFileOverride = resolveAllowedWebSearchTraceFile(env); + if (traceFileOverride) { + traceEnv.CCS_WEBSEARCH_TRACE_FILE = traceFileOverride; } appendWebSearchTrace( diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 2c25302e..9c9f51ee 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; @@ -267,4 +267,57 @@ describe('websearch-transformer hook helpers', () => { 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 = ` + Example title + Example snippet + `.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 }); + } + }); }); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 8eec45a7..51d6f1b0 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -144,6 +144,53 @@ 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': { command: 'node', args: ['/global/server.cjs'] }, + 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': { command: 'node', args: ['/old/server.cjs'] }, + 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; + }; + expect(instanceContent.mcpServers).toEqual({ + 'ccs-websearch': { command: 'node', args: ['/global/server.cjs'] }, + 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(() => {}); diff --git a/tests/unit/targets/settings-profile-websearch-launch.test.ts b/tests/unit/targets/settings-profile-websearch-launch.test.ts index 045757ef..6064575a 100644 --- a/tests/unit/targets/settings-profile-websearch-launch.test.ts +++ b/tests/unit/targets/settings-profile-websearch-launch.test.ts @@ -115,6 +115,18 @@ exit 0 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); + }); + it('keeps launch non-fatal when WebSearch is disabled', () => { if (process.platform === 'win32') return; diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 38109ecb..196a1f1c 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -336,9 +336,25 @@ describe('CLAUDECODE environment stripping', () => { 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, }); @@ -347,12 +363,23 @@ describe('CLAUDECODE environment stripping', () => { expect(spawnCalls.length).toBeGreaterThan(0); const launch = spawnCalls[0]; expect(launch.args).toContain('--disallowedTools'); - expect(launch.args).toContain('WebSearch'); + 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; + }; + expect(claudeUserConfig.mcpServers?.['ccs-websearch']).toEqual({ + command: 'node', + args: [path.join(ccsDir, 'mcp', 'ccs-websearch-server.cjs')], + }); }); it('headless executor propagates a WebSearch trace launch id when tracing is enabled', async () => { diff --git a/tests/unit/utils/websearch/mcp-installer.test.ts b/tests/unit/utils/websearch/mcp-installer.test.ts index 53b3b10b..45aaf4df 100644 --- a/tests/unit/utils/websearch/mcp-installer.test.ts +++ b/tests/unit/utils/websearch/mcp-installer.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'bun:test'; +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'; @@ -39,6 +39,8 @@ describe('ensureWebSearchMcp', () => { } afterEach(() => { + mock.restore(); + if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { @@ -158,4 +160,23 @@ describe('ensureWebSearchMcp', () => { 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); + }); }); From 7f83e041b7fa8907a5d9484acd21ea88edac67e7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 31 Mar 2026 14:22:11 -0400 Subject: [PATCH 24/33] feat(websearch): add managed mcp runtime and provider cooldowns - switch ccs-websearch MCP to stdio-first framing and keep legacy compatibility - add cooldown persistence and bounded retries for provider failures - surface cooldown status in readiness output and regression tests --- lib/hooks/websearch-transformer.cjs | 372 +++++++++++++++++- lib/mcp/ccs-websearch-server.cjs | 58 ++- src/utils/websearch/mcp-installer.ts | 13 +- src/utils/websearch/status.ts | 92 ++++- .../hooks/ccs-websearch-mcp-server.test.ts | 97 ++++- .../unit/hooks/websearch-transformer.test.ts | 368 ++++++++++++++++- tests/unit/instance-manager-mcp-sync.test.ts | 21 +- .../utils/claudecode-env-stripping.test.ts | 4 +- .../utils/websearch/mcp-installer.test.ts | 39 +- tests/unit/utils/websearch/status.test.ts | 114 +++++- 10 files changed, 1115 insertions(+), 63 deletions(-) diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 42d2022b..1165bc36 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -30,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 @@ -101,6 +108,111 @@ function getSafeTracePrefixes() { ]; } +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) { @@ -146,6 +258,42 @@ function traceWebSearchEvent(event, payload = {}) { } } +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 { @@ -371,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')), }; } @@ -422,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(); @@ -474,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(); @@ -511,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(); @@ -720,8 +885,174 @@ function getConfiguredProviders() { ]; } +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) => provider.available()); + return getConfiguredProviders().filter((provider) => !getProviderCooldown(provider.id) && provider.available()); } function getActiveProviderIds() { @@ -733,8 +1064,29 @@ function hasAnyActiveProviders() { } async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { - const activeProviders = getActiveProviders(); 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'}` @@ -757,13 +1109,7 @@ async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { const errors = []; for (const provider of activeProviders) { debug(`Trying ${provider.name}`); - traceWebSearchEvent('websearch_provider_attempt', { - source: 'provider', - providerId: provider.id, - providerName: provider.name, - ...fingerprint, - }); - const result = await provider.fn(query, timeoutSec); + const result = await runProviderWithPolicy(provider, query, timeoutSec, fingerprint); if (result.success) { traceWebSearchEvent('websearch_provider_success', { source: 'provider', @@ -890,8 +1236,10 @@ module.exports = { runLocalWebSearch, shouldSkipHook, getActiveProviderIds, + classifyProviderFailure, getQueryFingerprint, getSkipReason, + parseRetryAfterSeconds, traceWebSearchEvent, tryExaSearch, tryTavilySearch, diff --git a/lib/mcp/ccs-websearch-server.cjs b/lib/mcp/ccs-websearch-server.cjs index c3f7c137..13880600 100644 --- a/lib/mcp/ccs-websearch-server.cjs +++ b/lib/mcp/ccs-websearch-server.cjs @@ -61,9 +61,7 @@ function getTools() { } function writeMessage(message) { - const body = Buffer.from(JSON.stringify(message), 'utf8'); - process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); - process.stdout.write(body); + process.stdout.write(`${JSON.stringify(message)}\n`); } function writeResponse(id, result) { @@ -262,26 +260,46 @@ function writeSessionSummary(exitCodeOrSignal) { function parseMessages() { while (true) { - const headerEnd = inputBuffer.indexOf('\r\n\r\n'); - if (headerEnd === -1) { - return; - } + let body; + const startsWithLegacyHeaders = inputBuffer + .slice(0, Math.min(inputBuffer.length, 32)) + .toString('utf8') + .toLowerCase() + .startsWith('content-length:'); - const headerText = inputBuffer.slice(0, headerEnd).toString('utf8'); - const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i); - if (!contentLengthMatch) { - inputBuffer = Buffer.alloc(0); - return; - } + if (startsWithLegacyHeaders) { + const headerEnd = inputBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return; + } - const contentLength = Number.parseInt(contentLengthMatch[1], 10); - const messageEnd = headerEnd + 4 + contentLength; - if (inputBuffer.length < messageEnd) { - 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 body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8'); - inputBuffer = inputBuffer.slice(messageEnd); + 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 { diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts index 8466b08e..5f7260e9 100644 --- a/src/utils/websearch/mcp-installer.ts +++ b/src/utils/websearch/mcp-installer.ts @@ -20,6 +20,13 @@ interface ClaudeUserConfig { [key: string]: unknown; } +interface ManagedWebSearchMcpConfig { + type: 'stdio'; + command: 'node'; + args: [string]; + env: Record; +} + function getCcsMcpDir(): string { return path.join(getCcsDir(), 'mcp'); } @@ -91,9 +98,11 @@ function readClaudeUserConfig(configPath: string): ClaudeUserConfig | 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 { @@ -252,9 +261,11 @@ export function ensureWebSearchMcpConfig(): boolean { config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) ? (config.mcpServers as Record) : {}; - const desiredServerConfig = { + const desiredServerConfig: ManagedWebSearchMcpConfig = { + type: 'stdio', command: 'node', args: [getWebSearchMcpServerPath()], + env: {}, }; const currentConfig = existingServers[WEBSEARCH_MCP_SERVER_NAME]; diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index b812f7d9..ecb5ce94 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -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 { + try { + const statePath = getProviderStatePath(); + if (!existsSync(statePath)) { + return {}; + } + + const parsed = JSON.parse(readFileSync(statePath, 'utf8')) as { + cooldowns?: Record; + }; + const nextCooldowns: Record = {}; + + 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, + 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) + ); } /** diff --git a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts index 79f4fdaf..4229687f 100644 --- a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts @@ -7,6 +7,10 @@ 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}`; } @@ -22,26 +26,47 @@ function collectResponses( function tryParse(): void { while (true) { - const headerEnd = buffer.indexOf('\r\n\r\n'); - if (headerEnd === -1) { - return; + 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; + } } - 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; - } - - const body = buffer.slice(headerEnd + 4, messageEnd).toString('utf8'); - buffer = buffer.slice(messageEnd); responses.push(JSON.parse(body) as Record); if (responses.length >= expectedCount) { @@ -374,4 +399,40 @@ describe('ccs-websearch MCP server', () => { 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(); + } + }); }); diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 9c9f51ee..43ebde85 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'bun:test'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +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'; @@ -29,11 +36,18 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { url: string; description: string; }>; + classifyProviderFailure: (result: { + error?: string; + retryAfterSec?: number | null; + statusCode?: number | null; + success?: boolean; + }) => Record; formatStructuredSearchResults: ( query: string, providerName: string, results: Array<{ title: string; url: string; description: string }> ) => string; + parseRetryAfterSeconds: (rawValue: string) => number | null; }; function runHookWithMockedFetch(mode: 'success' | 'failure') { @@ -76,6 +90,42 @@ function runHookWithMockedFetch(mode: 'success' | 'failure') { } 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 = ` Example title @@ -320,4 +370,320 @@ describe('websearch-transformer hook helpers', () => { 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 = ` + Fallback title + Fallback snippet + `.trim(); + + writeFileSync( + preloadPath, + ` +const fs = require('fs'); +const requestLogPath = ${JSON.stringify(requestLogPath)}; +const html = ${JSON.stringify(html)}; +function record(url) { + const requests = fs.existsSync(requestLogPath) + ? JSON.parse(fs.readFileSync(requestLogPath, 'utf8')) + : []; + requests.push(String(url)); + fs.writeFileSync(requestLogPath, JSON.stringify(requests), 'utf8'); +} +global.fetch = async (url) => { + const 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; + }; + 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); + 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 = ` + Cooldown title + Cooldown snippet + `.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); + 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); + 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 }); + } + }); }); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 51d6f1b0..27d5dbd0 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -150,7 +150,12 @@ describe('InstanceManager MCP sync', () => { JSON.stringify( { mcpServers: { - 'ccs-websearch': { command: 'node', args: ['/global/server.cjs'] }, + 'ccs-websearch': { + type: 'stdio', + command: 'node', + args: ['/global/server.cjs'], + env: {}, + }, shared: { command: 'global-shared' }, }, }, @@ -168,7 +173,12 @@ describe('InstanceManager MCP sync', () => { JSON.stringify( { mcpServers: { - 'ccs-websearch': { command: 'node', args: ['/old/server.cjs'] }, + 'ccs-websearch': { + type: 'stdio', + command: 'node', + args: ['/old/server.cjs'], + env: {}, + }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }, @@ -185,7 +195,12 @@ describe('InstanceManager MCP sync', () => { mcpServers: Record; }; expect(instanceContent.mcpServers).toEqual({ - 'ccs-websearch': { command: 'node', args: ['/global/server.cjs'] }, + 'ccs-websearch': { + type: 'stdio', + command: 'node', + args: ['/global/server.cjs'], + env: {}, + }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 196a1f1c..acd83eb7 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -374,11 +374,13 @@ describe('CLAUDECODE environment stripping', () => { const claudeUserConfig = JSON.parse( fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8') ) as { - mcpServers?: Record; + mcpServers?: Record; }; expect(claudeUserConfig.mcpServers?.['ccs-websearch']).toEqual({ + type: 'stdio', command: 'node', args: [path.join(ccsDir, 'mcp', 'ccs-websearch-server.cjs')], + env: {}, }); }); diff --git a/tests/unit/utils/websearch/mcp-installer.test.ts b/tests/unit/utils/websearch/mcp-installer.test.ts index 45aaf4df..5fb07d34 100644 --- a/tests/unit/utils/websearch/mcp-installer.test.ts +++ b/tests/unit/utils/websearch/mcp-installer.test.ts @@ -38,6 +38,15 @@ describe('ensureWebSearchMcp', () => { ); } + function getManagedConfig() { + return { + type: 'stdio', + command: 'node', + args: [getWebSearchMcpServerPath()], + env: {}, + }; + } + afterEach(() => { mock.restore(); @@ -79,14 +88,36 @@ describe('ensureWebSearchMcp', () => { expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(true); const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { - mcpServers: Record; + mcpServers: Record; }; expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] }); - expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual({ - command: 'node', - args: [getWebSearchMcpServerPath()], + 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', () => { diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index 3bdd3c23..b696278d 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -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 & Pick): 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 }); + } + }); }); From 8237c38c995252e7bc78f9c6e2d86c0bb3cfbe7d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 31 Mar 2026 19:10:19 +0000 Subject: [PATCH 25/33] chore(release): 7.63.0-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7711f339..67ab369a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.63.0-dev.5", + "version": "7.63.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 014a844844c76fe5dfce7763ed62e071ee063f58 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 31 Mar 2026 23:38:23 -0400 Subject: [PATCH 26/33] fix: probe Codex config override support directly --- src/targets/codex-detector.ts | 19 ++++++- tests/unit/targets/codex-detector.test.ts | 52 +++++++++++++++++++ .../targets/codex-runtime-integration.test.ts | 39 +++++++++++++- .../codex-settings-bridge-launch.test.ts | 7 +++ .../codex-dashboard-service.test.ts | 22 +++++++- 5 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/targets/codex-detector.ts b/src/targets/codex-detector.ts index faf52a35..31e72423 100644 --- a/src/targets/codex-detector.ts +++ b/src/targets/codex-detector.ts @@ -5,6 +5,7 @@ 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 runCodexProbe(codexPath: string, args: string[]): string | undefined { const isWindows = process.platform === 'win32'; @@ -49,9 +50,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 '); +} + +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 ') ? [CODEX_CONFIG_OVERRIDE_FEATURE] : []; + return codexHelpAdvertisesConfigOverrides(helpText) ? [CODEX_CONFIG_OVERRIDE_FEATURE] : []; } export function detectCodexCli(): string | null { diff --git a/tests/unit/targets/codex-detector.test.ts b/tests/unit/targets/codex-detector.test.ts index c392bcf7..69f71db1 100644 --- a/tests/unit/targets/codex-detector.test.ts +++ b/tests/unit/targets/codex-detector.test.ts @@ -72,4 +72,56 @@ describe('codex-detector', () => { execFileSyncSpy.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 \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(); + }); }); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 7e06621d..92539e48 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -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); } @@ -324,6 +334,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 \\n', } @@ -333,7 +344,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 \\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', () => { diff --git a/tests/unit/targets/codex-settings-bridge-launch.test.ts b/tests/unit/targets/codex-settings-bridge-launch.test.ts index 814d4ce5..9e520ae0 100644 --- a/tests/unit/targets/codex-settings-bridge-launch.test.ts +++ b/tests/unit/targets/codex-settings-bridge-launch.test.ts @@ -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 diff --git a/tests/unit/web-server/codex-dashboard-service.test.ts b/tests/unit/web-server/codex-dashboard-service.test.ts index 2700622b..7dc64be6 100644 --- a/tests/unit/web-server/codex-dashboard-service.test.ts +++ b/tests/unit/web-server/codex-dashboard-service.test.ts @@ -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 \n -p, --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 \n' }); + writeCodexStub({ + helpText: ' -p, --profile \n', + supportsConfigOverrides: false, + }); fs.writeFileSync( path.join(codexHome, 'config.toml'), `profile = "missing-profile" From b6717c55295dadeeff87ec356bc19d7b1b1d3733 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 00:03:10 -0400 Subject: [PATCH 27/33] fix: bootstrap explicit CODEX_HOME for ccsxp --- src/targets/codex-adapter.ts | 32 ++++++++++++ .../targets/codex-runtime-integration.test.ts | 52 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index d4491288..0199dfc1 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -83,6 +83,32 @@ function normalizeCodexReasoningOverride(value: string | number | undefined): st ); } +function ensureExplicitCodexHomeDir(env: NodeJS.ProcessEnv): string | undefined { + const codexHome = env.CODEX_HOME?.trim(); + if (!codexHome) { + return undefined; + } + + try { + fs.mkdirSync(codexHome, { recursive: true }); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code !== 'EEXIST') { + return `[X] Unable to initialize CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`; + } + } + + try { + if (!fs.statSync(codexHome).isDirectory()) { + return `[X] CODEX_HOME path is not a directory: ${codexHome}`; + } + return undefined; + } catch (err) { + const error = err as NodeJS.ErrnoException; + return `[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 +233,12 @@ export class CodexAdapter implements TargetAdapter { return exitWithCleanup(1); } + const codexHomeInitError = ensureExplicitCodexHomeDir(env); + if (codexHomeInitError) { + console.error(codexHomeInitError); + return exitWithCleanup(1); + } + const isWindows = process.platform === 'win32'; const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath); const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 92539e48..83723f67 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -270,6 +270,58 @@ process.exit(0); ]); }); + it('creates an explicit CODEX_HOME directory before launching native Codex', () => { + if (process.platform === 'win32') return; + + const freshCodexHome = path.join(tmpHome, 'fresh-codex-home'); + const result = runCcsxpAlias(['--version'], { + ...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([['--version']]); + expect(readLoggedCodexEnv(codexEnvLogPath)).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 CODEX_HOME points to a file', () => { + if (process.platform === 'win32') return; + + const invalidCodexHome = path.join(tmpHome, 'codex-home-file'); + fs.writeFileSync(invalidCodexHome, 'not-a-directory'); + + const result = runCcsxpAlias(['--version'], { + ...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([]); + }); + it('keeps ccsxp pinned to native Codex even when a user passes another --target override', () => { if (process.platform === 'win32') return; From ca54bad2aa774c9dc3de9429d3e1440d179796a7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 01:39:14 -0400 Subject: [PATCH 28/33] fix(codex): harden native runtime detection --- package.json | 1 + src/targets/codex-adapter.ts | 82 ++++++++++++++--- src/targets/codex-detector.ts | 23 +++-- .../services/codex-dashboard-service.ts | 21 +++-- tests/npm/cross-platform.test.js | 5 + tests/unit/targets/codex-detector.test.ts | 14 +++ .../targets/codex-runtime-integration.test.ts | 92 ++++++++++++++++--- .../codex-dashboard-service.test.ts | 6 ++ .../compatible-cli/codex-overview-tab.tsx | 80 ++++++++++------ 9 files changed, 256 insertions(+), 68 deletions(-) diff --git a/package.json b/package.json index 67ab369a..854a1d37 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "ui:build": "cd ui && bun run build", "ui:preview": "cd ui && bun run preview", "ui:validate": "cd ui && bun run validate", + "prepack": "bun run build:all", "prepare": "husky", "postinstall": "node scripts/postinstall.js" }, diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 0199dfc1..09a278d7 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -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,10 +85,54 @@ function normalizeCodexReasoningOverride(value: string | number | undefined): st ); } -function ensureExplicitCodexHomeDir(env: NodeJS.ProcessEnv): string | undefined { - const codexHome = env.CODEX_HOME?.trim(); +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 undefined; + return { env: normalizedEnv }; + } + + if (isInformationalCodexInvocation(args)) { + return { env: normalizedEnv }; } try { @@ -94,18 +140,27 @@ function ensureExplicitCodexHomeDir(env: NodeJS.ProcessEnv): string | undefined } catch (err) { const error = err as NodeJS.ErrnoException; if (error.code !== 'EEXIST') { - return `[X] Unable to initialize CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`; + return { + env: normalizedEnv, + error: `[X] Unable to initialize CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`, + }; } } try { if (!fs.statSync(codexHome).isDirectory()) { - return `[X] CODEX_HOME path is not a directory: ${codexHome}`; + return { + env: normalizedEnv, + error: `[X] CODEX_HOME path is not a directory: ${codexHome}`, + }; } - return undefined; + return { env: normalizedEnv }; } catch (err) { const error = err as NodeJS.ErrnoException; - return `[X] Unable to access CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`; + return { + env: normalizedEnv, + error: `[X] Unable to access CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`, + }; } } @@ -233,11 +288,12 @@ export class CodexAdapter implements TargetAdapter { return exitWithCleanup(1); } - const codexHomeInitError = ensureExplicitCodexHomeDir(env); - if (codexHomeInitError) { - console.error(codexHomeInitError); + 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); @@ -248,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(' '); @@ -256,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) => { diff --git a/src/targets/codex-detector.ts b/src/targets/codex-detector.ts index 31e72423..97cfac1a 100644 --- a/src/targets/codex-detector.ts +++ b/src/targets/codex-detector.ts @@ -7,6 +7,22 @@ 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'; const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath); @@ -112,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 { diff --git a/src/web-server/services/codex-dashboard-service.ts b/src/web-server/services/codex-dashboard-service.ts index ce95894f..725c0bc1 100644 --- a/src/web-server/services/codex-dashboard-service.ts +++ b/src/web-server/services/codex-dashboard-service.ts @@ -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 { 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 { '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' + ); }); }); }); diff --git a/tests/unit/targets/codex-detector.test.ts b/tests/unit/targets/codex-detector.test.ts index 69f71db1..bf4b0f4a 100644 --- a/tests/unit/targets/codex-detector.test.ts +++ b/tests/unit/targets/codex-detector.test.ts @@ -73,6 +73,20 @@ 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, ''); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 83723f67..439e476d 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -270,11 +270,11 @@ process.exit(0); ]); }); - it('creates an explicit CODEX_HOME directory before launching native Codex', () => { + 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 = runCcsxpAlias(['--version'], { + const result = runCcs(['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], { ...process.env, CI: '1', NO_COLOR: '1', @@ -289,25 +289,31 @@ process.exit(0); expect(result.status).toBe(0); expect(fs.existsSync(freshCodexHome)).toBe(true); expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: freshCodexHome, - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - }, + 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 CODEX_HOME points to a file', () => { + 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 = runCcsxpAlias(['--version'], { + const result = runCcs( + ['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], + { ...process.env, CI: '1', NO_COLOR: '1', @@ -315,11 +321,67 @@ process.exit(0); 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([]); + 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', () => { diff --git a/tests/unit/web-server/codex-dashboard-service.test.ts b/tests/unit/web-server/codex-dashboard-service.test.ts index 7dc64be6..31c9ef65 100644 --- a/tests/unit/web-server/codex-dashboard-service.test.ts +++ b/tests/unit/web-server/codex-dashboard-service.test.ts @@ -318,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 () => { diff --git a/ui/src/components/compatible-cli/codex-overview-tab.tsx b/ui/src/components/compatible-cli/codex-overview-tab.tsx index ee0bcda3..f74cd945 100644 --- a/ui/src/components/compatible-cli/codex-overview-tab.tsx +++ b/ui/src/components/compatible-cli/codex-overview-tab.tsx @@ -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 ( @@ -142,29 +143,42 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) { -

- There are two supported paths. Use ccsxp if you want the built-in CCS - Codex provider shortcut. Use the saved recipe below if you want plain{' '} - codex or a personal alias like cxp to default to CLIProxy. -

-
-

Saved native Codex recipe

-
-                {CLIPROXY_NATIVE_CODEX_RECIPE}
-              
-
-
+ {supportsManagedRouting ? ( + <> +

+ There are two supported paths. Use ccsxp if you want the built-in CCS + Codex provider shortcut. Use the saved recipe below if you want plain{' '} + codex or a personal alias like cxp to default to + CLIProxy. +

+
+

Saved native Codex recipe

+
+                    {CLIPROXY_NATIVE_CODEX_RECIPE}
+                  
+
+
+

+ 1. Save a provider named cliproxy with the base URL and env key + above. +

+

+ 2. In Top-level settings, set Default provider{' '} + to cliproxy. +

+

+ 3. Export CLIPROXY_API_KEY in your shell before launching native + Codex. +

+
+ + ) : (

- 1. Save a provider named cliproxy with the base URL and env key above. + This Codex build can still use the native path, but CCS-backed Codex routing via{' '} + ccsxp or ccs codex --target codex stays unavailable until + the detected Codex binary exposes --config overrides.

-

- 2. In Top-level settings, set Default provider to{' '} - cliproxy. -

-

- 3. Export CLIPROXY_API_KEY in your shell before launching native Codex. -

-
+ )}
@@ -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) {

CCS Codex provider / bridge

- Use ccsxp or ccs codex --target codex 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 cliproxy recipe above. + {supportsManagedRouting ? ( + <> + Use ccsxp or ccs codex --target codex 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 cliproxy{' '} + recipe above. + + ) : ( + <> + The CCS Codex provider route is currently unavailable because the detected Codex + build does not expose --config overrides. + + )}

From 99d0d884418eba81473bec0c0211c16e4029eaf0 Mon Sep 17 00:00:00 2001 From: Simon Shi <9512067+simonsmh@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:44:41 +0800 Subject: [PATCH 29/33] Update Kiro models to version 4.6 --- ui/src/lib/model-catalogs.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 62fd74b8..b8a3484e 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -472,8 +472,30 @@ export const MODEL_CATALOGS: Record = { 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', From 58503a469a88e51308be43f76dc9c32168e19466 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Apr 2026 14:22:13 +0000 Subject: [PATCH 30/33] chore(release): 7.63.0-dev.7 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 854a1d37..97d37ba0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.63.0-dev.6", + "version": "7.63.0-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From d6382af73e3fd57138fdfb085b4edc19b1d07270 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Apr 2026 15:06:26 +0000 Subject: [PATCH 31/33] chore(release): 7.63.0-dev.8 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 97d37ba0..90a7b6e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.63.0-dev.7", + "version": "7.63.0-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 27409b789b29a1d008f621f55aab7eed9861d6d4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 11:39:34 -0400 Subject: [PATCH 32/33] fix(docker): harden proxy with timeout, Bun compat, and test coverage - Add 30s timeout on proxy requests to prevent indefinite hangs - Wrap resolveLocalCliproxyPort in try/catch with default port fallback - Simplify buildProxyBody: remove fragile content-length check that caused Bun to fall through to req.pipe() on consumed streams - Replace proxyRes.pipe(res) with manual streaming for Bun compatibility (pipe hangs after writeHead in Bun runtime) - Replace deprecated req.on('aborted') with res.on('close') cleanup (req.on('close') fires with req.destroyed=true in Bun after body consumption, prematurely destroying the proxy connection) - Explicitly end proxy request for bodyless methods (GET/HEAD/OPTIONS) instead of piping an already-consumed express stream - Add server.closeAllConnections() in test cleanup to prevent hangs - Add GET passthrough and 502 unreachable test cases --- src/web-server/routes/cliproxy-local-proxy.ts | 51 ++++++++++++------- .../web-server/cliproxy-local-proxy.test.ts | 31 +++++++++++ 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/src/web-server/routes/cliproxy-local-proxy.ts b/src/web-server/routes/cliproxy-local-proxy.ts index fa745d11..a8359186 100644 --- a/src/web-server/routes/cliproxy-local-proxy.ts +++ b/src/web-server/routes/cliproxy-local-proxy.ts @@ -19,9 +19,16 @@ export interface CliproxyLocalProxyDeps { resolveTargetPort?: () => number; } +/** Proxy request timeout in milliseconds (30 seconds) */ +const PROXY_TIMEOUT_MS = 30_000; + function resolveLocalCliproxyPort(): number { - const config = loadOrCreateUnifiedConfig(); - return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); + 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 { @@ -30,22 +37,17 @@ function isJsonContentType(contentType: string | string[] | undefined): boolean } function buildProxyBody(req: Request): Buffer | undefined { - if (!isJsonContentType(req.headers['content-type']) || req.body === undefined) { - return undefined; - } - - const contentLengthHeader = req.headers['content-length']; - const contentLength = Array.isArray(contentLengthHeader) - ? contentLengthHeader[0] - : contentLengthHeader; - const hasTransferEncoding = req.headers['transfer-encoding'] !== undefined; - const parsedContentLength = - typeof contentLength === 'string' ? Number.parseInt(contentLength, 10) : NaN; - - if (!hasTransferEncoding && (!Number.isFinite(parsedContentLength) || parsedContentLength <= 0)) { + // 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)); } @@ -100,20 +102,27 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {} path: targetPath, method: req.method, headers: buildProxyHeaders(req.headers, targetPort, bodyBuffer), + timeout: PROXY_TIMEOUT_MS, }, (proxyRes) => { res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); - proxyRes.pipe(res, { end: true }); + // 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' }); } }); - req.on('aborted', () => proxyReq.destroy()); + // 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(); @@ -125,6 +134,14 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {} 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 }); }); diff --git a/tests/unit/web-server/cliproxy-local-proxy.test.ts b/tests/unit/web-server/cliproxy-local-proxy.test.ts index 57f39755..d665731b 100644 --- a/tests/unit/web-server/cliproxy-local-proxy.test.ts +++ b/tests/unit/web-server/cliproxy-local-proxy.test.ts @@ -56,6 +56,8 @@ afterEach(async () => { continue; } + // Force-close keep-alive connections so server.close() doesn't hang + server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); } }); @@ -118,4 +120,33 @@ describe('cliproxy local proxy route', () => { 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('management panel'); + }); + 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('management panel'); + }); + + 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' }); + }); }); From b3ec9ff78daa22af36f0599fc707d1c8ff398a06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Apr 2026 15:54:47 +0000 Subject: [PATCH 33/33] chore(release): 7.63.0-dev.9 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 90a7b6e7..c5318ff4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.63.0-dev.8", + "version": "7.63.0-dev.9", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",