From d6b705e6a9e4215c45721f929f69a2680c1eb725 Mon Sep 17 00:00:00 2001 From: Wei He Date: Mon, 4 May 2026 20:22:13 -0400 Subject: [PATCH 01/38] fix(dispatcher): re-inject anthropic auth env for anthropic-compatible api profiles (#1181) * fix(dispatcher): preserve anthropic auth env for settings profiles on non-proxy path API profiles whose `ANTHROPIC_BASE_URL` is classified as `'anthropic'` (anthropic.com, paths containing `/anthropic`, ollama.com) skip the local OpenAI-compat proxy. The non-proxy launch path stripped `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_API_KEY` from the subprocess env without re-injecting them, so Claude Code launched with no routing/auth in `process.env` and failed with `Not logged in - Please run /login`. The `--settings` env block does not satisfy Claude Code's auth check. Pre-existing for `anthropic.com` and `/anthropic` profiles. Newly broken in v7.77.0 for Ollama Cloud profiles - PR #1175 reclassified `ollama.com` from `generic-chat-completion-api` to `anthropic`, moving it from the proxy path onto this broken non-proxy path. Fix by extending `stripAnthropicRoutingEnv` with an optional `preserveFrom` parameter. Routing keys present in `preserveFrom` survive the strip (with values from `preserveFrom`). Settings-type profiles pass their own `settings.env` as the preserve source so routing they explicitly supplied is kept while routing leaked from the parent shell or `global.env` is dropped. Wired into both call sites: - `headless-executor.ts` (the `-p` headless executor) - `settings-flow.ts` (the interactive flow, which then calls `execClaude` - whose own `stripAnthropicRoutingEnv` pass on the merged env now also takes `envVars` as the preserve source so the caller-supplied routing is not stripped a second time before spawn) Native Anthropic / Bedrock / Vertex profiles are unaffected (they don't put routing keys in `settings.env`). The OpenAI-compat proxy path is unaffected because `buildOpenAICompatProxyEnv` overrides `BASE_URL` / `AUTH_TOKEN` with localhost values and explicitly deletes `API_KEY` after this strip. Replaces the Apr 21 defensive double-strip test (which was the mechanism causing this regression on the interactive path) with a test asserting the new contract: caller-supplied routing in `envVars` survives, parent-process routing is still stripped. * test: harden anthropic settings env preservation coverage * fix: preserve explicit blank anthropic routing env --------- Co-authored-by: Tam Nhu Tran --- src/delegation/headless-executor.ts | 2 +- src/dispatcher/flows/settings-flow.ts | 2 +- src/utils/shell-executor.ts | 22 +++- .../settings-profile-browser-launch.test.ts | 11 +- .../utils/claudecode-env-stripping.test.ts | 109 +++++++++++++++++- 5 files changed, 136 insertions(+), 10 deletions(-) diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 2ceeaa62..bc3a0f55 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -228,7 +228,7 @@ export class HeadlessExecutor { } let runtimeEnvVars: NodeJS.ProcessEnv = { - ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }), + ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }, settingsEnv), ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), CCS_PROFILE_TYPE: 'settings', CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', diff --git a/src/dispatcher/flows/settings-flow.ts b/src/dispatcher/flows/settings-flow.ts index f54bbb41..07674e54 100644 --- a/src/dispatcher/flows/settings-flow.ts +++ b/src/dispatcher/flows/settings-flow.ts @@ -275,7 +275,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise { expect(result.PATH).toBe('/usr/bin'); }); + it('stripAnthropicRoutingEnv preserves routing/auth keys from preserveFrom on the non-proxy settings path', () => { + const settingsEnv: NodeJS.ProcessEnv = { + ANTHROPIC_BASE_URL: 'https://ollama.com', + ANTHROPIC_AUTH_TOKEN: 'profile-token', + ANTHROPIC_API_KEY: 'profile-api-key', + ANTHROPIC_MODEL: 'minimax-m2.7:cloud', + OTHER: 'keep', + }; + const globalEnv: NodeJS.ProcessEnv = { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:9999', + ANTHROPIC_AUTH_TOKEN: 'global-stale', + }; + + const merged = stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }, settingsEnv); + + expect(merged.ANTHROPIC_BASE_URL).toBe('https://ollama.com'); + expect(merged.ANTHROPIC_AUTH_TOKEN).toBe('profile-token'); + expect(merged.ANTHROPIC_API_KEY).toBe('profile-api-key'); + expect(merged.ANTHROPIC_MODEL).toBe('minimax-m2.7:cloud'); + expect(merged.OTHER).toBe('keep'); + }); + + it('stripAnthropicRoutingEnv with empty preserveFrom strips all routing keys', () => { + const result = stripAnthropicRoutingEnv( + { + ANTHROPIC_BASE_URL: 'http://inherited', + ANTHROPIC_AUTH_TOKEN: 'inherited-token', + ANTHROPIC_MODEL: 'claude-opus-4-7', + }, + { ANTHROPIC_MODEL: 'claude-opus-4-7' } + ); + expect(result.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-7'); + }); + + it('stripAnthropicRoutingEnv preserveFrom only emits keys present in preserveFrom', () => { + const env = { + ANTHROPIC_BASE_URL: 'http://inherited', + ANTHROPIC_AUTH_TOKEN: 'inherited-token', + }; + expect(stripAnthropicRoutingEnv(env, { ANTHROPIC_BASE_URL: 'https://ollama.com' })).toEqual({ + ANTHROPIC_BASE_URL: 'https://ollama.com', + }); + expect(stripAnthropicRoutingEnv(env, { ANTHROPIC_AUTH_TOKEN: 'profile-token' })).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'profile-token', + }); + expect(stripAnthropicRoutingEnv(env, { ANTHROPIC_API_KEY: 'profile-api-key' })).toEqual({ + ANTHROPIC_API_KEY: 'profile-api-key', + }); + expect(stripAnthropicRoutingEnv(env, { ANTHROPIC_BASE_URL: '' })).toEqual({ + ANTHROPIC_BASE_URL: '', + }); + }); + it('stripAnthropicRoutingEnv removes routing/auth env case-insensitively while preserving model vars', () => { const input: NodeJS.ProcessEnv = { anthropic_base_url: 'http://127.0.0.1:8317/api/provider/codex', @@ -451,7 +506,10 @@ describe('CLAUDECODE environment stripping', () => { expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5-codex-mini'); }); - it('execClaude strips routing env reintroduced by explicit settings-profile overrides', () => { + it('execClaude preserves routing env supplied via explicit settings-profile envVars', () => { + process.env.ANTHROPIC_BASE_URL = 'http://parent-leaked'; + process.env.ANTHROPIC_AUTH_TOKEN = 'parent-leaked-token'; + execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'settings', CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', @@ -464,9 +522,9 @@ describe('CLAUDECODE environment stripping', () => { expect(spawnCalls.length).toBeGreaterThan(0); const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; - expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); - expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); - expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('reintroduced-routing-token'); + expect(env.ANTHROPIC_API_KEY).toBe('reintroduced-api-key'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); }); @@ -650,6 +708,49 @@ describe('CLAUDECODE environment stripping', () => { expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345'); }); + it('headless executor preserves profile routing env for non-proxy settings-profile delegation', async () => { + writeConfigWithAutoUpdatePreference(false); + const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); + fs.writeFileSync( + path.join(ccsDir, 'ollama-cloud.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://ollama.com', + ANTHROPIC_AUTH_TOKEN: 'settings-ollama-token', + ANTHROPIC_API_KEY: '', + ANTHROPIC_MODEL: 'gemma4:31b-cloud', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + const projectDir = path.join(ccsDir, 'project-headless-ollama-cloud'); + fs.mkdirSync(projectDir, { recursive: true }); + process.env.CCS_CLAUDE_PATH = 'claude'; + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex'; + process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token'; + process.env.ANTHROPIC_API_KEY = 'parent-api-key'; + + const result = await HeadlessExecutor.execute('ollama-cloud', 'latest AI chip news', { + cwd: projectDir, + 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.ANTHROPIC_BASE_URL).toBe('https://ollama.com'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('settings-ollama-token'); + expect(env.ANTHROPIC_API_KEY).toBe(''); + expect(env.ANTHROPIC_MODEL).toBe('gemma4:31b-cloud'); + expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345'); + }); + it('headless executor rebuilds OpenAI-compatible bridge env from settings instead of inheriting stale parent routing', async () => { writeConfigWithAutoUpdatePreference(false); const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); From fcfd2d279e5a9de01df5cc2353ce34e23550eeff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 20:26:33 -0400 Subject: [PATCH 02/38] chore(release): 7.77.1-dev.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e362e0dd..3b3cc137 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1", + "version": "7.77.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 58af9cd501f5add0c056ea7d62df32a3f8016435 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 5 May 2026 11:38:00 -0400 Subject: [PATCH 03/38] fix(ui): surface Claude Opus 4.7 in Claude picker --- ui/src/lib/model-catalogs.ts | 14 +++++++++++++- .../cliproxy/provider-model-selector.test.tsx | 18 ++++++++++++++++++ ui/tests/unit/ui/lib/preset-utils.test.ts | 3 ++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 18a40732..ef516b32 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -628,10 +628,22 @@ export const MODEL_CATALOGS: Record = { displayName: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-6', models: [ + { + id: 'claude-opus-4-7', + name: 'Claude Opus 4.7', + description: 'Latest flagship model', + extendedContext: true, + presetMapping: { + default: 'claude-opus-4-7', + opus: 'claude-opus-4-7', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }, + }, { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', - description: 'Latest flagship model', + description: 'Previous flagship model', extendedContext: true, presetMapping: { default: 'claude-opus-4-6', diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index abfde015..cddf5b38 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -70,6 +70,24 @@ describe('FlexibleModelSelector', () => { expect(screen.queryByText(/All Models \(/i)).not.toBeInTheDocument(); }); + it('surfaces Claude Opus 4.7 in the curated Claude picker', async () => { + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /select model/i })); + + expect(screen.getByRole('option', { name: /claude-opus-4-7/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /claude-opus-4-6/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /claude-sonnet-4-6/i })).toBeInTheDocument(); + }); + it('preserves a filtered legacy value under the current-value fallback group', async () => { render( { vi.restoreAllMocks(); }); - it('keeps the claude catalog default on Sonnet 4.6', () => { + it('keeps the claude catalog default on Sonnet 4.6 while exposing Opus 4.7', () => { const claudeCatalog = MODEL_CATALOGS.claude; expect(claudeCatalog.defaultModel).toBe('claude-sonnet-4-6'); + expect(claudeCatalog.models.map((model) => model.id)).toContain('claude-opus-4-7'); expect(claudeCatalog.models.map((model) => model.id)).toContain('claude-sonnet-4-6'); }); From dc8bbd85e7937e34689d07e2224334eb3db7bce2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 5 May 2026 11:44:58 -0400 Subject: [PATCH 04/38] fix: route OpenRouter profiles through v1 API --- src/proxy/upstream-url.ts | 10 ++++++ src/shared/provider-preset-catalog.ts | 2 +- .../unit/api/profile-writer-anthropic.test.ts | 4 +-- tests/unit/api/provider-presets.test.ts | 5 +++ tests/unit/proxy/upstream-url.test.ts | 31 +++++++++++++++++++ 5 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 tests/unit/proxy/upstream-url.test.ts diff --git a/src/proxy/upstream-url.ts b/src/proxy/upstream-url.ts index d41d89b6..5b81b53d 100644 --- a/src/proxy/upstream-url.ts +++ b/src/proxy/upstream-url.ts @@ -9,6 +9,11 @@ function ensureSupportedProtocol(parsed: URL): void { } } +function isOpenRouterHost(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return normalized === 'openrouter.ai' || normalized.endsWith('.openrouter.ai'); +} + function buildResolvedUrl(baseUrl: string, suffix: string): string { const parsed = new URL(baseUrl); ensureSupportedProtocol(parsed); @@ -18,6 +23,11 @@ function buildResolvedUrl(baseUrl: string, suffix: string): string { return parsed.toString(); } + if (isOpenRouterHost(parsed.hostname) && pathname.endsWith('/api')) { + parsed.pathname = `${pathname}/v1${suffix}`; + return parsed.toString(); + } + if (pathname.endsWith('/v1') || pathname.endsWith('/api')) { parsed.pathname = `${pathname}${suffix.startsWith('/') ? suffix : `/${suffix}`}`; return parsed.toString(); diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index b94fe6e8..5d934789 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -49,7 +49,7 @@ export interface ProviderPresetDefinition { icon?: string; } -export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api'; +export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; /** * Legacy aliases mapped to canonical preset IDs. diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index 0416c321..5a497d98 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -134,7 +134,7 @@ describe('profile-writer Anthropic direct', () => { it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => { const result = createApiProfile( 'openrouter-test', - 'https://openrouter.ai/api', + 'https://openrouter.ai/api/v1', 'sk-or-testkey', { default: 'anthropic/claude-opus-4.5', opus: 'anthropic/claude-opus-4.5', sonnet: 'anthropic/claude-opus-4.5', haiku: 'anthropic/claude-opus-4.5' } ); @@ -145,7 +145,7 @@ describe('profile-writer Anthropic direct', () => { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); // OpenRouter: proxy mode with ANTHROPIC_API_KEY explicitly blank - expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api'); + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api/v1'); expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-or-testkey'); expect(settings.env.ANTHROPIC_API_KEY).toBe(''); }); diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index e672b496..8d2dc167 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -92,6 +92,11 @@ describe('provider-presets', () => { expect(isValidPresetId('hf')).toBe(true); }); + it('uses OpenRouter v1 as the OpenAI-compatible API root', () => { + const preset = getPresetById('openrouter'); + expect(preset?.baseUrl).toBe('https://openrouter.ai/api/v1'); + }); + it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => { const recommendedPresetIds = PROVIDER_PRESETS.filter( (preset) => preset.category === 'recommended' diff --git a/tests/unit/proxy/upstream-url.test.ts b/tests/unit/proxy/upstream-url.test.ts new file mode 100644 index 00000000..4776ea87 --- /dev/null +++ b/tests/unit/proxy/upstream-url.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test'; +import { + resolveOpenAIChatCompletionsUrl, + resolveOpenAIModelsUrl, +} from '../../../src/proxy/upstream-url'; + +describe('OpenAI-compatible upstream URL resolution', () => { + it('routes current OpenRouter API roots through /api/v1', () => { + expect(resolveOpenAIChatCompletionsUrl('https://openrouter.ai/api/v1')).toBe( + 'https://openrouter.ai/api/v1/chat/completions' + ); + expect(resolveOpenAIModelsUrl('https://openrouter.ai/api/v1')).toBe( + 'https://openrouter.ai/api/v1/models' + ); + }); + + it('repairs legacy OpenRouter /api roots before appending OpenAI endpoints', () => { + expect(resolveOpenAIChatCompletionsUrl('https://openrouter.ai/api')).toBe( + 'https://openrouter.ai/api/v1/chat/completions' + ); + expect(resolveOpenAIModelsUrl('https://openrouter.ai/api')).toBe( + 'https://openrouter.ai/api/v1/models' + ); + }); + + it('does not rewrite non-OpenRouter /api roots', () => { + expect(resolveOpenAIChatCompletionsUrl('https://example.test/api')).toBe( + 'https://example.test/api/chat/completions' + ); + }); +}); From 8df43585f428d4406d293af9147fe7be9eb97c04 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 11:55:33 -0400 Subject: [PATCH 05/38] chore(release): 7.77.1-dev.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3b3cc137..a481d83d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1-dev.1", + "version": "7.77.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From be9effcce34a7545943d195b3a5ce5369083e98a Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 5 May 2026 13:33:04 -0400 Subject: [PATCH 06/38] feat: clarify account history sync route (#1187) * feat: clarify account history sync route * fix: clarify shared context command examples * fix: report missing bare profile settings --- docs/dashboard-auth-cli.md | 23 ++- docs/session-sharing-technical-analysis.md | 48 +++++- src/auth/account-profile-diagnostics.ts | 140 +++++++++++++++++ src/auth/auth-commands.ts | 32 ++-- src/auth/commands/create-command.ts | 12 +- src/auth/commands/show-command.ts | 44 ++++++ src/auth/commands/types.ts | 20 +++ tests/unit/auth-help-route-guidance.test.ts | 21 +++ .../auth/account-profile-diagnostics.test.ts | 102 +++++++++++++ .../account/account-route-guide-card.tsx | 126 +++++++++++++++ ui/src/lib/i18n.ts | 143 ++++++++++++++++++ ui/src/pages/accounts.tsx | 20 ++- .../account/account-route-guide-card.test.tsx | 53 +++++++ 13 files changed, 767 insertions(+), 17 deletions(-) create mode 100644 src/auth/account-profile-diagnostics.ts create mode 100644 tests/unit/auth-help-route-guidance.test.ts create mode 100644 tests/unit/auth/account-profile-diagnostics.test.ts create mode 100644 ui/src/components/account/account-route-guide-card.tsx create mode 100644 ui/tests/unit/components/account/account-route-guide-card.test.tsx diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index f8c83fc5..b2c2a33d 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -1,6 +1,6 @@ # Dashboard Authentication CLI -Last Updated: 2026-04-06 +Last Updated: 2026-05-05 CLI commands for managing CCS dashboard authentication. @@ -27,7 +27,16 @@ Dashboard auth and account context metadata are separate: - `dashboard_auth`: protects dashboard access with username/password - `accounts..context_mode/context_group`: controls isolated vs shared account context -Account context is isolation-first: +Account context is isolation-first. The recommended two-account route is: + +```bash +ccs auth create work +ccs auth create personal +ccs work +ccs personal +``` + +Only enable history sync when both accounts should share local continuity while tokens stay separate: | Mode | Default | Requirement | |------|---------|-------------| @@ -39,6 +48,16 @@ Shared continuity depth: - `standard` (default): shares project workspace context only - `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos` +`ccs auth show ` reports credential isolation, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane. + +Non-bare account profiles share the basic Claude `settings.json` with native Claude: + +```text +~/.ccs/instances//settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json +``` + +This keeps ordinary Claude settings in sync without copying account tokens. Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`. + `context_group` normalization and validation: - trim + lowercase + collapse internal whitespace to `-` diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index 551f6b42..a160cccb 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -1,6 +1,6 @@ # Session Sharing Technical Analysis -Last Updated: 2026-04-04 +Last Updated: 2026-05-05 ## Summary @@ -12,6 +12,38 @@ This is implemented as a context policy per account: - `shared` + `standard` (default): account workspace context is linked to a shared context group - `shared` + `deeper` (advanced opt-in): account also shares continuity artifacts +## Recommended Two-Account Route + +Use `ccs auth` account profiles when you want two real Claude accounts and want to choose which one runs each session: + +```bash +ccs auth create work +ccs auth create personal + +ccs work +ccs personal +``` + +This keeps usage and credentials isolated. Each account owns its own Claude config directory, login state, and `.anthropic` credentials. + +Basic Claude settings are shared for non-bare account profiles: + +```text +~/.ccs/instances//settings.json + -> ~/.ccs/shared/settings.json + -> ~/.claude/settings.json +``` + +This is for ordinary Claude Code settings, hooks, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show ` reports the current `Settings`, `History`, and `Plain ccs` lanes so users can see whether settings and resume history are aligned. + +Only opt in to shared history when both accounts should see the same local continuity: + +```bash +ccs auth create work2 --share-context --context-group daily --deeper-continuity +``` + +For existing accounts, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context. + ## Why This Is Safe Enough CCS only shares workspace context paths (project/session context files). It does **not** merge or copy authentication credentials between accounts. @@ -93,14 +125,24 @@ continuity: default: work ``` +Example with an existing `ck` account: + +```bash +ccs auth show ck +ccs auth backup default +ccs auth default ck +``` + +`ccs auth default ck` makes future plain `ccs` sessions use the `ck` account lane, so future `ccs` and `ccs ck` resume from the same local inventory. It does not automatically import old native `~/.claude/projects` history into `ck`; keep using `ccs -r` for the old native lane until you intentionally migrate that local history. + ## User Workflows ### New account with shared context ```bash ccs auth create work2 --share-context -ccs auth create backup --context-group sprint-a -ccs auth create backup2 --context-group sprint-a --deeper-continuity +ccs auth create backup --share-context --context-group sprint-a +ccs auth create backup2 --share-context --context-group sprint-a --deeper-continuity ``` ### Existing account diff --git a/src/auth/account-profile-diagnostics.ts b/src/auth/account-profile-diagnostics.ts new file mode 100644 index 00000000..9ae8d13d --- /dev/null +++ b/src/auth/account-profile-diagnostics.ts @@ -0,0 +1,140 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { DEFAULT_ACCOUNT_CONTEXT_GROUP, type AccountContextPolicy } from './account-context'; +import { getCcsDir } from '../config/config-loader-facade'; +import { getDefaultClaudeConfigDir } from '../utils/claude-config-path'; + +export type SettingsSyncState = 'shared' | 'profile-local' | 'missing' | 'unknown'; + +export interface AccountSettingsSyncSummary { + state: SettingsSyncState; + profile_settings_path: string; + shared_settings_path: string; + root_settings_path: string; + description: string; +} + +export interface AccountHistorySummary { + project_count: number; + session_count: number; + projects_path: string; + projects_shared: boolean; + deeper_artifacts_shared: boolean; +} + +function safeRealpath(targetPath: string): string | null { + try { + return fs.realpathSync(targetPath); + } catch { + return null; + } +} + +function countTopLevelDirectories(targetPath: string): number { + try { + return fs + .readdirSync(targetPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()).length; + } catch { + return 0; + } +} + +function countJsonFiles(targetPath: string): number { + try { + return fs.readdirSync(targetPath).filter((entry) => entry.endsWith('.json')).length; + } catch { + return 0; + } +} + +function resolvesTo(targetPath: string, expectedPath: string): boolean { + const realTarget = safeRealpath(targetPath); + const realExpected = safeRealpath(expectedPath); + return !!realTarget && !!realExpected && realTarget === realExpected; +} + +export function describeSettingsSync( + instancePath: string, + options: { bare?: boolean } = {} +): AccountSettingsSyncSummary { + const rootSettingsPath = path.join(getDefaultClaudeConfigDir(), 'settings.json'); + const sharedSettingsPath = path.join(getCcsDir(), 'shared', 'settings.json'); + const profileSettingsPath = path.join(instancePath, 'settings.json'); + + if (options.bare) { + if (!fs.existsSync(profileSettingsPath)) { + return { + state: 'missing', + profile_settings_path: profileSettingsPath, + shared_settings_path: sharedSettingsPath, + root_settings_path: rootSettingsPath, + description: 'missing (bare profile; no local settings.json yet)', + }; + } + + return { + state: 'profile-local', + profile_settings_path: profileSettingsPath, + shared_settings_path: sharedSettingsPath, + root_settings_path: rootSettingsPath, + description: 'profile-local (bare profile; not linked to ~/.claude/settings.json)', + }; + } + + if (!fs.existsSync(profileSettingsPath)) { + return { + state: 'missing', + profile_settings_path: profileSettingsPath, + shared_settings_path: sharedSettingsPath, + root_settings_path: rootSettingsPath, + description: 'missing (run or repair this profile to recreate settings link)', + }; + } + + if (resolvesTo(profileSettingsPath, rootSettingsPath)) { + return { + state: 'shared', + profile_settings_path: profileSettingsPath, + shared_settings_path: sharedSettingsPath, + root_settings_path: rootSettingsPath, + description: 'shared with ~/.claude/settings.json', + }; + } + + return { + state: 'unknown', + profile_settings_path: profileSettingsPath, + shared_settings_path: sharedSettingsPath, + root_settings_path: rootSettingsPath, + description: 'not linked to ~/.claude/settings.json', + }; +} + +export function summarizeAccountHistory( + instancePath: string, + policy: AccountContextPolicy +): AccountHistorySummary { + const projectsPath = path.join(instancePath, 'projects'); + const sessionEnvPath = path.join(instancePath, 'session-env'); + const group = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP; + const sharedGroupRoot = path.join(getCcsDir(), 'shared', 'context-groups', group); + const sharedProjectsPath = path.join(sharedGroupRoot, 'projects'); + const deeperArtifacts = ['session-env', 'file-history', 'shell-snapshots', 'todos']; + + return { + project_count: countTopLevelDirectories(projectsPath), + session_count: countJsonFiles(sessionEnvPath), + projects_path: projectsPath, + projects_shared: policy.mode === 'shared' && resolvesTo(projectsPath, sharedProjectsPath), + deeper_artifacts_shared: + policy.mode === 'shared' && + policy.continuityMode === 'deeper' && + deeperArtifacts.every((artifact) => + resolvesTo( + path.join(instancePath, artifact), + path.join(sharedGroupRoot, 'continuity', artifact) + ) + ), + }; +} diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 6f757e1a..aef02f4c 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -81,18 +81,25 @@ class AuthCommands { ); console.log(''); console.log(subheader('Examples')); - console.log(` ${dim('# Create & login to work profile')}`); + console.log(` ${dim('# Create two isolated accounts and choose one explicitly at runtime')}`); console.log(` ${color('ccs auth create work', 'command')}`); + console.log(` ${color('ccs auth create personal', 'command')}`); + console.log(` ${color('ccs work "review code"', 'command')}`); + console.log(` ${color('ccs personal "write tests"', 'command')}`); console.log(''); - console.log(` ${dim('# Create account with shared project context (default group)')}`); + console.log( + ` ${dim('# Optional: share local project history while credentials stay isolated')}` + ); console.log(` ${color('ccs auth create work2 --share-context', 'command')}`); console.log(''); console.log(` ${dim('# Share context only within a specific group')}`); - console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`); + console.log( + ` ${color('ccs auth create backup --share-context --context-group sprint-a', 'command')}` + ); console.log(''); console.log(` ${dim('# Advanced: deeper shared continuity for session history artifacts')}`); console.log( - ` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}` + ` ${color('ccs auth create backup --share-context --context-group sprint-a --deeper-continuity', 'command')}` ); console.log(''); console.log(` ${dim('# Create clean profile without shared commands/skills/agents')}`); @@ -113,9 +120,6 @@ class AuthCommands { console.log(` ${dim('# List all profiles')}`); console.log(` ${color('ccs auth list', 'command')}`); console.log(''); - console.log(` ${dim('# Use work profile')}`); - console.log(` ${color('ccs work "review code"', 'command')}`); - console.log(''); console.log(subheader('Options')); console.log( ` ${color('--force', 'command')} Allow overwriting existing profile (create)` @@ -147,14 +151,24 @@ class AuthCommands { ` By default, ${color('ccs', 'command')} uses Claude CLI defaults from ~/.claude/` ); console.log( - ` Use ${color('ccs auth default ', 'command')} to change the default profile.` + ` Recommended two-account route: create ${color('work', 'command')} and ${color('personal', 'command')}, then run the profile you want.` ); console.log( - ` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.` + ` Use ${color('ccs auth default ', 'command')} to change the default profile.` + ); + console.log(` Account logins, tokens, and .anthropic stay isolated for every profile.`); + console.log( + ` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show ', 'command')} shows the link state.` + ); + console.log( + ` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.` ); console.log( ` ${color('--deeper-continuity', 'command')} requires shared mode and syncs session-env/file-history/todos/shell-snapshots.` ); + console.log( + ` To make future plain ${color('ccs', 'command')} resume with an account, set ${color('ccs auth default ', 'command')}; back up the current native lane first with ${color('ccs auth backup default', 'command')}.` + ); console.log( ` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.` ); diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index f071e001..df45122d 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -280,7 +280,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise `Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account\n` + - `Context: ${formatAccountContextPolicy(contextPolicy)}` + + `Context: ${formatAccountContextPolicy(contextPolicy)}\n` + + `Tokens: isolated per account\n` + + `Settings: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude/settings.json'}` + (effectiveBare ? '\nMode: bare (no shared symlinks)' : ''), 'Profile Created' ) @@ -289,6 +291,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise console.log(header('Usage')); console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`); console.log(''); + console.log( + 'To keep two accounts separate, create another account and run either profile by name:' + ); + console.log(` ${color('ccs auth create personal', 'command')}`); + console.log( + ` ${color(`ccs ${profileName}`, 'command')} / ${color('ccs personal', 'command')}` + ); + console.log(''); console.log( warnBox( `Running the command below will SWITCH your default\n` + diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts index 63aab594..54ca56de 100644 --- a/src/auth/commands/show-command.ts +++ b/src/auth/commands/show-command.ts @@ -8,10 +8,18 @@ import * as fs from 'fs'; import * as path from 'path'; import { initUI, header, color, fail, table } from '../../utils/ui'; import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context'; +import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics'; +import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, ProfileOutput, parseArgs } from './types'; +function formatHistorySummary(history: ReturnType): string { + const scope = history.projects_shared ? 'shared projects' : 'profile-local projects'; + const deeper = history.deeper_artifacts_shared ? ', deeper artifacts shared' : ''; + return `${scope}: ${history.project_count} project(s), ${history.session_count} session env file(s)${deeper}`; +} + /** * Handle the show command */ @@ -37,6 +45,11 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise null); + const plainCcsUsesThisAccount = + !!plainCcsLane && path.resolve(plainCcsLane.configDir) === path.resolve(instancePath); // Count sessions let sessionCount = 0; @@ -63,6 +76,24 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise { + it('keeps the help copy explicit about account choice and credential isolation', () => { + const source = fs.readFileSync( + path.join(process.cwd(), 'src', 'auth', 'auth-commands.ts'), + 'utf8' + ); + + expect(source).toContain('Create two isolated accounts and choose one explicitly at runtime'); + expect(source).toContain('ccs auth create work'); + expect(source).toContain('ccs auth create personal'); + expect(source).toContain('Account logins, tokens, and .anthropic stay isolated'); + expect(source).toContain('settings.json'); + expect(source).toContain('ccs auth show '); + expect(source).toContain('History sync is opt-in'); + expect(source).toContain('ccs auth backup default'); + }); +}); diff --git a/tests/unit/auth/account-profile-diagnostics.test.ts b/tests/unit/auth/account-profile-diagnostics.test.ts new file mode 100644 index 00000000..63eda681 --- /dev/null +++ b/tests/unit/auth/account-profile-diagnostics.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + describeSettingsSync, + summarizeAccountHistory, +} from '../../../src/auth/account-profile-diagnostics'; + +describe('account profile diagnostics', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-diagnostics-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + 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 }); + } + }); + + function seedSharedSettings(instancePath: string): void { + const claudeDir = path.join(tempHome, '.claude'); + const sharedDir = path.join(tempHome, '.ccs', 'shared'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.mkdirSync(sharedDir, { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}\n'); + fs.symlinkSync(path.join(claudeDir, 'settings.json'), path.join(sharedDir, 'settings.json')); + fs.symlinkSync(path.join(sharedDir, 'settings.json'), path.join(instancePath, 'settings.json')); + } + + it('reports non-bare account settings as shared with root Claude settings', () => { + const instancePath = path.join(tempHome, '.ccs', 'instances', 'ck'); + seedSharedSettings(instancePath); + + const summary = describeSettingsSync(instancePath); + + expect(summary.state).toBe('shared'); + expect(summary.description).toBe('shared with ~/.claude/settings.json'); + expect(summary.root_settings_path).toBe(path.join(tempHome, '.claude', 'settings.json')); + }); + + it('reports bare account settings as profile-local', () => { + const instancePath = path.join(tempHome, '.ccs', 'instances', 'bare'); + fs.mkdirSync(instancePath, { recursive: true }); + fs.writeFileSync(path.join(instancePath, 'settings.json'), '{}\n'); + + const summary = describeSettingsSync(instancePath, { bare: true }); + + expect(summary.state).toBe('profile-local'); + expect(summary.description).toContain('bare profile'); + }); + + it('reports bare account settings as missing when the local file is absent', () => { + const instancePath = path.join(tempHome, '.ccs', 'instances', 'bare'); + + const summary = describeSettingsSync(instancePath, { bare: true }); + + expect(summary.state).toBe('missing'); + expect(summary.description).toContain('bare profile'); + expect(summary.profile_settings_path).toBe(path.join(instancePath, 'settings.json')); + }); + + it('summarizes shared project and deeper continuity lane state', () => { + const instancePath = path.join(tempHome, '.ccs', 'instances', 'ck'); + const groupRoot = path.join(tempHome, '.ccs', 'shared', 'context-groups', 'default'); + const projectsPath = path.join(groupRoot, 'projects'); + const sessionEnvPath = path.join(groupRoot, 'continuity', 'session-env'); + fs.mkdirSync(projectsPath, { recursive: true }); + fs.mkdirSync(sessionEnvPath, { recursive: true }); + fs.mkdirSync(path.join(projectsPath, 'project-a')); + fs.mkdirSync(path.join(projectsPath, 'project-b')); + fs.writeFileSync(path.join(sessionEnvPath, 'session-a.json'), '{}\n'); + fs.mkdirSync(instancePath, { recursive: true }); + fs.symlinkSync(projectsPath, path.join(instancePath, 'projects'), 'dir'); + + for (const artifact of ['session-env', 'file-history', 'shell-snapshots', 'todos']) { + const sharedArtifactPath = path.join(groupRoot, 'continuity', artifact); + fs.mkdirSync(sharedArtifactPath, { recursive: true }); + fs.symlinkSync(sharedArtifactPath, path.join(instancePath, artifact), 'dir'); + } + + const summary = summarizeAccountHistory(instancePath, { + mode: 'shared', + group: 'default', + continuityMode: 'deeper', + }); + + expect(summary.project_count).toBe(2); + expect(summary.session_count).toBe(1); + expect(summary.projects_shared).toBe(true); + expect(summary.deeper_artifacts_shared).toBe(true); + }); +}); diff --git a/ui/src/components/account/account-route-guide-card.tsx b/ui/src/components/account/account-route-guide-card.tsx new file mode 100644 index 00000000..ccba55ae --- /dev/null +++ b/ui/src/components/account/account-route-guide-card.tsx @@ -0,0 +1,126 @@ +import { Link2, Settings2, ShieldCheck, Terminal, UserRoundCheck, Waves } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { CopyButton } from '@/components/ui/copy-button'; + +interface AccountRouteGuideCardProps { + totalAccounts: number; + isolatedCount: number; + sharedPeerGroups: string[]; + deeperReadyGroups: string[]; +} + +type RouteStatus = 'empty' | 'isolated' | 'shared' | 'deeper' | 'mixed'; + +export function AccountRouteGuideCard({ + totalAccounts, + isolatedCount, + sharedPeerGroups, + deeperReadyGroups, +}: AccountRouteGuideCardProps) { + const { t } = useTranslation(); + const recommendedGroup = deeperReadyGroups[0] || sharedPeerGroups[0] || 'daily'; + const status: RouteStatus = + totalAccounts < 2 + ? 'empty' + : deeperReadyGroups.length > 0 + ? 'deeper' + : sharedPeerGroups.length > 0 + ? 'shared' + : totalAccounts >= 2 && isolatedCount === totalAccounts + ? 'isolated' + : totalAccounts === 0 + ? 'empty' + : 'mixed'; + const syncCommand = `ccs auth create work2 --share-context --context-group ${recommendedGroup} --deeper-continuity`; + + return ( + + +
+
+ {t('accountRouteGuide.title')} + {t('accountRouteGuide.description')} +
+ + {t(`accountRouteGuide.status.${status}`)} + +
+
+ +
+
+
+ + {t('accountRouteGuide.cards.isolated.title')} +
+

+ {t('accountRouteGuide.cards.isolated.desc')} +

+
+
+
+ + {t('accountRouteGuide.cards.select.title')} +
+

+ {t('accountRouteGuide.cards.select.desc')} +

+
+
+
+ + {t('accountRouteGuide.cards.settings.title')} +
+

+ {t('accountRouteGuide.cards.settings.desc')} +

+
+
+
+ + {t('accountRouteGuide.cards.sync.title')} +
+

+ {t('accountRouteGuide.cards.sync.desc')} +

+
+
+ +
+
+
+ + {t('accountRouteGuide.commands.isolated')} +
+ {['ccs auth create work', 'ccs auth create personal', 'ccs work', 'ccs personal'].map( + (command) => ( +
+ {command} + +
+ ) + )} +
+
+
+ + {t('accountRouteGuide.commands.sync')} +
+
+ {syncCommand} + +
+

+ {t('accountRouteGuide.commands.syncDesc', { group: recommendedGroup })} +

+
+
+
+
+ ); +} diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 7c25d987..c0a05780 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -253,6 +253,42 @@ const resources = { copied: 'Copied!', copyCommand: 'Copy Command', }, + accountRouteGuide: { + title: 'Recommended two-account route', + description: + 'Use separate account profiles for token isolation. Turn on history sync only when both accounts should see the same local continuity.', + status: { + empty: 'create accounts', + isolated: 'isolated ready', + shared: 'shared project context', + deeper: 'deeper sync ready', + mixed: 'mixed setup', + }, + cards: { + isolated: { + title: 'Tokens stay separate', + desc: 'Each ccs auth account owns its own Claude config, login, and credentials. Sync settings never copy tokens.', + }, + select: { + title: 'You choose the account', + desc: 'Run ccs work or ccs personal for explicit usage. Set Default only when plain ccs should follow one account.', + }, + settings: { + title: 'Settings follow root', + desc: 'Non-bare accounts link settings.json through CCS shared state to ~/.claude/settings.json. ccs auth show confirms it.', + }, + sync: { + title: 'History sync is opt-in', + desc: 'Both accounts need Shared mode and the same group. Deeper sync adds extra local continuity artifacts.', + }, + }, + commands: { + isolated: 'Isolated account usage', + sync: 'Optional shared history', + syncDesc: + 'Use the same group, such as "{{group}}", on both accounts when you want them to share local history.', + }, + }, cliproxyModelCategory: { google: 'Google (Gemini)', openai: 'OpenAI (GPT)', @@ -2819,6 +2855,41 @@ const resources = { copied: '已复制!', copyCommand: '复制命令', }, + accountRouteGuide: { + title: '推荐的双账号路线', + description: + '使用独立 account profile 保持 token 隔离。只有当两个账号应该看到同一份本地连续性时,才开启历史同步。', + status: { + empty: '创建账号', + isolated: '隔离已就绪', + shared: '共享项目上下文', + deeper: '更深同步已就绪', + mixed: '混合设置', + }, + cards: { + isolated: { + title: 'Token 仍然隔离', + desc: '每个 ccs auth 账号都有自己的 Claude 配置、登录和凭据。同步设置不会复制 token。', + }, + select: { + title: '你选择使用哪个账号', + desc: '运行 ccs work 或 ccs personal 来明确选择账号。只有当普通 ccs 应跟随某个账号时才设置默认。', + }, + settings: { + title: '设置跟随根目录', + desc: '非 bare 账号会通过 CCS shared state 将 settings.json 链接到 ~/.claude/settings.json。ccs auth show 会确认状态。', + }, + sync: { + title: '历史同步需要主动开启', + desc: '两个账号都需要使用 Shared 模式和同一分组。更深同步会加入额外的本地连续性文件。', + }, + }, + commands: { + isolated: '隔离账号用法', + sync: '可选共享历史', + syncDesc: '当希望两个账号共享本地历史时,在两个账号上使用同一分组,例如 "{{group}}"。', + }, + }, cliproxyModelCategory: { google: 'Google(Gemini)', openai: 'OpenAI(GPT)', @@ -5282,6 +5353,42 @@ const resources = { copied: 'Đã sao chép!', copyCommand: 'Sao chép lệnh', }, + accountRouteGuide: { + title: 'Lộ trình hai tài khoản khuyến nghị', + description: + 'Dùng account profile riêng để giữ token tách biệt. Chỉ bật history sync khi cả hai tài khoản cần thấy cùng continuity cục bộ.', + status: { + empty: 'tạo tài khoản', + isolated: 'đã sẵn sàng tách biệt', + shared: 'chia sẻ context project', + deeper: 'deeper sync sẵn sàng', + mixed: 'thiết lập hỗn hợp', + }, + cards: { + isolated: { + title: 'Token vẫn tách biệt', + desc: 'Mỗi tài khoản ccs auth có Claude config, login và credential riêng. Cài đặt sync không bao giờ copy token.', + }, + select: { + title: 'Bạn chọn tài khoản cần dùng', + desc: 'Chạy ccs work hoặc ccs personal để chọn rõ tài khoản. Chỉ đặt Default khi plain ccs nên đi theo một tài khoản.', + }, + settings: { + title: 'Settings đi theo root', + desc: 'Tài khoản không bare link settings.json qua CCS shared state tới ~/.claude/settings.json. ccs auth show sẽ xác nhận.', + }, + sync: { + title: 'History sync là tùy chọn', + desc: 'Cả hai tài khoản cần Shared mode và cùng group. Deeper sync thêm các artifact continuity cục bộ.', + }, + }, + commands: { + isolated: 'Dùng tài khoản tách biệt', + sync: 'Chia sẻ lịch sử tùy chọn', + syncDesc: + 'Dùng cùng group, ví dụ "{{group}}", trên cả hai tài khoản khi muốn chia sẻ lịch sử cục bộ.', + }, + }, cliproxyModelCategory: { google: 'Google (Gemini)', openai: 'OpenAI (GPT)', @@ -7848,6 +7955,42 @@ const resources = { copied: 'コピーしました!', copyCommand: 'コマンドをコピー', }, + accountRouteGuide: { + title: '推奨される 2 アカウント構成', + description: + 'トークンを分離するため、別々の account profile を使います。両方のアカウントで同じローカル継続性を見たい場合だけ履歴同期を有効にします。', + status: { + empty: 'アカウントを作成', + isolated: '分離準備済み', + shared: 'プロジェクト文脈を共有', + deeper: '拡張同期準備済み', + mixed: '混在設定', + }, + cards: { + isolated: { + title: 'トークンは分離されたまま', + desc: '各 ccs auth アカウントは専用の Claude 設定、ログイン、認証情報を持ちます。同期設定でトークンはコピーされません。', + }, + select: { + title: '使うアカウントを選ぶ', + desc: 'ccs work または ccs personal を実行して明示的に選びます。通常の ccs を特定アカウントに合わせたい場合だけ既定を設定します。', + }, + settings: { + title: '設定はルートに追従', + desc: '非 bare アカウントは CCS shared state 経由で settings.json を ~/.claude/settings.json にリンクします。ccs auth show で確認できます。', + }, + sync: { + title: '履歴同期は任意', + desc: '両方のアカウントで共有モードと同じグループが必要です。拡張同期では追加のローカル継続性ファイルも共有します。', + }, + }, + commands: { + isolated: '分離アカウントの使用', + sync: '任意の共有履歴', + syncDesc: + 'ローカル履歴を共有したい場合は、両方のアカウントで "{{group}}" など同じグループを使ってください。', + }, + }, cliproxyModelCategory: { google: 'Google (Gemini)', openai: 'OpenAI (GPT)', diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index 2f86ad2e..2e729551 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -7,6 +7,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { AlertTriangle, ArrowRight, Plus, Users, Zap } from 'lucide-react'; import { AccountsTable } from '@/components/account/accounts-table'; +import { AccountRouteGuideCard } from '@/components/account/account-route-guide-card'; import { ContinuityOverview } from '@/components/account/continuity-overview'; import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog'; import { CopyButton } from '@/components/ui/copy-button'; @@ -151,10 +152,11 @@ export function AccountsPage() {
- ccs auth create work --context-group sprint-a --deeper-continuity + ccs auth create work --share-context --context-group sprint-a + --deeper-continuity
@@ -205,6 +207,13 @@ export function AccountsPage() { plainCcsLane={plainCcsLane} /> + + {t('accountsPage.accountMatrix')} @@ -283,6 +292,13 @@ export function AccountsPage() { plainCcsLane={plainCcsLane} /> + + {t('accountsPage.accountMatrix')} diff --git a/ui/tests/unit/components/account/account-route-guide-card.test.tsx b/ui/tests/unit/components/account/account-route-guide-card.test.tsx new file mode 100644 index 00000000..aa83201a --- /dev/null +++ b/ui/tests/unit/components/account/account-route-guide-card.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import i18n from '@/lib/i18n'; +import { AccountRouteGuideCard } from '@/components/account/account-route-guide-card'; +import { render, screen } from '@tests/setup/test-utils'; + +describe('AccountRouteGuideCard', () => { + it('explains explicit two-account usage while tokens stay isolated', async () => { + await i18n.changeLanguage('en'); + + render( + + ); + + expect(screen.getByText('Recommended two-account route')).toBeInTheDocument(); + expect(screen.getByText('isolated ready')).toBeInTheDocument(); + expect(screen.getByText('Tokens stay separate')).toBeInTheDocument(); + expect(screen.getByText('Settings follow root')).toBeInTheDocument(); + expect(screen.getByText('ccs auth create work')).toBeInTheDocument(); + expect(screen.getByText('ccs auth create personal')).toBeInTheDocument(); + expect(screen.getByText('ccs work')).toBeInTheDocument(); + expect(screen.getByText('ccs personal')).toBeInTheDocument(); + }); + + it('uses the active deeper-ready group in optional sync guidance', async () => { + await i18n.changeLanguage('en'); + + render( + + ); + + expect(screen.getByText('deeper sync ready')).toBeInTheDocument(); + expect( + screen.getByText( + 'ccs auth create work2 --share-context --context-group sprint-a --deeper-continuity' + ) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Use the same group, such as "sprint-a", on both accounts when you want them to share local history.' + ) + ).toBeInTheDocument(); + }); +}); From a45b692c7a5f468f06828a19cdeb10aeafe4d892 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 13:36:45 -0400 Subject: [PATCH 07/38] chore(release): 7.77.1-dev.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a481d83d..aff9408d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1-dev.2", + "version": "7.77.1-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From ba18b68494a7b0563786d582d7e3d344238fbaf2 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Wed, 6 May 2026 17:35:00 -0400 Subject: [PATCH 08/38] fix: count CLIProxy OAuth usage in dashboard stats (#1190) * fix: count CLIProxy OAuth usage in dashboard stats * fix: fill cliproxy oauth usage details from logs * fix: keep mixed cliproxy oauth usage details * fix: avoid aggregate usage enrichment inflation * fix: cover oauth source prefixes and full log scans * fix: preserve distinct oauth usage details * fix: dedupe oauth log usage by auth identity * fix: drain usage queue and preserve repeated oauth requests * fix: guard usage queue drain against repeated full batches * fix(cliproxy): harden oauth usage stats merging --- .../services/__tests__/stats-fetcher.test.ts | 1436 +++++++++++++++++ .../__tests__/stats-transformer.test.ts | 207 +++ .../services/oauth-usage-log-transformer.ts | 196 +++ src/cliproxy/services/stats-fetcher.ts | 221 ++- src/cliproxy/services/stats-transformer.ts | 118 +- .../usage-compatibility-transformer.ts | 632 ++++++++ 6 files changed, 2767 insertions(+), 43 deletions(-) create mode 100644 src/cliproxy/services/__tests__/stats-fetcher.test.ts create mode 100644 src/cliproxy/services/oauth-usage-log-transformer.ts create mode 100644 src/cliproxy/services/usage-compatibility-transformer.ts diff --git a/src/cliproxy/services/__tests__/stats-fetcher.test.ts b/src/cliproxy/services/__tests__/stats-fetcher.test.ts new file mode 100644 index 00000000..b6e59769 --- /dev/null +++ b/src/cliproxy/services/__tests__/stats-fetcher.test.ts @@ -0,0 +1,1436 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { runWithScopedConfigDir } from '../../../utils/config-manager'; +import { __testExports, fetchCliproxyStats, fetchCliproxyUsageRaw } from '../stats-fetcher'; +import { mergeUsageResponseWithMissingDetails } from '../usage-compatibility-transformer'; + +const originalFetch = globalThis.fetch; +const originalDateNow = Date.now; + +let ccsDir = ''; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function requestUrl(input: RequestInfo | URL): string { + return input instanceof Request ? input.url : String(input); +} + +function createCodexQueueRecord() { + return { + timestamp: '2026-05-05T18:45:00.000Z', + provider: 'codex', + model: 'gpt-5.5', + alias: 'gpt-5.5', + source: 'user@example.com', + auth_index: 'codex-auth', + tokens: { + input_tokens: 12, + output_tokens: 8, + reasoning_tokens: 0, + cached_tokens: 3, + total_tokens: 23, + }, + failed: false, + }; +} + +function localTimestampIso(datePart: string, timePart: string): string { + const [year, month, day] = datePart.split('-').map(Number); + const [hour, minute, second] = timePart.split(':').map(Number); + return new Date(year, month - 1, day, hour, minute, second).toISOString(); +} + +function writeCliproxyMainLog(lines: string[]): void { + const logsDir = path.join(ccsDir, 'cliproxy', 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + fs.writeFileSync(path.join(logsDir, 'main.log'), `${lines.join('\n')}\n`, 'utf-8'); +} + +beforeEach(() => { + ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-stats-fetcher-')); + __testExports.clearCachedUsageQueueResponse(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + Date.now = originalDateNow; + __testExports.clearCachedUsageQueueResponse(); + fs.rmSync(ccsDir, { recursive: true, force: true }); +}); + +describe('fetchCliproxyUsageRaw', () => { + it('falls back to CLIProxy usage-queue when the legacy aggregate endpoint is unavailable', async () => { + const requestedUrls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + requestedUrls.push(url); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse([createCodexQueueRecord()]); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19201)); + + expect(requestedUrls.some((url) => url.endsWith('/v0/management/usage'))).toBe(true); + expect(requestedUrls.some((url) => url.includes('/v0/management/usage-queue'))).toBe(true); + expect(raw?.usage?.total_requests).toBe(1); + expect(raw?.usage?.success_count).toBe(1); + expect(raw?.usage?.total_tokens).toBe(23); + expect(raw?.usage?.apis?.codex.models?.['gpt-5.5'].details?.[0]).toMatchObject({ + source: 'user@example.com', + auth_index: 'codex-auth', + failed: false, + }); + }); + + it('uses queue details to enrich successful legacy aggregate usage without details', async () => { + const requestedUrls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + requestedUrls.push(url); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ + failed_requests: 0, + usage: { + total_requests: 3, + success_count: 3, + failure_count: 0, + total_tokens: 69, + apis: { + codex: { + total_requests: 3, + total_tokens: 69, + models: {}, + }, + }, + }, + }); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse([createCodexQueueRecord()]); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19215)); + + expect(requestedUrls.some((url) => url.endsWith('/v0/management/usage'))).toBe(true); + expect(requestedUrls.some((url) => url.includes('/v0/management/usage-queue'))).toBe(true); + expect(raw?.usage?.total_requests).toBe(3); + expect(raw?.usage?.success_count).toBe(3); + expect(raw?.usage?.total_tokens).toBe(69); + expect(raw?.usage?.apis?.codex.models?.['gpt-5.5'].details).toHaveLength(1); + }); + + it('drains CLIProxy usage-queue batches until the queue is exhausted', async () => { + let queueCalls = 0; + const firstBatch = Array.from({ length: 1000 }, (_, index) => ({ + ...createCodexQueueRecord(), + timestamp: `2026-05-05T18:${String(index % 60).padStart(2, '0')}:00.000Z`, + source: `oauth|codex-user-${index}@example.com-pro.json`, + auth_index: `codex-auth-${index}`, + })); + const finalBatch = [ + { + ...createCodexQueueRecord(), + timestamp: '2026-05-05T19:45:00.000Z', + source: 'oauth|codex-final@example.com-pro.json', + auth_index: 'codex-auth-final', + }, + ]; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + queueCalls++; + return jsonResponse(queueCalls === 1 ? firstBatch : finalBatch); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19212)); + + expect(queueCalls).toBe(2); + expect(raw?.usage?.total_requests).toBe(1001); + expect(raw?.usage?.total_tokens).toBe(23 * 1001); + expect(raw?.usage?.apis?.codex.models?.['gpt-5.5'].details).toHaveLength(1001); + }); + + it('falls back instead of reporting a repeated full queue batch as complete', async () => { + let queueCalls = 0; + let apiKeyUsageCalls = 0; + const fullBatch = Array.from({ length: 1000 }, (_, index) => ({ + ...createCodexQueueRecord(), + timestamp: `2026-05-05T18:${String(index % 60).padStart(2, '0')}:00.000Z`, + source: `oauth|codex-user-${index}@example.com-pro.json`, + auth_index: `codex-auth-${index}`, + })); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + queueCalls++; + return jsonResponse(fullBatch); + } + if (url.endsWith('/v0/management/api-key-usage')) { + apiKeyUsageCalls++; + return jsonResponse({ + codex: { + 'oauth|codex-user@example.com-pro.json': { + success: 3, + failed: 0, + }, + }, + }); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19213)); + + expect(queueCalls).toBe(2); + expect(apiKeyUsageCalls).toBe(1); + expect(raw?.usage?.total_requests).toBe(3); + expect(raw?.usage?.apis?.codex.models?.['gpt-5.5'].details).toHaveLength(3); + }); + + it('falls back instead of reporting a transiently failed queue drain as complete', async () => { + let queueCalls = 0; + let apiKeyUsageCalls = 0; + const fullBatch = Array.from({ length: 1000 }, (_, index) => ({ + ...createCodexQueueRecord(), + timestamp: `2026-05-05T18:${String(index % 60).padStart(2, '0')}:00.000Z`, + source: `oauth|codex-user-${index}@example.com-pro.json`, + auth_index: `codex-auth-${index}`, + })); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + queueCalls++; + if (queueCalls === 1) { + return jsonResponse(fullBatch); + } + throw new Error('temporary queue read failure'); + } + if (url.endsWith('/v0/management/api-key-usage')) { + apiKeyUsageCalls++; + return jsonResponse({ + codex: { + 'oauth|codex-user@example.com-pro.json': { + success: 4, + failed: 1, + }, + }, + }); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19214)); + + expect(queueCalls).toBe(2); + expect(apiKeyUsageCalls).toBe(1); + expect(raw?.usage?.total_requests).toBe(5); + expect(raw?.usage?.success_count).toBe(4); + expect(raw?.usage?.failure_count).toBe(1); + expect(raw?.usage?.apis?.codex.models?.['gpt-5.5'].details).toHaveLength(5); + }); + + it('falls back when usage queue draining exceeds the overall deadline', async () => { + let queueCalls = 0; + let apiKeyUsageCalls = 0; + Date.now = (() => (queueCalls === 0 ? 0 : 31_000)) as typeof Date.now; + const fullBatch = Array.from({ length: 1000 }, (_, index) => ({ + ...createCodexQueueRecord(), + timestamp: `2026-05-05T18:${String(index % 60).padStart(2, '0')}:00.000Z`, + source: `user-${index}@example.com`, + auth_index: `codex-auth-${index}`, + })); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + queueCalls++; + return jsonResponse(fullBatch); + } + if (url.endsWith('/v0/management/api-key-usage')) { + apiKeyUsageCalls++; + return jsonResponse({ + codex: { + 'oauth|codex-user@example.com-pro.json': { + success: 6, + failed: 0, + }, + }, + }); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19217)); + + expect(queueCalls).toBe(1); + expect(apiKeyUsageCalls).toBe(1); + expect(raw?.usage?.total_requests).toBe(6); + expect(raw?.usage?.apis?.codex.models?.['gpt-5.5'].details).toHaveLength(6); + }); + + it('keeps queue stats available after the same CLIProxy usage queue has been drained', async () => { + let queueCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + queueCalls++; + return jsonResponse(queueCalls === 1 ? [createCodexQueueRecord()] : []); + } + if (url.endsWith('/v0/management/api-key-usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const firstRaw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19204)); + const secondRaw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19204)); + + expect(firstRaw?.usage?.total_requests).toBe(1); + expect(secondRaw?.usage?.total_requests).toBe(1); + expect(secondRaw?.usage?.apis?.codex.models?.['gpt-5.5'].details).toHaveLength(1); + }); + + it('uses fresh API-key totals before cached queue details after the queue drains', async () => { + let queueCalls = 0; + let apiKeyUsageCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + queueCalls++; + return jsonResponse(queueCalls === 1 ? [createCodexQueueRecord()] : []); + } + if (url.endsWith('/v0/management/api-key-usage')) { + apiKeyUsageCalls++; + return jsonResponse({ + codex: { + 'oauth|codex-user@example.com-pro.json': { + success: 5, + failed: 0, + }, + }, + }); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const firstRaw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19216)); + const secondRaw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19216)); + + expect(firstRaw?.usage?.total_requests).toBe(1); + expect(apiKeyUsageCalls).toBe(1); + expect(secondRaw?.usage?.total_requests).toBe(5); + expect(secondRaw?.usage?.apis?.codex.models?.['gpt-5.5'].details).toHaveLength(1); + }); + + it('does not reuse drained usage-queue stats for a different CLIProxy management URL', async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse(url.includes(':19205') ? [createCodexQueueRecord()] : []); + } + if (url.endsWith('/v0/management/api-key-usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const firstRaw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19205)); + const secondRaw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19206)); + + expect(firstRaw?.usage?.total_requests).toBe(1); + expect(secondRaw?.usage?.total_requests).toBe(0); + expect(secondRaw?.usage?.apis).toEqual({}); + }); + + it('uses API-key usage totals when neither aggregate nor queue usage is available', async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.endsWith('/v0/management/api-key-usage')) { + return jsonResponse({ + openai: { + 'https://api.example.test|sk-redacted': { + success: 2, + failed: 1, + }, + }, + }); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19202)); + + expect(raw?.usage?.total_requests).toBe(3); + expect(raw?.usage?.success_count).toBe(2); + expect(raw?.usage?.failure_count).toBe(1); + expect(raw?.usage?.apis?.openai.total_requests).toBe(3); + expect(raw?.usage?.apis?.openai.models).toEqual({}); + }); + + it('merges local OAuth log usage when management endpoints have only aggregate API-key totals', async () => { + writeCliproxyMainLog([ + '2026-05-05T18:45:00.000Z INFO request_id=req-1 Use OAuth provider=codex auth_file=codex-user@example.com-pro.json for model gpt-5.5', + '2026-05-05T18:45:01.000Z INFO request_id=req-1 POST "/api/provider/codex/v1/messages?beta=true" status=200', + ]); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse([]); + } + if (url.endsWith('/v0/management/api-key-usage')) { + return jsonResponse({ + openai: { + 'https://api.example.test|sk-redacted': { + success: 2, + failed: 0, + }, + }, + }); + } + if (url.endsWith('/v0/management/auth-files')) { + return jsonResponse({ files: [] }); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const stats = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyStats(19207)); + + expect(stats?.totalRequests).toBe(3); + expect(stats?.successCount).toBe(3); + expect(stats?.requestsByProvider).toEqual({ openai: 2, codex: 1 }); + expect(stats?.requestsByModel).toEqual({ 'gpt-5.5': 1 }); + expect(stats?.accountStats['codex:user@example.com']).toMatchObject({ + provider: 'codex', + source: 'user@example.com', + successCount: 1, + failureCount: 0, + }); + }); + + it('adds missing OAuth details to matching API-key provider totals without double-counting', async () => { + writeCliproxyMainLog([ + '2026-05-05T18:45:00.000Z INFO request_id=req-1 Use OAuth provider=codex auth_file=codex-user@example.com-pro.json for model gpt-5.5', + '2026-05-05T18:45:01.000Z INFO request_id=req-1 POST "/api/provider/codex/v1/messages?beta=true" status=200', + ]); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse([]); + } + if (url.endsWith('/v0/management/api-key-usage')) { + return jsonResponse({ + codex: { + 'oauth|codex-user@example.com-pro.json': { + success: 1, + failed: 0, + }, + }, + }); + } + if (url.endsWith('/v0/management/auth-files')) { + return jsonResponse({ files: [] }); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const stats = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyStats(19209)); + + expect(stats?.totalRequests).toBe(1); + expect(stats?.successCount).toBe(1); + expect(stats?.requestsByProvider).toEqual({ codex: 1 }); + expect(stats?.requestsByModel).toEqual({ 'gpt-5.5': 1 }); + expect(stats?.accountStats['codex:user@example.com']).toMatchObject({ + provider: 'codex', + source: 'user@example.com', + successCount: 1, + failureCount: 0, + }); + }); + + it('keeps log-derived OAuth details when the same provider already has other details', async () => { + writeCliproxyMainLog([ + '2026-05-05T18:45:00.000Z INFO request_id=req-1 Use OAuth provider=codex auth_file=codex-user@example.com-pro.json for model gpt-5.5', + '2026-05-05T18:45:01.000Z INFO request_id=req-1 POST "/api/provider/codex/v1/messages?beta=true" status=200', + ]); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 7, + apis: { + codex: { + total_requests: 1, + total_tokens: 7, + models: { + 'gpt-4o': { + total_requests: 1, + total_tokens: 7, + details: [ + { + timestamp: '2026-05-05T18:44:00.000Z', + source: 'api-key|sk-redacted', + auth_index: 'api-key|sk-redacted', + tokens: { + input_tokens: 4, + output_tokens: 3, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 7, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }); + } + if (url.endsWith('/v0/management/auth-files')) { + return jsonResponse({ files: [] }); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const stats = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyStats(19210)); + + expect(stats?.totalRequests).toBe(2); + expect(stats?.successCount).toBe(2); + expect(stats?.requestsByProvider).toEqual({ codex: 2 }); + expect(stats?.requestsByModel).toEqual({ 'gpt-4o': 1, 'gpt-5.5': 1 }); + expect(stats?.accountStats['codex:user@example.com']).toMatchObject({ + provider: 'codex', + source: 'user@example.com', + successCount: 1, + failureCount: 0, + }); + }); + + it('does not double-count local OAuth logs when usage queue already has provider details', async () => { + writeCliproxyMainLog([ + '2026-05-05T18:45:00.000Z INFO request_id=req-1 Use OAuth provider=codex auth_file=codex-user@example.com-pro.json for model gpt-5.5', + '2026-05-05T18:45:01.000Z INFO request_id=req-1 POST "/api/provider/codex/v1/messages?beta=true" status=200', + ]); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse([ + { + ...createCodexQueueRecord(), + source: 'user@example.com', + auth_index: 'codex-auth', + }, + ]); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19208)); + + expect(raw?.usage?.total_requests).toBe(1); + expect(raw?.usage?.total_tokens).toBe(23); + expect(raw?.usage?.apis?.codex.total_requests).toBe(1); + const details = raw?.usage?.apis?.codex.models?.['gpt-5.5'].details; + expect(details).toHaveLength(1); + expect(details?.[0]?.tokens.total_tokens).toBe(23); + }); + + it('scans the full CLIProxy main log when OAuth selection and completion are far apart', async () => { + writeCliproxyMainLog([ + '2026-05-05T18:45:00.000Z INFO request_id=req-1 Use OAuth provider=codex auth_file=codex-user@example.com-pro.json for model gpt-5.5', + 'x'.repeat(1024 * 1024 + 64), + '2026-05-05T18:45:01.000Z INFO request_id=req-1 POST "/api/provider/codex/v1/messages?beta=true" status=200', + ]); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse([]); + } + if (url.endsWith('/v0/management/api-key-usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19211)); + + expect(raw?.usage?.total_requests).toBe(1); + expect(raw?.usage?.apis?.codex.models?.['gpt-5.5'].details?.[0]).toMatchObject({ + auth_index: 'codex-user@example.com-pro.json', + failed: false, + }); + }); + + it('parses bracketed CLIProxy main.log request ids and local timestamps', async () => { + writeCliproxyMainLog([ + '[2026-05-05 18:45:00] [req-a] [debug] Use OAuth provider=codex auth_file=codex-user-a@example.com-pro.json for model gpt-5.5', + '[2026-05-05 18:45:00] [req-b] [debug] Use OAuth provider=codex auth_file=codex-user-b@example.com-pro.json for model gpt-5.5', + '[2026-05-05 18:45:01] [req-b] [info ] POST "/api/provider/codex/v1/messages?beta=true" status=200', + '[2026-05-05 18:45:02] [req-a] [info ] POST "/api/provider/codex/v1/messages?beta=true" status=200', + ]); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse([]); + } + if (url.endsWith('/v0/management/api-key-usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19218)); + const details = raw?.usage?.apis?.codex.models?.['gpt-5.5'].details; + + expect(details?.map((detail) => detail.auth_index)).toEqual([ + 'codex-user-b@example.com-pro.json', + 'codex-user-a@example.com-pro.json', + ]); + expect(details?.map((detail) => detail.request_id)).toEqual(['req-b', 'req-a']); + expect(details?.map((detail) => detail.timestamp)).toEqual([ + localTimestampIso('2026-05-05', '18:45:01'), + localTimestampIso('2026-05-05', '18:45:02'), + ]); + }); + + it('removes request-id pending entries when FIFO log matching consumes them', async () => { + writeCliproxyMainLog([ + '2026-05-05T18:45:00.000Z INFO request_id=req-1 Use OAuth provider=codex auth_file=codex-user@example.com-pro.json for model gpt-5.5', + '2026-05-05T18:45:01.000Z INFO POST "/api/provider/codex/v1/messages?beta=true" status=200', + '2026-05-05T18:45:02.000Z INFO request_id=req-1 POST "/api/provider/codex/v1/messages?beta=true" status=200', + ]); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse([]); + } + if (url.endsWith('/v0/management/api-key-usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19219)); + const details = raw?.usage?.apis?.codex.models?.['gpt-5.5'].details; + + expect(details).toHaveLength(1); + expect(details?.[0]?.request_id).toBe('req-1'); + }); + + it('removes overwritten request-id pending entries from provider FIFO queues', async () => { + writeCliproxyMainLog([ + '2026-05-05T18:45:00.000Z INFO request_id=req-1 Use OAuth provider=codex auth_file=codex-old@example.com-pro.json for model gpt-5.5', + '2026-05-05T18:45:01.000Z INFO request_id=req-1 Use OAuth provider=codex auth_file=codex-new@example.com-pro.json for model gpt-5.5', + '2026-05-05T18:45:02.000Z INFO request_id=req-1 POST "/api/provider/codex/v1/messages?beta=true" status=200', + '2026-05-05T18:45:03.000Z INFO POST "/api/provider/codex/v1/messages?beta=true" status=200', + ]); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse([]); + } + if (url.endsWith('/v0/management/api-key-usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19220)); + const details = raw?.usage?.apis?.codex.models?.['gpt-5.5'].details; + + expect(details).toHaveLength(1); + expect(details?.[0]?.auth_index).toBe('codex-new@example.com-pro.json'); + }); + + it('does not inflate token totals when enriching already-counted aggregate requests', () => { + const merged = mergeUsageResponseWithMissingDetails( + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 11, + apis: { + codex: { + total_requests: 1, + total_tokens: 11, + models: {}, + }, + }, + }, + }, + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 11, + apis: { + codex: { + total_requests: 1, + total_tokens: 11, + models: { + 'gpt-5.5': { + total_requests: 1, + total_tokens: 11, + details: [ + { + timestamp: '2026-05-05T18:45:01.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + tokens: { + input_tokens: 6, + output_tokens: 5, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 11, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + } + ); + + expect(merged.usage?.total_requests).toBe(1); + expect(merged.usage?.total_tokens).toBe(11); + expect(merged.usage?.apis?.codex.total_requests).toBe(1); + expect(merged.usage?.apis?.codex.total_tokens).toBe(11); + expect(merged.usage?.apis?.codex.models?.['gpt-5.5'].total_requests).toBe(1); + expect(merged.usage?.apis?.codex.models?.['gpt-5.5'].total_tokens).toBe(0); + expect(merged.usage?.apis?.codex.models?.['gpt-5.5'].details).toHaveLength(1); + }); + + it('does not inflate model requests when enriching incomplete aggregate model details', () => { + const merged = mergeUsageResponseWithMissingDetails( + { + failed_requests: 0, + usage: { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 22, + apis: { + codex: { + total_requests: 2, + total_tokens: 22, + models: { + 'gpt-5.5': { + total_requests: 2, + total_tokens: 22, + details: [ + { + timestamp: '2026-05-05T18:45:01.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + tokens: { + input_tokens: 6, + output_tokens: 5, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 11, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }, + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 11, + apis: { + codex: { + total_requests: 1, + total_tokens: 11, + models: { + 'gpt-5.5': { + total_requests: 1, + total_tokens: 11, + details: [ + { + timestamp: '2026-05-05T18:46:01.000Z', + source: 'provider=codex auth_file=codex-user@example.com-plus.json', + auth_index: 'codex-user@example.com-plus.json', + tokens: { + input_tokens: 7, + output_tokens: 4, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 11, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + } + ); + + const modelBucket = merged.usage?.apis?.codex.models?.['gpt-5.5']; + expect(merged.usage?.total_requests).toBe(2); + expect(merged.usage?.total_tokens).toBe(22); + expect(merged.usage?.apis?.codex.total_requests).toBe(2); + expect(merged.usage?.apis?.codex.total_tokens).toBe(22); + expect(modelBucket?.total_requests).toBe(2); + expect(modelBucket?.total_tokens).toBe(22); + expect(modelBucket?.details).toHaveLength(2); + }); + + it('fills aggregate detail gaps for repeated requests from the same account', () => { + const merged = mergeUsageResponseWithMissingDetails( + { + failed_requests: 0, + usage: { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 24, + apis: { + codex: { + total_requests: 2, + total_tokens: 24, + models: { + 'gpt-5.5': { + total_requests: 2, + total_tokens: 24, + details: [ + { + timestamp: '2026-05-05T18:45:01.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + tokens: { + input_tokens: 6, + output_tokens: 5, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 11, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }, + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 13, + apis: { + codex: { + total_requests: 1, + total_tokens: 13, + models: { + 'gpt-5.5': { + total_requests: 1, + total_tokens: 13, + details: [ + { + timestamp: '2026-05-05T18:46:01.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + tokens: { + input_tokens: 8, + output_tokens: 5, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 13, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + } + ); + + const modelBucket = merged.usage?.apis?.codex.models?.['gpt-5.5']; + expect(merged.usage?.total_requests).toBe(2); + expect(merged.usage?.total_tokens).toBe(24); + expect(modelBucket?.total_requests).toBe(2); + expect(modelBucket?.total_tokens).toBe(24); + expect(modelBucket?.details?.map((detail) => detail.timestamp)).toEqual([ + '2026-05-05T18:45:01.000Z', + '2026-05-05T18:46:01.000Z', + ]); + }); + + it('does not identity-dedupe tokenless logs before incomplete model detail gaps are filled', () => { + const merged = mergeUsageResponseWithMissingDetails( + { + failed_requests: 0, + usage: { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 23, + apis: { + codex: { + total_requests: 2, + total_tokens: 23, + models: { + 'gpt-5.5': { + total_requests: 2, + total_tokens: 23, + details: [ + { + timestamp: '2026-05-05T18:45:01.000Z', + source: 'user@example.com', + auth_index: 'codex-auth', + tokens: { + input_tokens: 12, + output_tokens: 8, + reasoning_tokens: 0, + cached_tokens: 3, + total_tokens: 23, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }, + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 0, + apis: { + codex: { + total_requests: 1, + total_tokens: 0, + models: { + 'gpt-5.5': { + total_requests: 1, + total_tokens: 0, + details: [ + { + timestamp: '2026-05-05T18:45:03.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + tokens: { + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 0, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + } + ); + + const modelBucket = merged.usage?.apis?.codex.models?.['gpt-5.5']; + expect(merged.usage?.total_requests).toBe(2); + expect(modelBucket?.total_requests).toBe(2); + expect(modelBucket?.details).toHaveLength(2); + }); + + it('dedupes tokenless local OAuth logs against delayed complete management details', () => { + const merged = mergeUsageResponseWithMissingDetails( + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 23, + apis: { + codex: { + total_requests: 1, + total_tokens: 23, + models: { + 'gpt-5.5': { + total_requests: 1, + total_tokens: 23, + details: [ + { + timestamp: '2026-05-05T18:47:30.000Z', + source: 'user@example.com', + auth_index: 'codex-auth', + request_id: 'req-1', + tokens: { + input_tokens: 12, + output_tokens: 8, + reasoning_tokens: 0, + cached_tokens: 3, + total_tokens: 23, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }, + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 0, + apis: { + codex: { + total_requests: 1, + total_tokens: 0, + models: { + 'gpt-5.5': { + total_requests: 1, + total_tokens: 0, + details: [ + { + timestamp: '2026-05-05T18:45:01.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + request_id: 'req-1', + tokens: { + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 0, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + } + ); + + const modelBucket = merged.usage?.apis?.codex.models?.['gpt-5.5']; + expect(merged.usage?.total_requests).toBe(1); + expect(merged.usage?.total_tokens).toBe(23); + expect(modelBucket?.total_requests).toBe(1); + expect(modelBucket?.details).toHaveLength(1); + expect(modelBucket?.details?.[0]?.tokens.total_tokens).toBe(23); + }); + + it('does not dedupe distinct Codex auth variants for the same email', () => { + const merged = mergeUsageResponseWithMissingDetails( + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 23, + apis: { + codex: { + total_requests: 1, + total_tokens: 23, + models: { + 'gpt-5.5': { + total_requests: 1, + total_tokens: 23, + details: [ + { + timestamp: '2026-05-05T18:45:01.000Z', + source: 'user@example.com', + auth_index: 'codex-pro-index', + request_id: 'req-pro', + tokens: { + input_tokens: 12, + output_tokens: 8, + reasoning_tokens: 0, + cached_tokens: 3, + total_tokens: 23, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }, + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 0, + apis: { + codex: { + total_requests: 1, + total_tokens: 0, + models: { + 'gpt-5.5': { + total_requests: 1, + total_tokens: 0, + details: [ + { + timestamp: '2026-05-05T18:45:07.000Z', + source: 'provider=codex auth_file=codex-user@example.com-free.json', + auth_index: 'codex-user@example.com-free.json', + request_id: 'req-free', + tokens: { + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 0, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + } + ); + + const modelBucket = merged.usage?.apis?.codex.models?.['gpt-5.5']; + expect(merged.usage?.total_requests).toBe(2); + expect(modelBucket?.total_requests).toBe(2); + expect(modelBucket?.details).toHaveLength(2); + }); + + it('dedupes duplicate logs before filling partial aggregate detail gaps', () => { + const merged = mergeUsageResponseWithMissingDetails( + { + failed_requests: 0, + usage: { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 23, + apis: { + codex: { + total_requests: 2, + total_tokens: 23, + models: { + 'gpt-5.5': { + total_requests: 2, + total_tokens: 23, + details: [ + { + timestamp: '2026-05-05T18:45:01.000Z', + source: 'user@example.com', + auth_index: 'codex-auth', + request_id: 'req-1', + tokens: { + input_tokens: 12, + output_tokens: 8, + reasoning_tokens: 0, + cached_tokens: 3, + total_tokens: 23, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }, + { + failed_requests: 0, + usage: { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 0, + apis: { + codex: { + total_requests: 2, + total_tokens: 0, + models: { + 'gpt-5.5': { + total_requests: 2, + total_tokens: 0, + details: [ + { + timestamp: '2026-05-05T18:45:02.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + request_id: 'req-1', + tokens: { + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 0, + }, + failed: false, + }, + { + timestamp: '2026-05-05T18:45:10.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + request_id: 'req-2', + tokens: { + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 0, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + } + ); + + const modelBucket = merged.usage?.apis?.codex.models?.['gpt-5.5']; + expect(merged.usage?.total_requests).toBe(2); + expect(modelBucket?.total_requests).toBe(2); + expect(modelBucket?.details?.map((detail) => detail.request_id)).toEqual(['req-1', 'req-2']); + }); + + it('keeps distinct complete requests from the same account and model', () => { + const merged = mergeUsageResponseWithMissingDetails( + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 11, + apis: { + codex: { + total_requests: 1, + total_tokens: 11, + models: { + 'gpt-5.5': { + total_requests: 1, + total_tokens: 11, + details: [ + { + timestamp: '2026-05-05T18:45:01.000Z', + source: 'user@example.com', + auth_index: 'codex-auth', + tokens: { + input_tokens: 6, + output_tokens: 5, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 11, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }, + { + failed_requests: 0, + usage: { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 13, + apis: { + codex: { + total_requests: 1, + total_tokens: 13, + models: { + 'gpt-5.5': { + total_requests: 1, + total_tokens: 13, + details: [ + { + timestamp: '2026-05-05T18:46:01.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + tokens: { + input_tokens: 8, + output_tokens: 5, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 13, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + } + ); + + const modelBucket = merged.usage?.apis?.codex.models?.['gpt-5.5']; + expect(merged.usage?.total_requests).toBe(2); + expect(merged.usage?.total_tokens).toBe(24); + expect(modelBucket?.total_requests).toBe(2); + expect(modelBucket?.total_tokens).toBe(24); + expect(modelBucket?.details).toHaveLength(2); + }); +}); + +describe('fetchCliproxyStats', () => { + it('builds account stats from queue records and normalizes OAuth auth filenames', async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url.endsWith('/v0/management/usage')) { + return jsonResponse({ error: 'not found' }, 404); + } + if (url.includes('/v0/management/usage-queue?count=1000')) { + return jsonResponse([createCodexQueueRecord()]); + } + if (url.endsWith('/v0/management/auth-files')) { + return jsonResponse({ files: [] }); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + const stats = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyStats(19203)); + + expect(stats?.totalRequests).toBe(1); + expect(stats?.requestsByProvider).toEqual({ codex: 1 }); + expect(stats?.requestsByModel).toEqual({ 'gpt-5.5': 1 }); + expect(stats?.accountStats['codex:user@example.com']).toMatchObject({ + accountKey: 'codex:user@example.com', + provider: 'codex', + source: 'user@example.com', + successCount: 1, + failureCount: 0, + totalTokens: 23, + }); + }); +}); diff --git a/src/cliproxy/services/__tests__/stats-transformer.test.ts b/src/cliproxy/services/__tests__/stats-transformer.test.ts index 3627d56a..54d408b9 100644 --- a/src/cliproxy/services/__tests__/stats-transformer.test.ts +++ b/src/cliproxy/services/__tests__/stats-transformer.test.ts @@ -334,4 +334,211 @@ describe('buildCliproxyStatsFromUsageResponse', () => { failureCount: 1, }); }); + + it('normalizes OAuth auth filenames before building account stats keys', () => { + const usage: CliproxyUsageApiResponse = { + usage: { + total_requests: 1, + apis: { + codex: { + total_requests: 1, + models: { + 'gpt-5.5': { + total_requests: 1, + details: [ + createDetail({ + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + }), + ], + }, + }, + }, + }, + }, + }; + + const stats = buildCliproxyStatsFromUsageResponse(usage); + + expect(stats.accountStats['codex:user@example.com']).toMatchObject({ + provider: 'codex', + source: 'user@example.com', + successCount: 1, + failureCount: 0, + }); + }); + + it('normalizes OAuth-prefixed auth filename sources before building account stats keys', () => { + const usage: CliproxyUsageApiResponse = { + usage: { + total_requests: 2, + apis: { + codex: { + total_requests: 2, + models: { + 'gpt-5.5': { + total_requests: 2, + details: [ + createDetail({ + source: 'oauth|codex-user@example.com-pro.json', + auth_index: 'oauth|codex-user@example.com-pro.json', + }), + createDetail({ + source: 'provider=codex auth_file=oauth|codex-user@example.com-pro.json', + auth_index: 'oauth|codex-user@example.com-pro.json', + timestamp: '2025-03-26T10:01:00.000Z', + }), + ], + }, + }, + }, + }, + }, + }; + + const stats = buildCliproxyStatsFromUsageResponse(usage); + + expect(stats.accountStats['codex:user@example.com']).toMatchObject({ + provider: 'codex', + source: 'user@example.com', + successCount: 2, + failureCount: 0, + }); + expect(stats.accountStats['codex:oauth|codex-user@example.com']).toBeUndefined(); + }); + + it('uses detail-derived success and failure counts when aggregate counters are stale', () => { + const usage: CliproxyUsageApiResponse = { + failed_requests: 0, + usage: { + total_requests: 2, + success_count: 0, + failure_count: 0, + apis: { + codex: { + total_requests: 2, + models: { + 'gpt-5.5': { + total_requests: 2, + details: [ + createDetail({ + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + }), + createDetail({ + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + timestamp: '2025-03-26T10:01:00.000Z', + failed: true, + }), + ], + }, + }, + }, + }, + }, + }; + + const stats = buildCliproxyStatsFromUsageResponse(usage); + + expect(stats.successCount).toBe(1); + expect(stats.failureCount).toBe(1); + expect(stats.quotaExceededCount).toBe(1); + expect(stats.accountStats['codex:user@example.com']).toMatchObject({ + successCount: 1, + failureCount: 1, + }); + }); + + it('uses detail-derived totals and latest timestamps when aggregate request counts are stale', () => { + const usage: CliproxyUsageApiResponse = { + failed_requests: 0, + usage: { + total_requests: 0, + success_count: 0, + failure_count: 0, + apis: { + codex: { + total_requests: 0, + models: { + 'gpt-5.5': { + total_requests: 0, + details: [ + createDetail({ + timestamp: '2025-03-26T10:02:00.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + failed: true, + }), + createDetail({ + timestamp: '2025-03-26T10:01:00.000Z', + source: 'provider=codex auth_file=codex-user@example.com-pro.json', + auth_index: 'codex-user@example.com-pro.json', + }), + ], + }, + }, + }, + }, + }, + }; + + const stats = buildCliproxyStatsFromUsageResponse(usage); + + expect(stats.totalRequests).toBe(2); + expect(stats.requestsByModel['gpt-5.5']).toBe(2); + expect(stats.requestsByProvider.codex).toBe(2); + expect(stats.successCount).toBe(1); + expect(stats.failureCount).toBe(1); + expect(stats.accountStats['codex:user@example.com']).toMatchObject({ + successCount: 1, + failureCount: 1, + lastUsedAt: '2025-03-26T10:02:00.000Z', + }); + }); + + it('does not strip plan-like suffixes from raw account identifiers', () => { + const usage: CliproxyUsageApiResponse = { + usage: { + total_requests: 2, + apis: { + codex: { + total_requests: 2, + models: { + 'gpt-5.5': { + total_requests: 2, + details: [ + createDetail({ + source: 'provider=codex auth_file=codex-user-free@example.com.json', + auth_index: 'codex-user-free@example.com.json', + }), + createDetail({ + source: 'codex-user@example.com-pro', + auth_index: 'codex-user@example.com-pro', + timestamp: '2025-03-26T10:01:00.000Z', + }), + ], + }, + }, + }, + }, + }, + }; + + const stats = buildCliproxyStatsFromUsageResponse(usage); + + expect(stats.accountStats['codex:user-free@example.com']).toMatchObject({ + provider: 'codex', + source: 'user-free@example.com', + successCount: 1, + failureCount: 0, + }); + expect(stats.accountStats['codex:user@example.com-pro']).toMatchObject({ + provider: 'codex', + source: 'user@example.com-pro', + successCount: 1, + failureCount: 0, + }); + expect(stats.accountStats['codex:user@example.com']).toBeUndefined(); + }); }); diff --git a/src/cliproxy/services/oauth-usage-log-transformer.ts b/src/cliproxy/services/oauth-usage-log-transformer.ts new file mode 100644 index 00000000..a99f5e99 --- /dev/null +++ b/src/cliproxy/services/oauth-usage-log-transformer.ts @@ -0,0 +1,196 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { getCliproxyWritablePath } from '../config/path-resolver'; +import type { CliproxyUsageApiResponse } from './stats-fetcher'; +import { + buildUsageResponseFromQueueRecords, + hasUsageDetails, +} from './usage-compatibility-transformer'; + +interface PendingOAuthRequest { + timestamp?: string; + provider: string; + model: string; + authFile: string; + requestId?: string; +} + +interface ProviderCompletion { + timestamp?: string; + provider: string; + requestId?: string; + failed: boolean; +} + +function unquote(value: string): string { + return value.trim().replace(/^['"]|['"]$/g, ''); +} + +function extractTimestamp(line: string): string | undefined { + const isoTimestamp = line.match(/\d{4}-\d{2}-\d{2}T[^\s\]]+/)?.[0]; + if (isoTimestamp) { + return isoTimestamp.replace(/[,\]]+$/, ''); + } + + const bracketedTimestamp = line + .match(/^\[(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})\]/) + ?.slice(1, 3); + if (!bracketedTimestamp) { + return undefined; + } + + const [datePart, timePart] = bracketedTimestamp; + const [year, month, day] = datePart.split('-').map(Number); + const [hour, minute, second] = timePart.split(':').map(Number); + return new Date(year, month - 1, day, hour, minute, second).toISOString(); +} + +function extractRequestId(line: string): string | undefined { + const explicitRequestId = line.match( + /\b(?:request[_-]?id|req[_-]?id|rid)[=:]\s*([A-Za-z0-9._:-]+)/i + )?.[1]; + if (explicitRequestId) { + return explicitRequestId; + } + + const bracketedRequestId = line.match(/^\[[^\]]+\]\s+\[([^\]]+)\]/)?.[1]?.trim(); + return bracketedRequestId && bracketedRequestId !== '--------' ? bracketedRequestId : undefined; +} + +function parseOAuthSelection(line: string): PendingOAuthRequest | null { + const match = line.match( + /Use OAuth\s+provider=([^\s]+)\s+auth_file=("[^"]+"|'[^']+'|[^\s]+)\s+for model\s+("[^"]+"|'[^']+'|[^\s]+)/i + ); + if (!match) { + return null; + } + + return { + timestamp: extractTimestamp(line), + provider: unquote(match[1] ?? 'unknown'), + authFile: unquote(match[2] ?? 'unknown'), + model: unquote(match[3] ?? 'unknown'), + requestId: extractRequestId(line), + }; +} + +function parseProviderCompletion(line: string): ProviderCompletion | null { + const match = line.match(/\b(?:POST|GET)\s+"?\/api\/provider\/([^/"\s]+)\//i); + if (!match) { + return null; + } + + return { + timestamp: extractTimestamp(line), + provider: unquote(match[1] ?? 'unknown'), + requestId: extractRequestId(line), + failed: + /\b(?:failed|failure|error)\b/i.test(line) || + /\bstatus[=:]\s*[45]\d\d\b/i.test(line) || + /\s[45]\d\d(?:\s|$)/.test(line), + }; +} + +function addPendingRequest( + pending: PendingOAuthRequest, + byRequestId: Map, + byProvider: Map +): void { + if (pending.requestId) { + const previous = byRequestId.get(pending.requestId); + if (previous) { + const previousProviderQueue = byProvider.get(previous.provider) ?? []; + byProvider.set( + previous.provider, + previousProviderQueue.filter((entry) => entry !== previous) + ); + } + byRequestId.set(pending.requestId, pending); + } + + const providerQueue = byProvider.get(pending.provider) ?? []; + providerQueue.push(pending); + byProvider.set(pending.provider, providerQueue); +} + +function takePendingRequest( + completion: ProviderCompletion, + byRequestId: Map, + byProvider: Map +): PendingOAuthRequest | null { + const byId = completion.requestId ? byRequestId.get(completion.requestId) : undefined; + if (byId) { + byRequestId.delete(completion.requestId ?? ''); + const providerQueue = byProvider.get(byId.provider) ?? []; + byProvider.set( + byId.provider, + providerQueue.filter((entry) => entry !== byId) + ); + return byId; + } + + const providerQueue = byProvider.get(completion.provider) ?? []; + const next = providerQueue.shift(); + byProvider.set(completion.provider, providerQueue); + if (next?.requestId) { + byRequestId.delete(next.requestId); + } + return next ?? null; +} + +export function buildUsageResponseFromCliproxyLogLines(lines: string[]): CliproxyUsageApiResponse { + const pendingByRequestId = new Map(); + const pendingByProvider = new Map(); + const records: unknown[] = []; + + for (const line of lines) { + const selection = parseOAuthSelection(line); + if (selection) { + addPendingRequest(selection, pendingByRequestId, pendingByProvider); + continue; + } + + const completion = parseProviderCompletion(line); + if (!completion) { + continue; + } + + const pending = takePendingRequest(completion, pendingByRequestId, pendingByProvider); + if (!pending) { + continue; + } + + records.push({ + timestamp: completion.timestamp ?? pending.timestamp ?? '1970-01-01T00:00:00.000Z', + provider: pending.provider, + model: pending.model, + source: `provider=${pending.provider} auth_file=${pending.authFile}`, + auth_index: pending.authFile, + request_id: completion.requestId ?? pending.requestId, + failed: completion.failed, + tokens: {}, + }); + } + + return buildUsageResponseFromQueueRecords(records); +} + +function readLogFile(filePath: string): string | null { + if (!fs.existsSync(filePath)) { + return null; + } + + return fs.readFileSync(filePath, 'utf-8'); +} + +export function buildUsageResponseFromCliproxyMainLog(): CliproxyUsageApiResponse | null { + const logPath = path.join(getCliproxyWritablePath(), 'logs', 'main.log'); + const contents = readLogFile(logPath); + if (!contents) { + return null; + } + + const response = buildUsageResponseFromCliproxyLogLines(contents.split(/\r?\n/)); + return hasUsageDetails(response) ? response : null; +} diff --git a/src/cliproxy/services/stats-fetcher.ts b/src/cliproxy/services/stats-fetcher.ts index 7448dadd..272e8ffd 100644 --- a/src/cliproxy/services/stats-fetcher.ts +++ b/src/cliproxy/services/stats-fetcher.ts @@ -13,6 +13,22 @@ import { buildManagementHeaders, } from '../proxy/proxy-target-resolver'; import { buildCliproxyStatsFromUsageResponse } from './stats-transformer'; +import { buildUsageResponseFromCliproxyMainLog } from './oauth-usage-log-transformer'; +import { + buildUsageResponseFromApiKeyUsage, + buildUsageResponseFromQueueRecords, + hasUsageDetails, + hasUsageTotals, + mergeUsageResponseWithMissingDetails, + mergeUsageResponses, +} from './usage-compatibility-transformer'; + +interface ManagementJsonResult { + ok: boolean; + status: number; + data: T | null; + cacheKey: string; +} /** Per-account usage statistics */ export interface AccountUsageStats { @@ -65,6 +81,7 @@ export interface CliproxyRequestDetail { timestamp: string; source: string; auth_index: string | number; + request_id?: string; tokens: { input_tokens: number; output_tokens: number; @@ -112,6 +129,11 @@ export interface CliproxyManagementAuthFile { name?: string; } +const cachedUsageQueueResponses = new Map(); +const USAGE_QUEUE_BATCH_SIZE = 1000; +const USAGE_QUEUE_MAX_BATCHES = 100; +const USAGE_QUEUE_DRAIN_TIMEOUT_MS = 30_000; + /** * Fetch usage statistics from CLIProxyAPI management API * @param port CLIProxyAPI port (default: 8317) @@ -142,15 +164,159 @@ export async function fetchCliproxyStats(port?: number): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + let localLogUsage: CliproxyUsageApiResponse | null | undefined; + const getLocalLogUsage = (): CliproxyUsageApiResponse | null => { + if (localLogUsage !== undefined) { + return localLogUsage; + } + localLogUsage = getProxyTarget().isRemote ? null : buildUsageResponseFromCliproxyMainLog(); + return localLogUsage; + }; + + const mergeLocalLogUsage = (response: CliproxyUsageApiResponse): CliproxyUsageApiResponse => + mergeUsageResponseWithMissingDetails(response, getLocalLogUsage()); + const mergeAggregateWithDetails = ( + aggregate: CliproxyUsageApiResponse, + details: CliproxyUsageApiResponse + ): CliproxyUsageApiResponse => + mergeUsageResponseWithMissingDetails(aggregate, details, { appendExtraDetails: false }); + + let legacyAggregateUsage: CliproxyUsageApiResponse | null = null; + const legacyUsage = await fetchManagementJson( + '/v0/management/usage', + port + ); + if (legacyUsage?.ok && legacyUsage.data) { + if (hasUsageDetails(legacyUsage.data)) { + return mergeLocalLogUsage(legacyUsage.data); + } + legacyAggregateUsage = legacyUsage.data; + } + + let cachedUsageQueueResponse: CliproxyUsageApiResponse | undefined; + const usageQueue = await fetchUsageQueueRecords(port); + if (usageQueue?.cacheKey) { + cachedUsageQueueResponse = cachedUsageQueueResponses.get(usageQueue.cacheKey); + } + + if (usageQueue && Array.isArray(usageQueue.data)) { + const queueResponse = buildUsageResponseFromQueueRecords(usageQueue.data); + if (hasUsageDetails(queueResponse)) { + const mergedUsageQueueResponse = cachedUsageQueueResponse + ? mergeUsageResponses(cachedUsageQueueResponse, queueResponse) + : queueResponse; + cachedUsageQueueResponses.set(usageQueue.cacheKey, mergedUsageQueueResponse); + cachedUsageQueueResponse = mergedUsageQueueResponse; + if (usageQueue.ok) { + const response = legacyAggregateUsage + ? mergeAggregateWithDetails(legacyAggregateUsage, mergedUsageQueueResponse) + : mergedUsageQueueResponse; + return mergeLocalLogUsage(response); + } + } + } + + if (legacyAggregateUsage) { + const response = cachedUsageQueueResponse + ? mergeAggregateWithDetails(legacyAggregateUsage, cachedUsageQueueResponse) + : legacyAggregateUsage; + return mergeLocalLogUsage(response); + } + + const apiKeyUsage = await fetchManagementJson('/v0/management/api-key-usage', port); + if (apiKeyUsage?.ok && apiKeyUsage.data) { + const apiKeyResponseWithCachedDetails = cachedUsageQueueResponse + ? mergeAggregateWithDetails( + buildUsageResponseFromApiKeyUsage(apiKeyUsage.data), + cachedUsageQueueResponse + ) + : buildUsageResponseFromApiKeyUsage(apiKeyUsage.data); + const apiKeyResponse = mergeLocalLogUsage(apiKeyResponseWithCachedDetails); + if (hasUsageTotals(apiKeyResponse)) { + return apiKeyResponse; + } + } + + if (cachedUsageQueueResponse) { + return mergeLocalLogUsage(cachedUsageQueueResponse); + } + + const logUsage = getLocalLogUsage(); + if (logUsage && hasUsageDetails(logUsage)) { + return logUsage; + } + + if (usageQueue?.ok) { + return buildUsageResponseFromQueueRecords([]); + } + + return null; +} + +async function fetchUsageQueueRecords( + port?: number +): Promise | null> { + const records: unknown[] = []; + const seenFullBatchSignatures = new Set(); + let cacheKey = ''; + let status = 0; + const drainStartedAt = Date.now(); + + for (let batchCount = 0; batchCount < USAGE_QUEUE_MAX_BATCHES; batchCount++) { + const result = await fetchManagementJson( + `/v0/management/usage-queue?count=${USAGE_QUEUE_BATCH_SIZE}`, + port + ); + if (!result) { + return records.length > 0 ? { ok: false, status, data: records, cacheKey } : null; + } + + cacheKey ||= result.cacheKey; + status = result.status; + if (!result.ok || !Array.isArray(result.data)) { + return records.length > 0 ? { ok: false, status, data: records, cacheKey } : result; + } + + if (result.data.length === USAGE_QUEUE_BATCH_SIZE) { + const batchSignature = createUsageQueueBatchSignature(result.data); + if (seenFullBatchSignatures.has(batchSignature)) { + return { ok: false, status, data: records, cacheKey }; + } + seenFullBatchSignatures.add(batchSignature); + } + + records.push(...result.data); + if (result.data.length < USAGE_QUEUE_BATCH_SIZE) { + return { ok: true, status, data: records, cacheKey }; + } + + if (Date.now() - drainStartedAt >= USAGE_QUEUE_DRAIN_TIMEOUT_MS) { + return { ok: false, status, data: records, cacheKey }; + } + } + + return { ok: false, status, data: records, cacheKey }; +} + +function createUsageQueueBatchSignature(records: unknown[]): string { + return JSON.stringify(records); +} + +async function fetchManagementJson( + endpointPath: string, + port?: number, + timeoutMs = 5000 +): Promise | null> { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { const target = getProxyTarget(); if (port !== undefined && !target.isRemote) { target.port = port; } - const url = buildProxyUrl(target, '/v0/management/usage'); + const url = buildProxyUrl(target, endpointPath); const headers = target.isRemote ? buildManagementHeaders(target) @@ -161,51 +327,46 @@ export async function fetchCliproxyUsageRaw( headers, }); - clearTimeout(timeoutId); - if (!response.ok) { - return null; + return { ok: false, status: response.status, data: null, cacheKey: url }; } - return (await response.json()) as CliproxyUsageApiResponse; + return { + ok: true, + status: response.status, + data: (await response.json()) as T, + cacheKey: url, + }; } catch { return null; + } finally { + clearTimeout(timeoutId); } } async function fetchCliproxyAuthFiles(port?: number): Promise { try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - - const target = getProxyTarget(); - if (port !== undefined && !target.isRemote) { - target.port = port; - } - const url = buildProxyUrl(target, '/v0/management/auth-files'); - - const headers = target.isRemote - ? buildManagementHeaders(target) - : { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` }; - - const response = await fetch(url, { - signal: controller.signal, - headers, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { + const result = await fetchManagementJson<{ files?: CliproxyManagementAuthFile[] }>( + '/v0/management/auth-files', + port + ); + if (!result?.ok || !result.data) { return null; } - const data = (await response.json()) as { files?: CliproxyManagementAuthFile[] }; + const data = result.data; return Array.isArray(data.files) ? data.files : null; } catch { return null; } } +export const __testExports = { + clearCachedUsageQueueResponse(): void { + cachedUsageQueueResponses.clear(); + }, +}; + /** OpenAI-compatible model object from /v1/models endpoint */ export interface CliproxyModel { id: string; diff --git a/src/cliproxy/services/stats-transformer.ts b/src/cliproxy/services/stats-transformer.ts index ed2be0ea..ca4bc7db 100644 --- a/src/cliproxy/services/stats-transformer.ts +++ b/src/cliproxy/services/stats-transformer.ts @@ -30,6 +30,44 @@ function normalizeProvider(provider: string): string { return mapExternalProviderName(normalized) ?? normalized; } +function extractAuthFilenameFromSource(source: string): string { + const authFileMatch = source.match(/(?:^|\s)auth_file=("[^"]+"|'[^']+'|[^\s]+)/i); + const rawValue = authFileMatch?.[1] ?? source; + const value = rawValue.trim().replace(/^['"]|['"]$/g, ''); + const filenameCandidate = value.split('|').pop() ?? value; + return filenameCandidate.split(/[\\/]/).pop() ?? filenameCandidate.trim(); +} + +function stripKnownAuthPlanSuffix(value: string): string { + return value.replace(/-(?:free|plus|pro|team|business|enterprise)$/i, ''); +} + +function normalizeAuthFilenameSource(provider: string, source: string): string | null { + const filename = extractAuthFilenameFromSource(source); + const normalizedProvider = normalizeProvider(provider); + const hadJsonExtension = /\.json$/i.test(filename); + let candidate = filename.replace(/\.json$/i, ''); + const providerPrefix = `${normalizedProvider}-`; + if (candidate.toLowerCase().startsWith(providerPrefix)) { + candidate = candidate.slice(providerPrefix.length); + } + + candidate = candidate.replace(/^[a-f0-9]{8}[-_]/i, ''); + if (hadJsonExtension) { + candidate = stripKnownAuthPlanSuffix(candidate); + } + + const parts = candidate.split('@'); + if (parts.length < 2) { + return null; + } + + const localPart = parts[0]?.trim(); + const domainPart = parts.slice(1).join('@').trim(); + const email = `${localPart}@${domainPart}`; + return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) ? email.toLowerCase() : null; +} + function buildAuthIndexLookup( authFiles: CliproxyManagementAuthFile[] | undefined ): ReadonlyMap { @@ -106,12 +144,38 @@ function resolveSourceForDetail( const source = detail.source?.trim(); if (source) { - return source; + return normalizeAuthFilenameSource(resolvedProvider, source) ?? source; } return resolvedAuthFile?.source ?? 'unknown'; } +function resolveCountWithDetails(aggregateCount: number | undefined, detailCount: number): number { + if (aggregateCount === undefined) { + return detailCount; + } + + return aggregateCount < detailCount ? detailCount : aggregateCount; +} + +function shouldReplaceLastUsedAt(current: string | undefined, next: string | undefined): boolean { + if (!next) { + return false; + } + + const nextTime = Date.parse(next); + if (!Number.isFinite(nextTime)) { + return false; + } + + if (!current) { + return true; + } + + const currentTime = Date.parse(current); + return !Number.isFinite(currentTime) || nextTime > currentTime; +} + export function buildCliproxyStatsFromUsageResponse( data: CliproxyUsageApiResponse, options: BuildCliproxyStatsOptions = {} @@ -129,7 +193,7 @@ export function buildCliproxyStatsFromUsageResponse( if (usage?.apis) { for (const [provider, providerData] of Object.entries(usage.apis)) { - let sawProviderDetail = false; + let providerDetailCount = 0; if (!providerData.models) { const normalizedProvider = normalizeProvider(provider); requestsByProvider[normalizedProvider] = @@ -138,14 +202,16 @@ export function buildCliproxyStatsFromUsageResponse( } for (const [model, modelData] of Object.entries(providerData.models)) { - requestsByModel[model] = modelData.total_requests ?? 0; + let modelDetailCount = 0; if (!modelData.details) { + requestsByModel[model] = (requestsByModel[model] ?? 0) + (modelData.total_requests ?? 0); continue; } for (const detail of modelData.details) { sawAnyDetail = true; - sawProviderDetail = true; + providerDetailCount++; + modelDetailCount++; const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup); const source = resolveSourceForDetail(resolvedProvider, detail, authIndexLookup); const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source); @@ -172,26 +238,52 @@ export function buildCliproxyStatsFromUsageResponse( const tokens = detail.tokens?.total_tokens ?? 0; accountStats[accountKey].totalTokens += tokens; - accountStats[accountKey].lastUsedAt = detail.timestamp; + if (shouldReplaceLastUsedAt(accountStats[accountKey].lastUsedAt, detail.timestamp)) { + accountStats[accountKey].lastUsedAt = detail.timestamp; + } totalInputTokens += detail.tokens?.input_tokens ?? 0; totalOutputTokens += detail.tokens?.output_tokens ?? 0; } + + requestsByModel[model] = + (requestsByModel[model] ?? 0) + + resolveCountWithDetails(modelData.total_requests, modelDetailCount); } - if (!sawProviderDetail) { + const providerTotal = providerData.total_requests ?? 0; + if (providerDetailCount === 0) { const normalizedProvider = normalizeProvider(provider); requestsByProvider[normalizedProvider] = - (requestsByProvider[normalizedProvider] ?? 0) + (providerData.total_requests ?? 0); + (requestsByProvider[normalizedProvider] ?? 0) + providerTotal; + } else if (providerTotal > providerDetailCount) { + const normalizedProvider = normalizeProvider(provider); + requestsByProvider[normalizedProvider] = + (requestsByProvider[normalizedProvider] ?? 0) + (providerTotal - providerDetailCount); } } } + const aggregateFailureCount = usage?.failure_count ?? data.failed_requests; + const successCount = sawAnyDetail + ? resolveCountWithDetails(usage?.success_count, totalSuccessCount) + : (usage?.success_count ?? 0); + const failureCount = sawAnyDetail + ? resolveCountWithDetails(aggregateFailureCount, totalFailureCount) + : (aggregateFailureCount ?? 0); + const providerTotalRequests = Object.values(requestsByProvider).reduce( + (total, count) => total + count, + 0 + ); + const totalRequests = Math.max( + usage?.total_requests ?? 0, + successCount + failureCount, + providerTotalRequests + ); + return { - totalRequests: usage?.total_requests ?? 0, - successCount: sawAnyDetail ? totalSuccessCount : (usage?.success_count ?? 0), - failureCount: sawAnyDetail - ? totalFailureCount - : (usage?.failure_count ?? data.failed_requests ?? 0), + totalRequests, + successCount, + failureCount, tokens: { input: totalInputTokens, output: totalOutputTokens, @@ -200,7 +292,7 @@ export function buildCliproxyStatsFromUsageResponse( requestsByModel, requestsByProvider, accountStats, - quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0, + quotaExceededCount: failureCount, retryCount: 0, collectedAt: new Date().toISOString(), }; diff --git a/src/cliproxy/services/usage-compatibility-transformer.ts b/src/cliproxy/services/usage-compatibility-transformer.ts new file mode 100644 index 00000000..dd2714cb --- /dev/null +++ b/src/cliproxy/services/usage-compatibility-transformer.ts @@ -0,0 +1,632 @@ +import type { CliproxyRequestDetail, CliproxyUsageApiResponse } from './stats-fetcher'; + +interface CliproxyUsageQueueRecord { + timestamp?: string; + provider?: string; + model?: string; + alias?: string; + source?: string; + auth_index?: string | number; + request_id?: string; + tokens?: Partial; + failed?: boolean; +} + +interface ApiKeyUsageEntry { + success?: number; + failed?: number; +} + +type ApiKeyUsageResponse = Record>; + +interface MergeMissingDetailsOptions { + appendExtraDetails?: boolean; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asString(value: unknown, fallback: string): string { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function asNumber(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +} + +function asBoolean(value: unknown): boolean { + return value === true; +} + +function normalizeTokens(rawTokens: unknown): CliproxyRequestDetail['tokens'] { + const tokens = asRecord(rawTokens) ?? {}; + const input = asNumber(tokens.input_tokens); + const output = asNumber(tokens.output_tokens); + const reasoning = asNumber(tokens.reasoning_tokens); + const cached = asNumber(tokens.cached_tokens); + const explicitTotal = asNumber(tokens.total_tokens); + const total = explicitTotal || input + output + reasoning + cached; + + return { + input_tokens: input, + output_tokens: output, + reasoning_tokens: reasoning, + cached_tokens: cached, + total_tokens: total, + }; +} + +function normalizeQueueRecord(record: unknown): CliproxyUsageQueueRecord | null { + const raw = asRecord(record); + if (!raw) { + return null; + } + + const provider = asString(raw.provider, 'unknown'); + const model = asString(raw.model, asString(raw.alias, 'unknown')); + const source = asString(raw.source, 'unknown'); + const authIndex = + typeof raw.auth_index === 'string' || typeof raw.auth_index === 'number' + ? raw.auth_index + : source; + + return { + timestamp: asString(raw.timestamp, new Date().toISOString()), + provider, + model, + alias: asString(raw.alias, model), + source, + auth_index: authIndex, + request_id: asString(raw.request_id, ''), + tokens: normalizeTokens(raw.tokens), + failed: asBoolean(raw.failed), + }; +} + +function ensureProviderBucket( + response: CliproxyUsageApiResponse, + provider: string +): NonNullable['apis']>[string] { + const usage = (response.usage ??= { apis: {} }); + const apis = (usage.apis ??= {}); + return (apis[provider] ??= { total_requests: 0, total_tokens: 0, models: {} }); +} + +function ensureModelBucket( + providerBucket: NonNullable['apis']>[string], + model: string +): NonNullable[string] { + const models = (providerBucket.models ??= {}); + return (models[model] ??= { total_requests: 0, total_tokens: 0, details: [] }); +} + +function addDetail( + response: CliproxyUsageApiResponse, + provider: string, + model: string, + detail: CliproxyRequestDetail +): void { + const usage = (response.usage ??= { apis: {} }); + const providerBucket = ensureProviderBucket(response, provider); + const modelBucket = ensureModelBucket(providerBucket, model); + const totalTokens = detail.tokens?.total_tokens ?? 0; + + usage.total_requests = (usage.total_requests ?? 0) + 1; + usage.total_tokens = (usage.total_tokens ?? 0) + totalTokens; + if (detail.failed) { + usage.failure_count = (usage.failure_count ?? 0) + 1; + response.failed_requests = (response.failed_requests ?? 0) + 1; + } else { + usage.success_count = (usage.success_count ?? 0) + 1; + } + + providerBucket.total_requests = (providerBucket.total_requests ?? 0) + 1; + providerBucket.total_tokens = (providerBucket.total_tokens ?? 0) + totalTokens; + modelBucket.total_requests = (modelBucket.total_requests ?? 0) + 1; + modelBucket.total_tokens = (modelBucket.total_tokens ?? 0) + totalTokens; + (modelBucket.details ??= []).push(detail); +} + +function createDetailSignature( + provider: string, + model: string, + detail: CliproxyRequestDetail +): string { + return [ + provider, + model, + detail.request_id?.trim() ?? '', + detail.timestamp, + detail.source, + String(detail.auth_index), + detail.tokens?.input_tokens ?? 0, + detail.tokens?.output_tokens ?? 0, + detail.tokens?.reasoning_tokens ?? 0, + detail.tokens?.cached_tokens ?? 0, + detail.tokens?.total_tokens ?? 0, + detail.failed ? '1' : '0', + ].join('|'); +} + +function collectResponseDetails( + response: CliproxyUsageApiResponse +): Array<{ provider: string; model: string; detail: CliproxyRequestDetail }> { + const entries: Array<{ provider: string; model: string; detail: CliproxyRequestDetail }> = []; + for (const [provider, providerData] of Object.entries(response.usage?.apis ?? {})) { + for (const [model, modelData] of Object.entries(providerData.models ?? {})) { + for (const detail of modelData.details ?? []) { + entries.push({ provider, model, detail }); + } + } + } + return entries; +} + +export function buildUsageResponseFromQueueRecords(records: unknown[]): CliproxyUsageApiResponse { + const response: CliproxyUsageApiResponse = { + failed_requests: 0, + usage: { + total_requests: 0, + success_count: 0, + failure_count: 0, + total_tokens: 0, + apis: {}, + }, + }; + + for (const rawRecord of records) { + const record = normalizeQueueRecord(rawRecord); + if (!record) { + continue; + } + + addDetail(response, record.provider ?? 'unknown', record.model ?? 'unknown', { + timestamp: record.timestamp ?? new Date().toISOString(), + source: record.source ?? 'unknown', + auth_index: record.auth_index ?? record.source ?? 'unknown', + request_id: record.request_id || undefined, + tokens: normalizeTokens(record.tokens), + failed: record.failed === true, + }); + } + + return response; +} + +export function mergeUsageResponses( + base: CliproxyUsageApiResponse, + incoming: CliproxyUsageApiResponse +): CliproxyUsageApiResponse { + const merged = buildUsageResponseFromQueueRecords([]); + const seen = new Set(); + + for (const entry of [...collectResponseDetails(base), ...collectResponseDetails(incoming)]) { + const signature = createDetailSignature(entry.provider, entry.model, entry.detail); + if (seen.has(signature)) { + continue; + } + seen.add(signature); + addDetail(merged, entry.provider, entry.model, entry.detail); + } + + return merged; +} + +function cloneUsageResponse(response: CliproxyUsageApiResponse): CliproxyUsageApiResponse { + return { + failed_requests: response.failed_requests ?? 0, + usage: { + total_requests: response.usage?.total_requests ?? 0, + success_count: response.usage?.success_count ?? 0, + failure_count: response.usage?.failure_count ?? 0, + total_tokens: response.usage?.total_tokens ?? 0, + apis: Object.fromEntries( + Object.entries(response.usage?.apis ?? {}).map(([provider, providerData]) => [ + provider, + { + total_requests: providerData.total_requests ?? 0, + total_tokens: providerData.total_tokens ?? 0, + models: Object.fromEntries( + Object.entries(providerData.models ?? {}).map(([model, modelData]) => [ + model, + { + total_requests: modelData.total_requests ?? 0, + total_tokens: modelData.total_tokens ?? 0, + details: (modelData.details ?? []).map((detail) => ({ + ...detail, + tokens: { ...detail.tokens }, + })), + }, + ]) + ), + }, + ]) + ), + }, + }; +} + +function cloneDetail(detail: CliproxyRequestDetail): CliproxyRequestDetail { + return { + ...detail, + tokens: { ...detail.tokens }, + }; +} + +function createMissingDetailMergeKey( + provider: string, + model: string, + detail: CliproxyRequestDetail +): string { + return [ + provider, + model, + detail.request_id?.trim() ?? '', + detail.timestamp, + detail.source?.trim() ?? '', + String(detail.auth_index ?? '').trim(), + detail.tokens?.input_tokens ?? 0, + detail.tokens?.output_tokens ?? 0, + detail.tokens?.reasoning_tokens ?? 0, + detail.tokens?.cached_tokens ?? 0, + detail.tokens?.total_tokens ?? 0, + detail.failed ? '1' : '0', + ].join('|'); +} + +function extractDuplicateIdentityValue(value: string): string { + const authFileMatch = value.match(/(?:^|\s)auth_file=("[^"]+"|'[^']+'|[^\s]+)/i); + const rawValue = authFileMatch?.[1] ?? value; + const unquotedValue = rawValue.trim().replace(/^['"]|['"]$/g, ''); + const pipeCandidate = unquotedValue.split('|').pop() ?? unquotedValue; + return pipeCandidate.split(/[\\/]/).pop() ?? pipeCandidate.trim(); +} + +function stripKnownAuthPlanSuffix(value: string): string { + return value.replace(/-(?:free|plus|pro|team|business|enterprise)$/i, ''); +} + +function normalizeDuplicateIdentity(provider: string, value: string | number | undefined): string { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return ''; + } + + const normalizedProvider = provider.trim().toLowerCase(); + const identityValue = extractDuplicateIdentityValue(rawValue); + const hadJsonExtension = /\.json$/i.test(identityValue); + let candidate = identityValue.replace(/\.json$/i, ''); + const providerPrefix = `${normalizedProvider}-`; + if (candidate.toLowerCase().startsWith(providerPrefix)) { + candidate = candidate.slice(providerPrefix.length); + } + + candidate = candidate.replace(/^[a-f0-9]{8}[-_]/i, '').trim(); + if (hadJsonExtension) { + candidate = stripKnownAuthPlanSuffix(candidate); + } + + return candidate ? candidate.toLowerCase() : rawValue.toLowerCase(); +} + +function resolveCompleteDetailDuplicateIdentity( + provider: string, + detail: CliproxyRequestDetail +): string { + return ( + normalizeDuplicateIdentity(provider, detail.source) || + normalizeDuplicateIdentity(provider, detail.auth_index) || + 'unknown' + ); +} + +function createLikelyLogDuplicateKey( + provider: string, + model: string, + detail: CliproxyRequestDetail +): string { + return [ + provider, + model, + resolveCompleteDetailDuplicateIdentity(provider, detail), + detail.failed ? '1' : '0', + ].join('|'); +} + +function createRequestIdDuplicateKey( + provider: string, + model: string, + detail: CliproxyRequestDetail +): string { + const requestId = detail.request_id?.trim(); + return requestId ? [provider, model, requestId, detail.failed ? '1' : '0'].join('|') : ''; +} + +function parseDetailTimestamp(detail: CliproxyRequestDetail): number | null { + const timestampMs = Date.parse(detail.timestamp); + return Number.isFinite(timestampMs) ? timestampMs : null; +} + +const TOKENLESS_LOG_DUPLICATE_WINDOW_MS = 5_000; + +interface LikelyLogDuplicateCandidates { + byRequestId: Map; + byIdentity: Map; +} + +function detailHasTokenUsage(detail: CliproxyRequestDetail): boolean { + return ( + (detail.tokens?.input_tokens ?? 0) > 0 || + (detail.tokens?.output_tokens ?? 0) > 0 || + (detail.tokens?.reasoning_tokens ?? 0) > 0 || + (detail.tokens?.cached_tokens ?? 0) > 0 || + (detail.tokens?.total_tokens ?? 0) > 0 + ); +} + +function collectDetailMergeKeyCounts(response: CliproxyUsageApiResponse): Map { + const counts = new Map(); + for (const entry of collectResponseDetails(response)) { + const key = createMissingDetailMergeKey(entry.provider, entry.model, entry.detail); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; +} + +function collectLikelyLogDuplicateCandidates( + response: CliproxyUsageApiResponse +): LikelyLogDuplicateCandidates { + const candidates: LikelyLogDuplicateCandidates = { + byRequestId: new Map(), + byIdentity: new Map(), + }; + for (const [provider, providerData] of Object.entries(response.usage?.apis ?? {})) { + for (const [model, modelData] of Object.entries(providerData.models ?? {})) { + const details = modelData.details ?? []; + const canUseIdentityWindow = (modelData.total_requests ?? 0) <= details.length; + for (const detail of details) { + const requestIdKey = createRequestIdDuplicateKey(provider, model, detail); + if (requestIdKey) { + candidates.byRequestId.set( + requestIdKey, + (candidates.byRequestId.get(requestIdKey) ?? 0) + 1 + ); + } + + if (!canUseIdentityWindow) { + continue; + } + + const timestampMs = parseDetailTimestamp(detail); + if (timestampMs === null) { + continue; + } + + const key = createLikelyLogDuplicateKey(provider, model, detail); + const timestamps = candidates.byIdentity.get(key) ?? []; + timestamps.push(timestampMs); + candidates.byIdentity.set(key, timestamps); + } + } + } + return candidates; +} + +function consumeDetailKey(counts: Map, key: string): boolean { + const count = counts.get(key) ?? 0; + if (count <= 0) { + return false; + } + + if (count === 1) { + counts.delete(key); + } else { + counts.set(key, count - 1); + } + return true; +} + +function consumeCount(counts: Map, key: string): boolean { + const count = counts.get(key) ?? 0; + if (count <= 0) { + return false; + } + + if (count === 1) { + counts.delete(key); + } else { + counts.set(key, count - 1); + } + return true; +} + +function consumeLikelyLogDuplicateCandidate( + candidates: LikelyLogDuplicateCandidates, + provider: string, + model: string, + detail: CliproxyRequestDetail +): boolean { + if (detailHasTokenUsage(detail)) { + return false; + } + + const requestIdKey = createRequestIdDuplicateKey(provider, model, detail); + if (requestIdKey && consumeCount(candidates.byRequestId, requestIdKey)) { + return true; + } + + const timestampMs = parseDetailTimestamp(detail); + if (timestampMs === null) { + return false; + } + + const key = createLikelyLogDuplicateKey(provider, model, detail); + const timestamps = candidates.byIdentity.get(key) ?? []; + const index = timestamps.findIndex( + (candidateTimestampMs) => + Math.abs(candidateTimestampMs - timestampMs) <= TOKENLESS_LOG_DUPLICATE_WINDOW_MS + ); + if (index === -1) { + return false; + } + + timestamps.splice(index, 1); + if (timestamps.length === 0) { + candidates.byIdentity.delete(key); + } else { + candidates.byIdentity.set(key, timestamps); + } + return true; +} + +function countProviderDetails( + providerBucket: NonNullable['apis']>[string] +): number { + return Object.values(providerBucket.models ?? {}).reduce( + (total, modelData) => total + (modelData.details ?? []).length, + 0 + ); +} + +function appendDetailToExistingProvider( + merged: CliproxyUsageApiResponse, + provider: string, + model: string, + detail: CliproxyRequestDetail, + options: Required +): void { + const providerBucket = ensureProviderBucket(merged, provider); + const shouldFillAggregateOnly = + (providerBucket.total_requests ?? 0) > countProviderDetails(providerBucket); + if (!shouldFillAggregateOnly) { + if (!options.appendExtraDetails) { + return; + } + addDetail(merged, provider, model, cloneDetail(detail)); + return; + } + + const modelBucket = ensureModelBucket(providerBucket, model); + const shouldFillModelAggregateOnly = + (modelBucket.total_requests ?? 0) > (modelBucket.details ?? []).length; + + if (!shouldFillModelAggregateOnly) { + modelBucket.total_requests = (modelBucket.total_requests ?? 0) + 1; + } + (modelBucket.details ??= []).push(cloneDetail(detail)); +} + +export function mergeUsageResponseWithMissingDetails( + base: CliproxyUsageApiResponse, + incoming: CliproxyUsageApiResponse | null | undefined, + options: MergeMissingDetailsOptions = {} +): CliproxyUsageApiResponse { + if (!incoming || !hasUsageDetails(incoming)) { + return base; + } + + const normalizedOptions: Required = { + appendExtraDetails: options.appendExtraDetails ?? true, + }; + const merged = cloneUsageResponse(base); + const existingDetailCounts = collectDetailMergeKeyCounts(base); + const likelyLogDuplicateCandidates = collectLikelyLogDuplicateCandidates(base); + for (const entry of collectResponseDetails(incoming)) { + const mergeKey = createMissingDetailMergeKey(entry.provider, entry.model, entry.detail); + if (consumeDetailKey(existingDetailCounts, mergeKey)) { + continue; + } + + if ( + consumeLikelyLogDuplicateCandidate( + likelyLogDuplicateCandidates, + entry.provider, + entry.model, + entry.detail + ) + ) { + continue; + } + + if (base.usage?.apis?.[entry.provider]) { + appendDetailToExistingProvider( + merged, + entry.provider, + entry.model, + entry.detail, + normalizedOptions + ); + continue; + } + + addDetail(merged, entry.provider, entry.model, cloneDetail(entry.detail)); + } + + return merged; +} + +export function buildUsageResponseFromApiKeyUsage(rawResponse: unknown): CliproxyUsageApiResponse { + const response = buildUsageResponseFromQueueRecords([]); + const usage = response.usage ?? { + total_requests: 0, + success_count: 0, + failure_count: 0, + total_tokens: 0, + apis: {}, + }; + response.usage = usage; + const byProvider = asRecord(rawResponse) as ApiKeyUsageResponse | null; + if (!byProvider) { + return response; + } + + for (const [provider, sources] of Object.entries(byProvider)) { + const sourceRecords = asRecord(sources); + if (!sourceRecords) { + continue; + } + + let providerRequests = 0; + for (const entry of Object.values(sourceRecords)) { + const usageEntry = asRecord(entry); + if (!usageEntry) { + continue; + } + + const success = asNumber(usageEntry.success); + const failed = asNumber(usageEntry.failed); + providerRequests += success + failed; + usage.success_count = (usage.success_count ?? 0) + success; + usage.failure_count = (usage.failure_count ?? 0) + failed; + response.failed_requests = (response.failed_requests ?? 0) + failed; + } + + if (providerRequests > 0) { + const providerBucket = ensureProviderBucket(response, provider); + providerBucket.total_requests = (providerBucket.total_requests ?? 0) + providerRequests; + usage.total_requests = (usage.total_requests ?? 0) + providerRequests; + } + } + + return response; +} + +export function hasUsageDetails(response: CliproxyUsageApiResponse): boolean { + return collectResponseDetails(response).length > 0; +} + +export function hasUsageTotals(response: CliproxyUsageApiResponse): boolean { + return (response.usage?.total_requests ?? 0) > 0; +} From 7e958784c4226ae1f8182046ce57111ffef44fe4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 17:39:30 -0400 Subject: [PATCH 09/38] chore(release): 7.77.1-dev.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aff9408d..2eeb9ba8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1-dev.3", + "version": "7.77.1-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 1b5376239fbdd788de90717792297714e8d48ab9 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 7 May 2026 06:14:12 -0400 Subject: [PATCH 10/38] fix: preserve native Claude passthrough args Closes #1189 --- docs/project-roadmap.md | 3 +- src/ccs.ts | 2 +- src/delegation/delegation-handler.ts | 150 ++++++++++---- .../delegation/delegation-handler.test.ts | 194 ++++++++++++++++++ 4 files changed, 311 insertions(+), 38 deletions(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 2521db5c..76e0ff07 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-30 +Last Updated: 2026-05-07 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-05-07**: **#1189** Headless settings-profile delegation now preserves native Claude passthrough args without a Claude flag allowlist. Explicit `--channels` values reach Claude Code, future native flags can carry multiple adjacent values, malformed CCS-owned flags no longer swallow the next native flag, and `--prompt=` routes through headless delegation consistently with `--prompt `. - **2026-05-03**: **#1172** Local CLIProxy config generation now keeps the CPAMC management dashboard aligned with backend selection. `backend: original` points to the upstream dashboard, `backend: plus` points to the CCS-maintained CPAMC fork, `cliproxy.management_panel_repository` lets advanced users override the panel repository, and stale generated configs are regenerated when the expected panel source changes. - **2026-04-30**: **#1153** Native Claude launches now accept session-scoped `--effort low|medium|high|xhigh|max` overrides through CCS without mutating global Claude settings. CCS validates invalid or missing effort values before spawning Claude, normalizes accepted values, keeps default headless `-p/--prompt` launches on native Claude instead of delegation parsing, and preserves CLIProxy/Codex/Droid effort aliases. - **2026-04-28**: **#1123** CLIProxy quota failover now uses the dashboard/manual pause mechanism for all quota-visible OAuth providers with CCS quota fetchers: Antigravity, Claude, Codex, Gemini CLI, and GitHub Copilot. When a healthy fallback exists, CCS moves the exhausted account token out of the live `auth/` folder into `auth-paused/`, marks the account paused for dashboard visibility, persists the cooldown for auto-resume, and still avoids self-pausing the last usable account. diff --git a/src/ccs.ts b/src/ccs.ts index b1163244..f62909a2 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -73,7 +73,7 @@ async function main(): Promise { // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue // through normal adapter dispatch logic. - if (args.includes('-p') || args.includes('--prompt')) { + if (args.some((arg) => arg === '-p' || arg === '--prompt' || arg.startsWith('--prompt='))) { const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type === 'settings'; if (shouldUseDelegation) { const { DelegationHandler } = await import('./delegation/delegation-handler'); diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index 2716bfcb..4e59ffdf 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -9,6 +9,59 @@ import { fail, warn } from '../utils/ui'; import { getCcsDir } from '../config/config-loader-facade'; const PROFILE_FLAGS_WITH_VALUE = new Set(['-p', '--prompt', '--effort']); +const PROMPT_FLAGS_WITH_VALUE = new Set(['-p', '--prompt']); +const NUMERIC_CCS_FLAGS_WITH_VALUE = new Set(['--timeout', '--max-turns']); +const DASH_REJECTING_CCS_FLAGS_WITH_VALUE = new Set([ + '--permission-mode', + '--fallback-model', + '--agents', + '--betas', +]); +const CCS_FLAGS_WITH_VALUE = new Set([ + '-p', + '--prompt', + '--timeout', + '--permission-mode', + '--max-turns', + '--fallback-model', + '--agents', + '--betas', +]); + +function isFlag(arg: string): boolean { + return arg.startsWith('-'); +} + +function isInlineCcsValueFlag(arg: string): boolean { + return Array.from(CCS_FLAGS_WITH_VALUE).some( + (flag) => flag.startsWith('--') && arg.startsWith(`${flag}=`) + ); +} + +function shouldSkipCcsFlagValue(args: string[], index: number): boolean { + const arg = args[index]; + if (index >= args.length - 1) return false; + if (PROMPT_FLAGS_WITH_VALUE.has(arg)) return true; + const nextArg = args[index + 1]; + if (NUMERIC_CCS_FLAGS_WITH_VALUE.has(arg) && /^-\d/.test(nextArg)) return true; + if ( + DASH_REJECTING_CCS_FLAGS_WITH_VALUE.has(arg) && + isFlag(nextArg) && + !nextArg.startsWith('--') && + !CCS_FLAGS_WITH_VALUE.has(nextArg) + ) { + return true; + } + return !isFlag(nextArg); +} + +function findInlineFlagValue(args: string[], flagName: string): string | undefined { + const prefix = `${flagName}=`; + const inlineArg = args.find((arg) => arg.startsWith(prefix)); + if (inlineArg === undefined) return undefined; + const value = inlineArg.slice(prefix.length); + return value.trim().length > 0 ? value : undefined; +} /** * Parse and validate a string flag value @@ -20,9 +73,15 @@ function parseStringFlag( options?: { allowDashPrefix?: boolean } ): string | undefined { const index = args.indexOf(flagName); - if (index === -1 || index >= args.length - 1) return undefined; + let value: string | undefined; - const value = args[index + 1]; + if (index === -1) { + value = findInlineFlagValue(args, flagName); + if (value === undefined) return undefined; + } else { + if (index >= args.length - 1) return undefined; + value = args[index + 1]; + } // Reject dash-prefixed values (likely another flag) if (!options?.allowDashPrefix && value.startsWith('-')) { @@ -165,9 +224,10 @@ export class DelegationHandler { continue; } + if (args[i].startsWith('--prompt=')) continue; if (args[i].startsWith('--effort=')) continue; - if (!args[i].startsWith('-')) { + if (!isFlag(args[i])) { return args[i]; } } @@ -187,6 +247,14 @@ export class DelegationHandler { const index = pIndex !== -1 ? pIndex : promptIndex; + if (index === -1) { + const inlinePrompt = args.find((arg) => arg.startsWith('--prompt=')); + if (inlinePrompt) { + const prompt = inlinePrompt.slice('--prompt='.length); + if (prompt.length > 0) return prompt; + } + } + if (index === -1 || index === args.length - 1) { console.error(fail('Missing prompt after -p flag')); console.error(' Usage: ccs glm -p "task description"'); @@ -215,15 +283,20 @@ export class DelegationHandler { }; // Parse permission-mode (CLI flag overrides settings file) - const permModeIndex = args.indexOf('--permission-mode'); - if (permModeIndex !== -1 && permModeIndex < args.length - 1) { - options.permissionMode = args[permModeIndex + 1]; + const permissionMode = parseStringFlag(args, '--permission-mode'); + if (permissionMode) { + options.permissionMode = permissionMode; } // Parse timeout (validated: positive integer, max 10 minutes) const timeoutIndex = args.indexOf('--timeout'); - if (timeoutIndex !== -1 && timeoutIndex < args.length - 1) { - const rawVal = args[timeoutIndex + 1]; + const inlineTimeout = findInlineFlagValue(args, '--timeout'); + const rawTimeout = + timeoutIndex !== -1 && timeoutIndex < args.length - 1 + ? args[timeoutIndex + 1] + : inlineTimeout; + if (rawTimeout !== undefined) { + const rawVal = rawTimeout; const val = parseInt(rawVal, 10); if (!isNaN(val) && val > 0 && val <= 600000) { options.timeout = val; @@ -238,8 +311,13 @@ export class DelegationHandler { // Parse --max-turns (limit agentic turns, max 100) const maxTurnsIndex = args.indexOf('--max-turns'); - if (maxTurnsIndex !== -1 && maxTurnsIndex < args.length - 1) { - const rawVal = args[maxTurnsIndex + 1]; + const inlineMaxTurns = findInlineFlagValue(args, '--max-turns'); + const rawMaxTurns = + maxTurnsIndex !== -1 && maxTurnsIndex < args.length - 1 + ? args[maxTurnsIndex + 1] + : inlineMaxTurns; + if (rawMaxTurns !== undefined) { + const rawVal = rawMaxTurns; const val = parseInt(rawVal, 10); if (!isNaN(val) && val > 0 && val <= 100) { options.maxTurns = val; @@ -271,42 +349,42 @@ export class DelegationHandler { // Parse --betas (experimental features) options.betas = parseStringFlag(args, '--betas'); - // Collect extra args to pass through to Claude CLI - // CCS-handled flags with values (skip these and their values): - const ccsFlagsWithValue = new Set([ - '-p', - '--prompt', - '--timeout', - '--permission-mode', - '--max-turns', - '--fallback-model', - '--agents', - '--betas', - ]); + // Collect extra args to pass through to Claude CLI. + // Only CCS-owned flags are consumed here. Unknown/native Claude flags are preserved + // generically, including future variadic flags such as "--flag value1 value2". const extraArgs: string[] = []; const profile = this._extractProfile(args); + let profileSkipped = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; - // Skip profile name (non-flag first arg) - if (arg === profile && !arg.startsWith('-')) continue; - - // Skip CCS-handled flags and their values - if (ccsFlagsWithValue.has(arg)) { - i++; // Skip next arg (the value) + // Skip only the first profile token. Later matching values may belong to native flags. + if (!profileSkipped && arg === profile && !isFlag(arg)) { + profileSkipped = true; continue; } - // Collect flags and their values as passthrough - if (arg.startsWith('-')) { - extraArgs.push(arg); - // If next arg exists and doesn't start with '-', it's likely a value - if (i + 1 < args.length && !args[i + 1].startsWith('-')) { - extraArgs.push(args[i + 1]); - i++; // Skip the value we just added - } + // Skip CCS-handled flags and their values. + if (CCS_FLAGS_WITH_VALUE.has(arg)) { + if (shouldSkipCcsFlagValue(args, i)) i++; + continue; } + if (isInlineCcsValueFlag(arg)) { + continue; + } + + // Preserve native/future Claude flags and all adjacent values until the next flag. + if (isFlag(arg)) { + extraArgs.push(arg); + while (i + 1 < args.length && !isFlag(args[i + 1])) { + extraArgs.push(args[i + 1]); + i++; + } + continue; + } + + extraArgs.push(arg); } if (extraArgs.length > 0) { diff --git a/tests/unit/delegation/delegation-handler.test.ts b/tests/unit/delegation/delegation-handler.test.ts index 095a8e8f..ea5fbd2d 100644 --- a/tests/unit/delegation/delegation-handler.test.ts +++ b/tests/unit/delegation/delegation-handler.test.ts @@ -36,6 +36,7 @@ describe('DelegationHandler', () => { it('rejects negative timeout with warning', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', '-5000']); expect(options.timeout).toBeUndefined(); + expect(options.extraArgs).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalled(); }); @@ -55,6 +56,22 @@ describe('DelegationHandler', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--timeout']); expect(options.timeout).toBeUndefined(); }); + + it('does not drop following native flags when timeout value is missing', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--timeout', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.timeout).toBeUndefined(); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + }); }); describe('_extractOptions - max-turns validation', () => { @@ -72,6 +89,7 @@ describe('DelegationHandler', () => { it('rejects negative max-turns with warning', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '-5']); expect(options.maxTurns).toBeUndefined(); + expect(options.extraArgs).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalled(); }); @@ -91,6 +109,18 @@ describe('DelegationHandler', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '100']); expect(options.maxTurns).toBe(100); }); + + it('accepts inline max-turns syntax', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns=7']); + expect(options.maxTurns).toBe(7); + expect(options.extraArgs).toBeUndefined(); + }); + + it('treats empty inline max-turns as missing', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns=']); + expect(options.maxTurns).toBeUndefined(); + expect(options.extraArgs).toBeUndefined(); + }); }); describe('_extractOptions - fallback-model validation', () => { @@ -99,6 +129,18 @@ describe('DelegationHandler', () => { expect(options.fallbackModel).toBe('sonnet'); }); + it('accepts inline fallback-model syntax', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model=sonnet']); + expect(options.fallbackModel).toBe('sonnet'); + expect(options.extraArgs).toBeUndefined(); + }); + + it('treats empty inline fallback-model as missing', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model=']); + expect(options.fallbackModel).toBeUndefined(); + expect(options.extraArgs).toBeUndefined(); + }); + it('rejects dash-prefixed value with warning', () => { const options = handler._extractOptions([ 'glm', @@ -111,6 +153,24 @@ describe('DelegationHandler', () => { expect(consoleErrorSpy).toHaveBeenCalled(); }); + it('does not forward rejected dash-prefixed fallback-model values', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--fallback-model', + '-sonnet', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.fallbackModel).toBeUndefined(); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + it('rejects empty string value', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model', '']); expect(options.fallbackModel).toBeUndefined(); @@ -122,6 +182,54 @@ describe('DelegationHandler', () => { }); }); + describe('_extractOptions - permission-mode parsing', () => { + it('accepts inline permission-mode syntax', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--permission-mode=plan']); + expect(options.permissionMode).toBe('plan'); + expect(options.extraArgs).toBeUndefined(); + }); + + it('treats empty inline permission-mode as missing', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--permission-mode=']); + expect(options.permissionMode).toBe('acceptEdits'); + expect(options.extraArgs).toBeUndefined(); + }); + + it('does not drop following native flags when permission-mode value is missing', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--permission-mode', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.permissionMode).not.toBe('--channels'); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + }); + + it('does not forward rejected dash-prefixed permission-mode values', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--permission-mode', + '-invalid', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.permissionMode).toBe('acceptEdits'); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + }); + describe('_extractOptions - agents JSON validation', () => { it('accepts valid JSON for agents', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--agents', '{"name":"test"}']); @@ -189,6 +297,67 @@ describe('DelegationHandler', () => { const options = handler._extractOptions(['glm', '--effort', 'low', '-p', 'test']); expect(options.extraArgs).toEqual(['--effort', 'low']); }); + + it('passes Claude channels through to extraArgs', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + }); + + it('preserves variadic native flag values without a Claude flag allowlist', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--future-native', + 'alpha', + 'beta', + '--another-native', + 'gamma', + ]); + expect(options.extraArgs).toEqual([ + '--future-native', + 'alpha', + 'beta', + '--another-native', + 'gamma', + ]); + }); + + it('keeps native flag values that match the profile name', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--channels', 'glm']); + expect(options.extraArgs).toEqual(['--channels', 'glm']); + }); + + it('does not drop following native flags when a CCS flag value is missing', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--fallback-model', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.fallbackModel).toBeUndefined(); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + }); + + it('keeps later CCS prompt flags from leaking when a prior CCS value is invalid', () => { + const options = handler._extractOptions(['glm', '--fallback-model', '-p', 'test']); + expect(options.fallbackModel).toBeUndefined(); + expect(options.extraArgs).toBeUndefined(); + }); }); describe('_extractProfile', () => { @@ -202,6 +371,11 @@ describe('DelegationHandler', () => { expect(profile).toBe(''); }); + it('skips inline prompt before profile names', () => { + const profile = handler._extractProfile(['--prompt=test', 'glm']); + expect(profile).toBe('glm'); + }); + it('skips flag values correctly', () => { const profile = handler._extractProfile(['-p', 'test', 'kimi']); expect(profile).toBe('kimi'); @@ -217,4 +391,24 @@ describe('DelegationHandler', () => { expect(profile).toBe('glm'); }); }); + + describe('_extractPrompt', () => { + it('accepts inline prompt syntax', () => { + const prompt = handler._extractPrompt(['glm', '--prompt=test']); + expect(prompt).toBe('test'); + }); + + it('rejects empty inline prompt syntax', () => { + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit); + + try { + expect(() => handler._extractPrompt(['glm', '--prompt='])).toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + exitSpy.mockRestore(); + } + }); + }); }); From 5e06010a4ecc8b6806728d0c9bd51d5d76fe84bd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 06:17:52 -0400 Subject: [PATCH 11/38] chore(release): 7.77.1-dev.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2eeb9ba8..7f4259dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1-dev.4", + "version": "7.77.1-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 61390e5691e91d67ce04b6813a68135b7d283790 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 7 May 2026 11:05:53 -0400 Subject: [PATCH 12/38] fix(cliproxy): diagnose Gemini Plus OAuth credentials Closes #1131 --- .../oauth-handler-paste-callback.test.ts | 63 ++++++++++++++++++ src/cliproxy/auth/oauth-handler.ts | 66 ++++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts index 40d3321b..025e03fc 100644 --- a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts @@ -80,6 +80,69 @@ describe('requestPasteCallbackStart', () => { }); }); +describe('Gemini Plus OAuth credential diagnostics', () => { + it('fails fast when Gemini uses Plus without OAuth client env', async () => { + const { getGeminiPlusOAuthCredentialError } = await import( + `../oauth-handler?gemini-plus-missing-env=${Date.now()}` + ); + + const error = getGeminiPlusOAuthCredentialError('gemini', 'plus', {}); + + expect(error).toContain('Gemini OAuth from CLIProxy Plus is missing'); + expect(error).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_ID'); + expect(error).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET'); + expect(error).toContain('cliproxy.backend'); + expect(error).toContain('original'); + }); + + it('allows Gemini Plus when both OAuth client env values exist', async () => { + const { getGeminiPlusOAuthCredentialError } = await import( + `../oauth-handler?gemini-plus-env-present=${Date.now()}` + ); + + expect( + getGeminiPlusOAuthCredentialError('gemini', 'plus', { + CLIPROXY_GEMINI_OAUTH_CLIENT_ID: 'client-id', + CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET: 'client-secret', + }) + ).toBeNull(); + }); + + it('does not warn for Gemini on the original backend', async () => { + const { getGeminiPlusOAuthCredentialError } = await import( + `../oauth-handler?gemini-original-backend=${Date.now()}` + ); + + expect(getGeminiPlusOAuthCredentialError('gemini', 'original', {})).toBeNull(); + }); + + it('detects Gemini auth URLs missing client_id before display', async () => { + const { getGeminiAuthUrlCredentialError } = await import( + `../oauth-handler?gemini-auth-url-missing-client=${Date.now()}` + ); + + const error = getGeminiAuthUrlCredentialError( + 'gemini', + 'https://accounts.google.com/o/oauth2/v2/auth?client_id=&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback&state=test' + ); + + expect(error).toContain('Gemini OAuth from CLIProxy Plus is missing'); + }); + + it('allows Gemini auth URLs with client_id present', async () => { + const { getGeminiAuthUrlCredentialError } = await import( + `../oauth-handler?gemini-auth-url-client-present=${Date.now()}` + ); + + expect( + getGeminiAuthUrlCredentialError( + 'gemini', + 'https://accounts.google.com/o/oauth2/v2/auth?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback&state=test' + ) + ).toBeNull(); + }); +}); + describe('usesKiroLocalCallbackReplay', () => { it('limits local callback replay to CLI auth-code flows', async () => { const { usesKiroLocalCallbackReplay } = await import( diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 3777e9a3..c8dd2f74 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -13,9 +13,9 @@ import * as fs from 'fs'; import { fail, info, warn, color, ok } from '../../utils/ui'; import { createLogger } from '../../services/logging'; -import { ensureCLIProxyBinary } from '../binary-manager'; +import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager'; import { generateConfig } from '../config/config-generator'; -import { CLIProxyProvider } from '../types'; +import { CLIProxyBackend, CLIProxyProvider } from '../types'; import { AccountInfo, getProviderAccounts, @@ -82,9 +82,54 @@ interface PasteCallbackStartData { const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000; const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000; +const GEMINI_PLUS_CLIENT_ID_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_ID'; +const GEMINI_PLUS_CLIENT_SECRET_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET'; const logger = createLogger('cliproxy:auth:oauth'); +function buildGeminiPlusOAuthCredentialMessage(missing?: string[]): string { + const missingText = missing?.length ? ` Missing: ${missing.join(', ')}.` : ''; + return ( + 'Gemini OAuth from CLIProxy Plus is missing Google OAuth client credentials.' + + missingText + + ` Set ${GEMINI_PLUS_CLIENT_ID_ENV} and ${GEMINI_PLUS_CLIENT_SECRET_ENV} before starting CLIProxy Plus,` + + ' or switch `cliproxy.backend` to `original` for Gemini.' + ); +} + +export function getGeminiPlusOAuthCredentialError( + provider: CLIProxyProvider, + backend: CLIProxyBackend, + env: NodeJS.ProcessEnv = process.env +): string | null { + if (provider !== 'gemini' || backend !== 'plus') { + return null; + } + + const missing = [GEMINI_PLUS_CLIENT_ID_ENV, GEMINI_PLUS_CLIENT_SECRET_ENV].filter( + (name) => !env[name]?.trim() + ); + + return missing.length > 0 ? buildGeminiPlusOAuthCredentialMessage(missing) : null; +} + +export function getGeminiAuthUrlCredentialError( + provider: CLIProxyProvider, + authUrl: string +): string | null { + if (provider !== 'gemini') { + return null; + } + + try { + const parsed = new URL(authUrl); + const clientId = parsed.searchParams.get('client_id')?.trim(); + return clientId ? null : buildGeminiPlusOAuthCredentialMessage(); + } catch { + return null; + } +} + export async function requestPasteCallbackStart( provider: CLIProxyProvider, target: ProxyTarget, @@ -549,6 +594,12 @@ async function handlePasteCallbackMode( return null; } + const authUrlCredentialError = getGeminiAuthUrlCredentialError(provider, authUrl); + if (authUrlCredentialError) { + console.log(fail(authUrlCredentialError)); + return null; + } + const oauthState = startData.state || parseAuthUrlState(authUrl); const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir); @@ -920,6 +971,17 @@ export async function triggerOAuth( const useSelectedKiroDirectCliFlow = provider === 'kiro' && (isDeviceCodeFlow || useSelectedKiroLocalPasteCallback); + if (!(selectedPasteCallback && !useSelectedKiroDirectCliFlow)) { + const credentialError = getGeminiPlusOAuthCredentialError( + provider, + getStoredConfiguredBackend() + ); + if (credentialError) { + console.log(fail(credentialError)); + return null; + } + } + if (existingAccounts.length > 0 && !add) { console.log(''); console.log( From a95266e4ccd7bf0a12ff2c8c3df1bf717272884f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 11:10:09 -0400 Subject: [PATCH 13/38] chore(release): 7.77.1-dev.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7f4259dc..27b18905 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1-dev.5", + "version": "7.77.1-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 8b681df455ed1386818e5a3f919b7ac4f3f7b53b Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 7 May 2026 11:25:17 -0400 Subject: [PATCH 14/38] fix: route Cursor auth through browser polling Closes #1194 --- docs/system-architecture/provider-flows.md | 8 +++- .../__tests__/provider-capabilities.test.ts | 12 ++++++ .../backend-ui-default-ports-sync.test.ts | 10 ++--- src/cliproxy/provider-capabilities.ts | 16 ++++++++ src/web-server/routes/cliproxy-auth-routes.ts | 10 ++++- tests/unit/ui/provider-config.test.ts | 7 ++++ ...iproxy-auth-routes-manual-callback.test.ts | 30 +++++++++++++- .../web-server/cliproxy-auth-routes.test.ts | 15 +++---- .../components/account/add-account-dialog.tsx | 4 +- ui/src/lib/provider-config.ts | 17 ++++---- .../hooks/use-cliproxy-auth-flow.test.tsx | 41 +++++++++++++++++++ ui/tests/unit/ui/lib/provider-config.test.ts | 7 ++++ 12 files changed, 152 insertions(+), 25 deletions(-) diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 2eb12953..46a3bce9 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-03-30 +Last Updated: 2026-05-07 Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication. @@ -44,6 +44,11 @@ Generated local CLIProxy configs also keep the management dashboard aligned with | - User enters code at github.com/login/device | - Polls for token completion | | + | +---> Browser URL Polling (no callback port) + | - Cursor + | - Opens provider login URL returned by CLIProxyAPIPlus + | - Polls auth state until token is saved + | | | v | +------------------+ | | OAuth Server | Browser-based auth @@ -81,6 +86,7 @@ Generated local CLIProxy configs also keep the management dashboard aligned with | Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI | | Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork | | GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus fork | +| Cursor | `cursor` | Browser URL polling | none | CLIProxyAPIPlus fork | ### Codex Duplicate-Email Account Identity diff --git a/src/cliproxy/__tests__/provider-capabilities.test.ts b/src/cliproxy/__tests__/provider-capabilities.test.ts index d55cb54d..02fea0b0 100644 --- a/src/cliproxy/__tests__/provider-capabilities.test.ts +++ b/src/cliproxy/__tests__/provider-capabilities.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test'; import { buildProviderAliasMap, CLIPROXY_PROVIDER_IDS, + getDeviceCodeVerificationProviders, getOAuthCallbackPort, getOAuthFlowType, PROVIDER_CAPABILITIES, @@ -76,6 +77,17 @@ describe('provider-capabilities', () => { ]); }); + it('separates browser URL auth providers from verification-code device flows', () => { + expect(getDeviceCodeVerificationProviders()).toEqual([ + 'qwen', + 'kiro', + 'ghcp', + 'kimi', + 'codebuddy', + 'kilo', + ]); + }); + it('maps external provider aliases to canonical IDs', () => { expect(mapExternalProviderName('gemini-cli')).toBe('gemini'); expect(mapExternalProviderName('antigravity')).toBe('agy'); diff --git a/src/cliproxy/config/__tests__/backend-ui-default-ports-sync.test.ts b/src/cliproxy/config/__tests__/backend-ui-default-ports-sync.test.ts index 58ba0eb6..33045ff3 100644 --- a/src/cliproxy/config/__tests__/backend-ui-default-ports-sync.test.ts +++ b/src/cliproxy/config/__tests__/backend-ui-default-ports-sync.test.ts @@ -9,9 +9,9 @@ import { CLIPROXY_DEFAULT_PORT as BACKEND_CLIPROXY_DEFAULT_PORT } from '../port- import { DEFAULT_CURSOR_PORT as BACKEND_CURSOR_DEFAULT_PORT } from '../../../cursor/cursor-models'; import { CLIPROXY_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS, + getDeviceCodeVerificationProviders, getProviderDescription as getBackendProviderDescription, getProviderDisplayName as getBackendProviderDisplayName, - getProvidersByOAuthFlow, } from '../../provider-capabilities'; import { CLIPROXY_DEFAULT_PORT as UI_CLIPROXY_DEFAULT_PORT, @@ -20,9 +20,9 @@ import { import { CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS, CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_PROVIDERS, - DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS, PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS, PROVIDER_METADATA as UI_PROVIDER_METADATA, + VERIFICATION_CODE_AUTH_PROVIDERS as UI_VERIFICATION_CODE_AUTH_PROVIDERS, } from '../../../../ui/src/lib/provider-config'; import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../types/index'; @@ -43,9 +43,9 @@ describe('Default Port Sync', () => { expect(sorted(UI_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_CLIPROXY_PROVIDER_IDS)); }); - test('Device code providers are synced between backend and UI', () => { - expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual( - sorted(getProvidersByOAuthFlow('device_code')) + test('verification-code auth providers are synced between backend and UI', () => { + expect(sorted(UI_VERIFICATION_CODE_AUTH_PROVIDERS)).toEqual( + sorted(getDeviceCodeVerificationProviders()) ); }); diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index bafd436a..d473d13f 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -188,6 +188,12 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze( Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[] ); +export const BROWSER_URL_AUTH_PROVIDER_IDS = Object.freeze([ + 'cursor', +] as const satisfies readonly CLIProxyProvider[]); + +const BROWSER_URL_AUTH_PROVIDER_SET = new Set(BROWSER_URL_AUTH_PROVIDER_IDS); + /** Providers currently supported by quota status fetchers. */ export const QUOTA_SUPPORTED_PROVIDER_IDS = Object.freeze([ 'agy', @@ -278,6 +284,16 @@ export function getProvidersByOAuthFlow(flowType: OAuthFlowType): CLIProxyProvid ); } +export function isBrowserUrlAuthProvider(provider: CLIProxyProvider): boolean { + return BROWSER_URL_AUTH_PROVIDER_SET.has(provider); +} + +export function getDeviceCodeVerificationProviders(): CLIProxyProvider[] { + return getProvidersByOAuthFlow('device_code').filter( + (provider) => !isBrowserUrlAuthProvider(provider) + ); +} + export function getOAuthFlowType(provider: CLIProxyProvider): OAuthFlowType { return PROVIDER_CAPABILITIES[provider].oauthFlow; } diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 5faae932..a1547ce7 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -56,7 +56,11 @@ import { normalizeKiroAuthMethod, toKiroManagementMethod, } from '../../cliproxy/auth/auth-types'; -import { getOAuthFlowType, mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { + getOAuthFlowType, + isBrowserUrlAuthProvider, + mapExternalProviderName, +} from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { @@ -286,6 +290,10 @@ export function getStartUrlUnsupportedReason( return null; } + if (isBrowserUrlAuthProvider(provider)) { + return null; + } + if (getOAuthFlowType(provider) === 'device_code') { return `Provider '${provider}' uses Device Code flow. Use /api/cliproxy/auth/${provider}/start instead.`; } diff --git a/tests/unit/ui/provider-config.test.ts b/tests/unit/ui/provider-config.test.ts index fe85c2aa..8e5b04b4 100644 --- a/tests/unit/ui/provider-config.test.ts +++ b/tests/unit/ui/provider-config.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test'; import { getDeviceCodeProviderInstruction, getProviderDisplayName, + isDeviceCodeProvider, } from '../../../ui/src/lib/provider-config'; describe('provider-config fallbacks', () => { @@ -15,4 +16,10 @@ describe('provider-config fallbacks', () => { 'Complete the authorization in your browser.' ); }); + + it('does not classify Cursor browser URL auth as a verification-code device flow', () => { + expect(isDeviceCodeProvider('cursor')).toBe(false); + expect(isDeviceCodeProvider('ghcp')).toBe(true); + expect(isDeviceCodeProvider('qwen')).toBe(true); + }); }); diff --git a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts index 4a4ee4b8..89b83904 100644 --- a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts @@ -11,7 +11,7 @@ import { getCachedQuota, setCachedQuota, } from '../../../src/cliproxy/quota/quota-response-cache'; -import { restoreFetch, mockFetch } from '../../mocks'; +import { restoreFetch, mockFetch, getCapturedFetchRequests } from '../../mocks'; describe('cliproxy-auth-routes manual callback nickname persistence', () => { let server: Server; @@ -179,6 +179,34 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work'); }); + it('starts Cursor auth through the browser URL management endpoint', async () => { + mockFetch([ + { + url: /\/v0\/management\/cursor-auth-url\?is_webui=true$/, + response: { + url: 'https://cursor.com/loginDeepControl?state=cursor-state', + state: 'cursor-state', + }, + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/cursor/start-url', { + nickname: 'work', + }); + + expect(startResponse.status).toBe(200); + expect(startResponse.body).toEqual({ + success: true, + authUrl: 'https://cursor.com/loginDeepControl?state=cursor-state', + state: 'cursor-state', + method: null, + }); + + const requests = getCapturedFetchRequests(); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toContain('/v0/management/cursor-auth-url?is_webui=true'); + }); + it('returns wait after callback submission when the local token is not yet available', async () => { mockFetch([ { diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 8c21fb73..b6cf9d82 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -13,15 +13,14 @@ describe('cliproxy-auth-routes start-url guard', () => { ); expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow"); expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow"); - expect(getStartUrlUnsupportedReason('cursor')).toContain( - "Provider 'cursor' uses Device Code flow" - ); expect(getStartUrlUnsupportedReason('codebuddy')).toContain( "Provider 'codebuddy' uses Device Code flow" ); - expect(getStartUrlUnsupportedReason('kilo')).toContain( - "Provider 'kilo' uses Device Code flow" - ); + expect(getStartUrlUnsupportedReason('kilo')).toContain("Provider 'kilo' uses Device Code flow"); + }); + + it('allows Cursor browser URL auth on start-url', () => { + expect(getStartUrlUnsupportedReason('cursor')).toBeNull(); }); it('allows Kiro social methods on start-url', () => { @@ -138,6 +137,8 @@ describe('cliproxy-auth-routes nickname validation', () => { ]; expect(getStartAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull(); - expect(getStartAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull(); + expect( + getStartAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123') + ).toBeNull(); }); }); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index b8fcaa38..403ba88f 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -293,8 +293,8 @@ export function AddAccountDialog({ /** * Start auth flow using provider capabilities. - * - Device code providers use /start and rely on WebSocket events for code display. - * - Authorization code providers use /start-url and polling. + * - Verification-code providers use /start and WebSocket events for code display. + * - Browser URL providers use /start-url and polling. */ const handleAuthenticate = () => { if (isPowerUserModePending) { diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 00c34cc7..b99bf270 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -7,7 +7,7 @@ import { CLIPROXY_PROVIDER_IDS, PROVIDER_CAPABILITIES, - getProvidersByOAuthFlow, + getDeviceCodeVerificationProviders, } from '../../../src/cliproxy/provider-capabilities'; import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers'; import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types'; @@ -346,13 +346,14 @@ export function getProviderDescription(provider: unknown): string { return PROVIDER_METADATA[normalized].description; } -/** - * Providers that use Device Code OAuth flow instead of Authorization Code flow. - */ -export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = [ - ...getProvidersByOAuthFlow('device_code'), +/** Providers whose add-account UX shows a verification code and uses `/start`. */ +export const VERIFICATION_CODE_AUTH_PROVIDERS: CLIProxyProvider[] = [ + ...getDeviceCodeVerificationProviders(), ]; +/** Backward-compatible alias for verification-code auth providers. */ +export const DEVICE_CODE_PROVIDERS = VERIFICATION_CODE_AUTH_PROVIDERS; + const DEVICE_CODE_PROVIDER_DISPLAY_NAMES: Readonly>> = Object.freeze({ ghcp: 'GitHub Copilot', @@ -368,10 +369,10 @@ const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly { vi.restoreAllMocks(); }); + it('routes Cursor through start-url browser polling by default', async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/cursor/start-url')) { + return Promise.resolve( + createJsonResponse({ + success: true, + authUrl: 'https://cursor.com/loginDeepControl?state=cursor-state', + state: 'cursor-state', + }) + ); + } + + if (url.includes('/status?state=cursor-state')) { + return Promise.resolve(createJsonResponse({ status: 'wait' })); + } + + return Promise.resolve(createJsonResponse({ success: true, account: { id: 'cursor-test' } })); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('cursor'); + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/cliproxy/auth/cursor/start-url', + expect.objectContaining({ method: 'POST' }) + ); + expect(result.current.isDeviceCodeFlow).toBe(false); + expect(result.current.authUrl).toContain('cursor.com/loginDeepControl'); + expect(window.open).toHaveBeenCalledWith( + 'https://cursor.com/loginDeepControl?state=cursor-state', + '_blank' + ); + }); + it('ignores stale poll completions from a superseded auth attempt', async () => { const firstPoll = createDeferred(); let startCount = 0; diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index f83ede63..f4552276 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -12,6 +12,7 @@ import { getRequestedUpstreamModelRuleErrors, getRequestedModelId, groupProvidersBySection, + isDeviceCodeProvider, isPlusExtraProvider, parseRequestedUpstreamModelRules, PLUS_EXTRA_CLIPROXY_PROVIDERS, @@ -90,6 +91,12 @@ describe('provider presentation metadata', () => { expect(grouped[1]?.items.map((entry) => entry.provider)).toEqual(['cursor']); }); + it('keeps Cursor out of verification-code device flow routing', () => { + expect(isDeviceCodeProvider('cursor')).toBe(false); + expect(isDeviceCodeProvider('ghcp')).toBe(true); + expect(isDeviceCodeProvider('qwen')).toBe(true); + }); + it('detects plus-extra providers inside composite variants', () => { expect( variantUsesPlusExtraProvider({ From e2974ede440fe1d1ea8bb224272bd03dc3da8ad3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 11:29:56 -0400 Subject: [PATCH 15/38] chore(release): 7.77.1-dev.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 27b18905..55ca10bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1-dev.6", + "version": "7.77.1-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 19a50a8dd86ffcc602165701aa15981a53b822b8 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 7 May 2026 13:06:03 -0400 Subject: [PATCH 16/38] feat: deprecate GitHub Copilot compatibility surfaces (#1196) --- README.md | 3 ++- docs/project-overview-pdr.md | 14 +++++------ docs/project-roadmap.md | 7 +++--- docs/system-architecture/provider-flows.md | 14 +++++------ src/cliproxy/provider-capabilities.ts | 2 +- src/commands/command-catalog.ts | 4 +-- src/commands/copilot-command.ts | 8 +++++- src/commands/help-command.ts | 2 +- src/config/loader/yaml-serializer.ts | 9 ++++--- src/config/schemas/copilot-cursor.ts | 4 +-- src/config/schemas/unified-config.ts | 2 +- ui/src/components/layout/app-sidebar.tsx | 6 ++++- ui/src/components/setup/wizard/constants.ts | 1 - ui/src/lib/i18n.ts | 28 ++++++++++++++++++--- ui/src/pages/copilot.tsx | 18 +++++++++++++ 15 files changed, 87 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 93577d2a..20b04f81 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ CCS gives you one stable command surface while letting you switch between: - multiple runtimes such as Claude Code, Factory Droid, and Codex CLI - multiple Claude subscriptions and isolated account contexts -- OAuth providers like Codex, Copilot, Kiro, Claude, Qwen, Kimi, and more +- OAuth providers like Codex, Kiro, Claude, Qwen, Kimi, and more, with legacy + Copilot compatibility for existing setups - API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp, Novita, and Alibaba Coding Plan diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 14c87e4e..d4e6449c 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -1,6 +1,6 @@ # CCS Product Development Requirements (PDR) -Last Updated: 2026-04-08 +Last Updated: 2026-05-07 ## Product Overview @@ -31,7 +31,7 @@ Developers using Claude Code face these challenges: CCS provides: 1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR` -2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Copilot, Kiro (ghcp) integration +2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Kiro, and other active OAuth integrations, with deprecated Copilot compatibility for existing setups 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 @@ -68,8 +68,8 @@ CCS provides: - Share commands, skills, agents across accounts ### FR-003: OAuth Provider Integration -- Support Gemini, Codex, Antigravity, Copilot, Kiro (ghcp) OAuth flows -- Browser-based authentication (Authorization Code flow for most, Device Code for ghcp) +- Support Gemini, Codex, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows +- Browser-based authentication (Authorization Code flow for most, Device Code for ghcp compatibility) - Token caching and refresh ### FR-004: API Profile Management @@ -273,7 +273,7 @@ CCS provides: ### v7.2 Release (Complete) - [x] Kiro (AWS) OAuth provider support via CLIProxyAPIPlus -- [x] GitHub Copilot (ghcp) OAuth provider via Device Code flow +- [x] GitHub Copilot (ghcp) OAuth provider via Device Code flow (deprecated compatibility) - [x] Authorization Code flow for Kiro (port 9876) - [x] Device Code flow for ghcp (no local port needed) @@ -334,8 +334,8 @@ CCS provides: ### External Services - Anthropic Claude API - Google Gemini API -- GitHub Codex/Copilot API -- GitHub Copilot (ghcp - Device Code OAuth) +- GitHub Codex API +- GitHub Copilot (ghcp - deprecated Device Code OAuth compatibility) - AWS Kiro (Authorization Code OAuth) - Z.AI GLM API - OpenRouter API diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 76e0ff07..2e9441d1 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -22,7 +22,7 @@ All major modularization work is complete. The codebase evolved from monolithic | 8 | Auth Monitor | `monitoring/auth-monitor/` (465->8 files) | | 9 | Test Infrastructure | 1407 tests, 90% coverage | | 10 | Remote CLIProxy | `proxy-config-resolver.ts`, `remote-proxy-client.ts` | -| 11 | Kiro + ghcp Providers | OAuth support via CLIProxyAPIPlus (v7.2) | +| 11 | Kiro + legacy ghcp Providers | OAuth support via CLIProxyAPIPlus (v7.2) | | 12 | Hybrid Quota Management | `quota-manager.ts`, `quota-fetcher.ts` (v7.14) | | 13 | Docker Support | `docker/` directory with Dockerfile, Compose, entrypoint | | 14 | Image Analysis Hook | Vision proxying via CLIProxy transformers (v7.34) | @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-05-07**: **#1103** GitHub Copilot is now treated as a deprecated compatibility bridge. The dashboard moves Copilot out of the active Identity & Access section and into Deprecated, quick setup no longer offers `ghcp` for new onboarding, CLI/help/config copy marks Copilot as deprecated, and existing `ccs copilot` / `ghcp` compatibility paths remain available for current setups. - **2026-05-07**: **#1189** Headless settings-profile delegation now preserves native Claude passthrough args without a Claude flag allowlist. Explicit `--channels` values reach Claude Code, future native flags can carry multiple adjacent values, malformed CCS-owned flags no longer swallow the next native flag, and `--prompt=` routes through headless delegation consistently with `--prompt `. - **2026-05-03**: **#1172** Local CLIProxy config generation now keeps the CPAMC management dashboard aligned with backend selection. `backend: original` points to the upstream dashboard, `backend: plus` points to the CCS-maintained CPAMC fork, `cliproxy.management_panel_repository` lets advanced users override the panel repository, and stale generated configs are regenerated when the expected panel source changes. - **2026-04-30**: **#1153** Native Claude launches now accept session-scoped `--effort low|medium|high|xhigh|max` overrides through CCS without mutating global Claude settings. CCS validates invalid or missing effort values before spawning Claude, normalizes accepted values, keeps default headless `-p/--prompt` launches on native Claude instead of delegation parsing, and preserves CLIProxy/Codex/Droid effort aliases. @@ -214,7 +215,7 @@ worktrees: - **#158**: Fix AGY OAuth flow - **#157**: ~~Add Kiro auth support from CLIProxyAPIPlus~~ **COMPLETE** (v7.2) -- GitHub Copilot (ghcp) Device Code flow **COMPLETE** (v7.2) +- GitHub Copilot (ghcp) Device Code flow **COMPLETE** (v7.2, now deprecated compatibility) - Hybrid quota management **COMPLETE** (v7.14) --- @@ -225,7 +226,7 @@ worktrees: |-----------|--------|--------| | Modularization (Phases 1-9) | COMPLETE | - | | Remote CLIProxy Support (#142) | COMPLETE | v7.1 | -| Kiro + GitHub Copilot OAuth (#157) | COMPLETE | v7.2 | +| Kiro + GitHub Copilot OAuth (#157) | COMPLETE, Copilot now deprecated compatibility | v7.2 | | Hybrid Quota Management | COMPLETE | v7.14 | | Docker Support (PR #345) | COMPLETE | v7.23 | | Image Analysis Hook | COMPLETE | v7.34 | diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 46a3bce9..3a6f8d41 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -14,7 +14,7 @@ CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with ### Local Backend Choice -CCS defaults to the original `router-for-me/CLIProxyAPI` backend because it is the stable MIT upstream. The `plus` backend is an explicit opt-in path that downloads the community-maintained `kaitranntt/CLIProxyAPIPlus` fork for providers that still require Plus-only support, such as Kiro, GitHub Copilot, Cursor, GitLab, CodeBuddy, and Kilo. CCS does not silently downgrade `backend: plus` to `original`; users choose that backend deliberately when they need those providers. +CCS defaults to the original `router-for-me/CLIProxyAPI` backend because it is the stable MIT upstream. The `plus` backend is an explicit opt-in path that downloads the community-maintained `kaitranntt/CLIProxyAPIPlus` fork for providers that still require Plus-only support, such as Kiro, Cursor, GitLab, CodeBuddy, Kilo, and deprecated GitHub Copilot compatibility. CCS does not silently downgrade `backend: plus` to `original`; users choose that backend deliberately when they need those providers. Generated local CLIProxy configs also keep the management dashboard aligned with the selected backend. `backend: original` uses upstream CPAMC (`router-for-me/Cli-Proxy-API-Management-Center`), while `backend: plus` uses the CCS-maintained dashboard fork (`kaitranntt/Cli-Proxy-API-Management-Center`). Advanced users can override the generated `remote-management.panel-github-repository` value by setting `cliproxy.management_panel_repository` in `~/.ccs/config.yaml`; CCS will regenerate stale local CLIProxy configs when the expected dashboard repository changes. @@ -40,7 +40,7 @@ Generated local CLIProxy configs also keep the management dashboard aligned with | | - Callback to localhost:PORT | | | +---> Device Code Flow (no port needed) - | - GitHub Copilot (ghcp) + | - GitHub Copilot (ghcp, deprecated compatibility) | - User enters code at github.com/login/device | - Polls for token completion | | @@ -73,7 +73,7 @@ Generated local CLIProxy configs also keep the management dashboard aligned with +---> GitHub (Codex) +---> Antigravity (AGY) +---> AWS Kiro (Claude-powered) - +---> GitHub Copilot (ghcp) + +---> GitHub Copilot (ghcp, deprecated compatibility) +---> OpenAI-compatible endpoints ``` @@ -85,7 +85,7 @@ Generated local CLIProxy configs also keep the management dashboard aligned with | Codex | `codex` | Authorization Code | 9876 | CLIProxyAPI | | Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI | | Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork | -| GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus fork | +| GitHub Copilot (deprecated) | `ghcp` | Device Code | none | CLIProxyAPIPlus fork | | Cursor | `cursor` | Browser URL polling | none | CLIProxyAPIPlus fork | ### Codex Duplicate-Email Account Identity @@ -299,7 +299,7 @@ When CCS detects exhaustion and a healthy fallback exists, it temporarily pauses | | - Claude: policy limits endpoint | | - Codex: ChatGPT usage windows | | - Gemini CLI: Code Assist quota buckets - | | - GitHub Copilot: copilot_internal/user snapshots + | | - GitHub Copilot: copilot_internal/user snapshots (deprecated compatibility) | | | +---> Detect tier (free/paid/unknown) | | @@ -391,7 +391,7 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null { ### OAuth Providers - Device Code Flow -**Providers**: GitHub Copilot (ghcp) +**Providers**: GitHub Copilot (ghcp, deprecated compatibility) Provider identity note: - Providers that do not expose a reliable email no longer require a manual nickname during first auth. @@ -591,7 +591,7 @@ Image Analysis Hook enables vision model proxying through CLIProxy with automati | Codex | ✓ | Via CCS ImageAnalysis provider route | | Antigravity | ✓ | Via CCS ImageAnalysis provider route | | Kiro | ✓ | Via mapped CCS provider route when configured | -| Copilot | ✓ | Via mapped ghcp provider route | +| Copilot | ✓ | Deprecated compatibility route via mapped ghcp provider | | GLM/Kimi | ✓ | Via explicit or fallback backend mapping | --- diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index d473d13f..1d020d77 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -100,7 +100,7 @@ export const PROVIDER_CAPABILITIES: Record'); console.log(''); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 20fd265a..014df452 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -290,7 +290,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr { name: 'ccs proxy --help', summary: 'Deep help for the OpenAI-compatible local proxy' }, { name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' }, { name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' }, - { name: 'ccs copilot --help', summary: 'Deep help for GitHub Copilot commands' }, + { name: 'ccs copilot --help', summary: 'Deep help for deprecated GitHub Copilot commands' }, ], writeLine ); diff --git a/src/config/loader/yaml-serializer.ts b/src/config/loader/yaml-serializer.ts index e9106c4c..0365899b 100644 --- a/src/config/loader/yaml-serializer.ts +++ b/src/config/loader/yaml-serializer.ts @@ -162,11 +162,14 @@ export function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } - // Copilot section (GitHub Copilot proxy) + // Copilot section (deprecated GitHub Copilot compatibility bridge) if (config.copilot) { lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Copilot: GitHub Copilot API proxy (via copilot-api)'); - lines.push('# Uses your existing GitHub Copilot subscription with Claude Code.'); + lines.push('# Copilot: Deprecated GitHub Copilot compatibility bridge (via copilot-api)'); + lines.push( + '# Existing local setups remain available, but prefer Codex or another active provider.' + ); + lines.push('# GitHub usage-based Copilot billing begins June 1, 2026.'); lines.push('#'); lines.push('# !! DISCLAIMER - USE AT YOUR OWN RISK !!'); lines.push('# This uses an UNOFFICIAL reverse-engineered API.'); diff --git a/src/config/schemas/copilot-cursor.ts b/src/config/schemas/copilot-cursor.ts index e0b33fbb..761e4b64 100644 --- a/src/config/schemas/copilot-cursor.ts +++ b/src/config/schemas/copilot-cursor.ts @@ -2,7 +2,7 @@ * Copilot and Cursor IDE integration configuration types and defaults. * * Covers: - * - CopilotConfig: GitHub Copilot proxy integration (strictly opt-in) + * - CopilotConfig: deprecated GitHub Copilot proxy compatibility (strictly opt-in) * - CursorConfig: Cursor IDE proxy daemon */ @@ -13,7 +13,7 @@ export type CopilotAccountType = 'individual' | 'business' | 'enterprise'; /** * Copilot API configuration. - * Enables GitHub Copilot subscription usage via copilot-api proxy. + * Enables deprecated GitHub Copilot compatibility via copilot-api proxy. * Strictly opt-in - disabled by default. * * !! DISCLAIMER - USE AT YOUR OWN RISK !! diff --git a/src/config/schemas/unified-config.ts b/src/config/schemas/unified-config.ts index 78726c46..5290ccc0 100644 --- a/src/config/schemas/unified-config.ts +++ b/src/config/schemas/unified-config.ts @@ -68,7 +68,7 @@ export interface UnifiedConfig { global_env?: GlobalEnvConfig; /** Cross-profile continuity inheritance mapping */ continuity?: ContinuityConfig; - /** Copilot API configuration (GitHub Copilot proxy) */ + /** Copilot API configuration (deprecated GitHub Copilot compatibility bridge) */ copilot?: CopilotConfig; /** Cursor IDE configuration (Cursor proxy daemon) */ cursor?: CursorConfig; diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index a50ee9e4..2812b501 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -102,7 +102,6 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] { { path: '/cliproxy/control-panel', icon: Gauge, label: t('nav.controlPanel') }, ], }, - { path: '/copilot', icon: Github, label: t('nav.githubCopilot') }, { path: '/accounts', icon: Users, @@ -126,6 +125,11 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] { { title: t('nav.deprecated'), items: [ + { + path: '/copilot', + icon: Github, + label: `${t('nav.githubCopilot')} (${t('nav.deprecated')})`, + }, { path: '/legacy/cursor', iconSrc: '/assets/sidebar/cursor.svg', diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index 50a2c1ca..f40b591e 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -25,7 +25,6 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ 'iflow', 'kilo', 'kiro', - 'ghcp', ]; export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({ diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index c0a05780..4a6a2f03 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1278,7 +1278,12 @@ const resources = { }, copilotPage: { title: 'Copilot', - subtitle: 'GitHub Copilot proxy', + subtitle: 'Deprecated GitHub Copilot compatibility bridge', + deprecatedBadge: 'Deprecated', + deprecationTitle: 'Deprecated compatibility bridge', + deprecationItem1: 'GitHub usage-based Copilot billing begins June 1, 2026', + deprecationItem2: 'Existing local setups remain available for compatibility', + deprecationItem3: 'Prefer Codex or another active provider for new work', setup: 'Setup', auth: 'Auth', daemon: 'Daemon', @@ -3813,7 +3818,12 @@ const resources = { }, copilotPage: { title: 'Copilot', - subtitle: 'GitHub Copilot 代理', + subtitle: '已弃用的 GitHub Copilot 兼容桥接', + deprecatedBadge: '已弃用', + deprecationTitle: '已弃用的兼容桥接', + deprecationItem1: 'GitHub Copilot 用量计费将于 2026 年 6 月 1 日开始', + deprecationItem2: '现有本地配置仍可作为兼容路径继续使用', + deprecationItem3: '新的配置请优先使用 Codex 或其他活跃提供商', setup: '安装', auth: '认证', daemon: '守护进程', @@ -6396,7 +6406,12 @@ const resources = { }, copilotPage: { title: 'Copilot', - subtitle: 'Proxy GitHub Copilot', + subtitle: 'Cầu nối tương thích GitHub Copilot đã ngừng ưu tiên', + deprecatedBadge: 'Đã ngừng ưu tiên', + deprecationTitle: 'Cầu nối tương thích đã ngừng ưu tiên', + deprecationItem1: 'GitHub Copilot bắt đầu tính phí theo mức dùng từ 2026-06-01', + deprecationItem2: 'Thiết lập cục bộ hiện có vẫn được giữ cho tương thích', + deprecationItem3: 'Thiết lập mới nên dùng Codex hoặc nhà cung cấp còn hoạt động', setup: 'Cài đặt', auth: 'Xác thực', daemon: 'Daemon', @@ -9004,7 +9019,12 @@ const resources = { }, copilotPage: { title: 'Copilot', - subtitle: 'GitHub Copilot プロキシ', + subtitle: '非推奨の GitHub Copilot 互換ブリッジ', + deprecatedBadge: '非推奨', + deprecationTitle: '非推奨の互換ブリッジ', + deprecationItem1: 'GitHub Copilot の従量課金は 2026-06-01 に開始されます', + deprecationItem2: '既存のローカル設定は互換用として引き続き利用できます', + deprecationItem3: '新規設定では Codex または他の有効なプロバイダーを優先してください', setup: 'セットアップ', auth: '認証', daemon: 'デーモン', diff --git a/ui/src/pages/copilot.tsx b/ui/src/pages/copilot.tsx index 23a58578..d8841e1a 100644 --- a/ui/src/pages/copilot.tsx +++ b/ui/src/pages/copilot.tsx @@ -125,6 +125,9 @@ export function CopilotPage() {

{t('copilotPage.title')}

+ + {t('copilotPage.deprecatedBadge')} +
)} + {!isCliproxy && ( + + )} {!isCliproxy && hasLegacyInference && ( + + + + + ); +} diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts index 890ff1ac..8440e2c1 100644 --- a/ui/src/hooks/use-accounts.ts +++ b/ui/src/hooks/use-accounts.ts @@ -208,3 +208,28 @@ export function useConfirmLegacyAccountPolicies() { }, }); } +export function useUpdateAccountSharedResources() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: ({ + name, + shared_resource_mode, + }: { + name: string; + shared_resource_mode: 'shared' | 'profile-local'; + }) => api.accounts.updateSharedResources(name, { shared_resource_mode }), + onSuccess: (_data, vars) => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + const resourceSummary = + vars.shared_resource_mode === 'shared' + ? t('accountsPage.resourcesShared') + : t('accountsPage.resourcesProfileLocal'); + toast.success(t('toasts.resourcesUpdated', { name: vars.name, summary: resourceSummary })); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/hooks/use-shared.ts b/ui/src/hooks/use-shared.ts index 09a726b5..ecba2ea7 100644 --- a/ui/src/hooks/use-shared.ts +++ b/ui/src/hooks/use-shared.ts @@ -4,13 +4,17 @@ export interface SharedItem { name: string; description: string; path: string; - type: 'command' | 'skill' | 'agent'; + type: 'command' | 'skill' | 'agent' | 'plugin'; } -interface SharedSummary { +export type SharedResourceTab = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings'; + +export interface SharedSummary { commands: number; skills: number; agents: number; + plugins: number; + settings: boolean; total: number; symlinkStatus: { valid: boolean; message: string }; } @@ -99,20 +103,22 @@ export function useSharedSummary() { }); } -export function useSharedItems(type: 'commands' | 'skills' | 'agents') { +export function useSharedItems(type: SharedResourceTab) { return useQuery<{ items: SharedItem[] }>({ queryKey: ['shared', type], + enabled: type !== 'settings', queryFn: async () => { + if (type === 'settings') { + return { items: [] }; + } + const res = await fetch(`/api/shared/${type}`); return readJsonOrThrow<{ items: SharedItem[] }>(res, `Failed to fetch shared ${type}`); }, }); } -export function useSharedItemContent( - type: 'commands' | 'skills' | 'agents', - itemPath: string | null -) { +export function useSharedItemContent(type: SharedResourceTab, itemPath: string | null) { return useQuery({ queryKey: ['shared', type, 'content', itemPath], enabled: typeof itemPath === 'string' && itemPath.length > 0, diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 3dca15a6..0eec23f4 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -934,10 +934,16 @@ export interface Account { continuity_mode?: 'standard' | 'deeper'; context_inferred?: boolean; continuity_inferred?: boolean; + shared_resource_mode?: 'shared' | 'profile-local'; + shared_resource_inferred?: boolean; provider?: string; displayName?: string; } +export interface UpdateAccountSharedResources { + shared_resource_mode: 'shared' | 'profile-local'; +} + export interface PlainCcsLane { kind: 'native' | 'account-default' | 'account-inherited' | 'profile-default' | 'ambient'; label: string; @@ -1459,6 +1465,11 @@ export const api = { method: 'PUT', body: JSON.stringify(data), }), + updateSharedResources: (name: string, data: UpdateAccountSharedResources) => + request(`/accounts/${encodeURIComponent(name)}/shared-resources`, { + method: 'PUT', + body: JSON.stringify(data), + }), }, // Unified config API config: { diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 4a6a2f03..99e4e0f7 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -28,7 +28,7 @@ const resources = { claudeExtension: 'Claude Extension', accounts: 'Accounts', allAccounts: 'All Accounts', - sharedData: 'Shared Data', + sharedData: 'Shared Resources', compatibleClis: 'Compatible', deprecated: 'Deprecated', factoryDroid: 'Factory Droid', @@ -96,6 +96,7 @@ const resources = { settingsSaved: 'Settings saved', defaultTargetUpdated: 'Default target updated', failedUpdateDefaultTarget: 'Failed to update default target to {{target}}{{suffix}}', + resourcesUpdated: 'Shared resources updated', }, profileDialog: { editTitle: 'Edit Profile', @@ -675,8 +676,10 @@ const resources = { created: 'Created', lastUsed: 'Last Used', historySync: 'History Sync', + sharedResources: 'Resources', actions: 'Actions', syncTitle: 'Edit sync mode, group, and continuity depth', + resourcesTitle: 'Edit shared resource inheritance', confirmLegacyTitle: "Confirm this legacy account's current mode as explicit", sync: 'Sync', confirm: 'Confirm', @@ -696,6 +699,8 @@ const resources = { sharedGroupLegacy: 'shared ({{group}}, projects only, legacy)', isolatedLegacy: 'isolated (legacy default)', isolated: 'isolated', + resourcesShared: 'shared', + resourcesProfileLocal: 'profile-local', noSameGroupPeer: 'No same-group peer yet', sameGroupPeerCount_one: '{{count}} same-group peer', sameGroupPeerCount_other: '{{count}} same-group peers', @@ -706,6 +711,7 @@ const resources = { deeper: 'Deeper', legacy: 'Legacy', isolated: 'Isolated', + profileLocal: 'Local', }, }, addAccountDialog: { @@ -1196,6 +1202,9 @@ const resources = { quickCommandsDesc: 'Copy and run in terminal.', workspaceBadge: 'ccs auth Workspace', historySyncBadge: 'History & Resume Controls', + resourcesBadge: 'Shared Resources Controls', + resourcesShared: 'Shared', + resourcesProfileLocal: 'Profile Local', authAccounts: 'Auth Accounts', tableScopePrefix: 'This table is intentionally scoped to', tableScopeMiddle: 'accounts. Use', @@ -1494,9 +1503,11 @@ const resources = { commands: 'Commands', skills: 'Skills', agents: 'Agents', - title: 'Shared Data', - subtitle: 'Commands, skills, and agents shared across Claude instances', - totalShared: 'Total Shared', + plugins: 'Plugins', + settings: 'Settings', + title: 'Shared Resources', + subtitle: 'Commands, skills, agents, and configuration shared across accounts', + totalShared: 'Total Resources', visible: 'Visible', markdownDetail: 'Markdown detail view', filterPrefix: 'Filter:', @@ -1517,9 +1528,41 @@ const resources = { pathLabel: 'Path', resolvedSource: 'Resolved Source', loadingMarkdown: 'Loading markdown content...', + loadingSettings: 'Loading settings content...', failedLoadContent: 'Failed to load content', retryContent: 'Retry content', noMarkdown: 'No markdown content available.', + settingsTitle: 'settings.json', + settingsDescription: + 'This file contains shared settings that are linked across all shared-mode accounts.', + settingsPath: 'Shared Settings Path', + settingsActive: 'Active', + settingsInactive: 'Not Found', + resourcePolicies: 'Resource policies', + resourcePoliciesDescription: + 'Accounts marked shared inherit this hub. Profile-local accounts keep separate commands, skills, agents, plugins, and settings.', + sharedAccounts: '{{count}} shared', + profileLocalAccounts: '{{count}} profile-local', + moreProfileLocalAccounts: '+{{count}} more', + }, + editAccountSharedResources: { + title: 'Edit Shared Resources', + description: + 'Configure how "{{name}}" inherits commands, skills, and settings from the CCS shared directory.', + resourceMode: 'Resource Mode', + selectResourceMode: 'Select resource mode', + sharedOption: 'Shared', + sharedHint: 'Inherits commands, skills, and agents from the shared directory.', + profileLocalOption: 'Profile Local', + profileLocalHint: "Uses isolated resources in the account's private directory.", + implicationTitle: 'What this means', + sharedImplication: + 'This account will see all tools and configuration in the shared resource hub.', + profileLocalImplication: + 'This account will not see shared tools. It stays fully isolated for security or testing.', + cancel: 'Cancel', + save: 'Save', + saving: 'Saving...', }, // ======================================== @@ -4033,8 +4076,10 @@ const resources = { commands: '命令', skills: '技能', agents: '代理', - title: '共享数据', - subtitle: '在 Claude 实例间共享的命令、技能和代理', + plugins: '插件', + settings: '设置', + title: '共享资源', + subtitle: '在账号之间共享的命令、技能、代理、插件和配置', totalShared: '共享总数', visible: '可见', markdownDetail: 'Markdown 详情视图', @@ -4055,9 +4100,21 @@ const resources = { pathLabel: '路径', resolvedSource: '解析后来源', loadingMarkdown: '正在加载 Markdown 内容...', + loadingSettings: '正在加载设置内容...', failedLoadContent: '加载内容失败', retryContent: '重试内容加载', noMarkdown: '暂无 Markdown 内容。', + settingsTitle: 'settings.json', + settingsDescription: '此文件包含所有 shared 模式账号链接使用的共享设置。', + settingsPath: '共享设置路径', + settingsActive: '已启用', + settingsInactive: '未找到', + resourcePolicies: '资源策略', + resourcePoliciesDescription: + '标记为 shared 的账号继承此中心。profile-local 账号保留单独的命令、技能、代理、插件和设置。', + sharedAccounts: '{{count}} 个 shared', + profileLocalAccounts: '{{count}} 个 profile-local', + moreProfileLocalAccounts: '+{{count}} 个更多', }, heroSection: { title: 'CCS Config', @@ -6623,8 +6680,10 @@ const resources = { commands: 'Lệnh', skills: 'Kỹ năng', agents: 'Agent', - title: 'Dữ liệu dùng chung', - subtitle: 'Các lệnh, kỹ năng và agent được chia sẻ giữa các phiên Claude', + plugins: 'Plugin', + settings: 'Cài đặt', + title: 'Tài nguyên dùng chung', + subtitle: 'Lệnh, kỹ năng, agent, plugin và cấu hình được chia sẻ giữa các tài khoản', totalShared: 'Tổng số mục dùng chung', visible: 'Hiển thị', markdownDetail: 'Chế độ xem chi tiết Markdown', @@ -6646,9 +6705,22 @@ const resources = { pathLabel: 'Đường dẫn', resolvedSource: 'Nguồn đã giải quyết', loadingMarkdown: 'Đang tải nội dung Markdown...', + loadingSettings: 'Đang tải nội dung cài đặt...', failedLoadContent: 'Không tải được nội dung', retryContent: 'Thử lại nội dung', noMarkdown: 'Không có nội dung Markdown khả dụng.', + settingsTitle: 'settings.json', + settingsDescription: + 'Tệp này chứa cài đặt dùng chung được liên kết qua mọi tài khoản ở chế độ shared.', + settingsPath: 'Đường dẫn cài đặt dùng chung', + settingsActive: 'Đang hoạt động', + settingsInactive: 'Không tìm thấy', + resourcePolicies: 'Chính sách tài nguyên', + resourcePoliciesDescription: + 'Tài khoản shared kế thừa hub này. Tài khoản profile-local giữ lệnh, kỹ năng, agent, plugin và cài đặt riêng.', + sharedAccounts: '{{count}} shared', + profileLocalAccounts: '{{count}} profile-local', + moreProfileLocalAccounts: '+{{count}} khác', }, heroSection: { title: 'CCS Config', @@ -9238,8 +9310,10 @@ const resources = { commands: 'コマンド', skills: 'スキル', agents: 'エージェント', - title: '共有データ', - subtitle: 'Claudeインスタンス間で共有されるコマンド、スキル、エージェント', + plugins: 'プラグイン', + settings: '設定', + title: '共有リソース', + subtitle: 'アカウント間で共有されるコマンド、スキル、エージェント、プラグイン、設定', totalShared: '共有数', visible: '表示中', markdownDetail: 'Markdown詳細ビュー', @@ -9261,9 +9335,22 @@ const resources = { pathLabel: 'パス', resolvedSource: '解決後ソース', loadingMarkdown: 'Markdownコンテンツを読み込み中...', + loadingSettings: '設定内容を読み込み中...', failedLoadContent: 'コンテンツの読み込みに失敗しました', retryContent: '再読み込み', noMarkdown: 'Markdownコンテンツはありません。', + settingsTitle: 'settings.json', + settingsDescription: + 'このファイルには shared モードの全アカウントにリンクされる共有設定が含まれます。', + settingsPath: '共有設定パス', + settingsActive: '有効', + settingsInactive: '未検出', + resourcePolicies: 'リソースポリシー', + resourcePoliciesDescription: + 'shared のアカウントはこのハブを継承します。profile-local のアカウントはコマンド、スキル、エージェント、プラグイン、設定を分離します。', + sharedAccounts: '{{count}} shared', + profileLocalAccounts: '{{count}} profile-local', + moreProfileLocalAccounts: '+{{count}} 件', }, accountCardStats: { diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index 2e729551..9ded7061 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -176,6 +176,7 @@ export function AccountsPage() {
{t('accountsPage.workspaceBadge')} {t('accountsPage.historySyncBadge')} + {t('accountsPage.resourcesBadge')}

{t('accountsPage.authAccounts')} diff --git a/ui/src/pages/shared.tsx b/ui/src/pages/shared.tsx index 5b632569..4b3b6328 100644 --- a/ui/src/pages/shared.tsx +++ b/ui/src/pages/shared.tsx @@ -6,6 +6,8 @@ import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { CodeEditor } from '@/components/shared/code-editor'; +import { useAccounts } from '@/hooks/use-accounts'; import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared'; import { cn } from '@/lib/utils'; import '@/lib/i18n'; @@ -14,6 +16,8 @@ import { AlertCircle, AlertTriangle, Bot, + Box, + FileJson, FileText, FolderOpen, RefreshCw, @@ -21,7 +25,7 @@ import { Sparkles, } from 'lucide-react'; -type TabType = 'commands' | 'skills' | 'agents'; +type TabType = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings'; export function SharedPage() { const { t } = useTranslation(); @@ -30,6 +34,8 @@ export function SharedPage() { commands: t('sharedPage.commands'), skills: t('sharedPage.skills'), agents: t('sharedPage.agents'), + plugins: t('sharedPage.plugins'), + settings: t('sharedPage.settings'), }; const [query, setQuery] = useState(''); @@ -41,6 +47,7 @@ export function SharedPage() { error: summaryError, refetch: refetchSummary, } = useSharedSummary(); + const { data: accountsView } = useAccounts(); const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(tab); const allItems = items?.items ?? []; const normalizedQuery = query.trim().toLowerCase(); @@ -71,23 +78,43 @@ export function SharedPage() { return filteredItems.find((item) => item.path === selectedItemPath) ?? filteredItems[0]; }, [filteredItems, selectedItemPath]); + const contentPath = + tab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null); const { data: selectedItemContent, isLoading: isContentLoading, isError: isContentError, error: contentError, refetch: refetchContent, - } = useSharedItemContent(tab, selectedItem?.path ?? null); + } = useSharedItemContent(tab, contentPath); - const tabs: { id: TabType; label: string; icon: typeof FileText; count: number }[] = [ + const tabs: { id: TabType; label: string; icon: typeof FileText; count: number | string }[] = [ { id: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 }, { id: 'skills', label: tabLabels.skills, icon: Sparkles, count: summary?.skills ?? 0 }, { id: 'agents', label: tabLabels.agents, icon: Bot, count: summary?.agents ?? 0 }, + { id: 'plugins', label: tabLabels.plugins, icon: Box, count: summary?.plugins ?? 0 }, + { + id: 'settings', + label: tabLabels.settings, + icon: FileJson, + count: summary?.settings ? 1 : 0, + }, ]; - const totalSharedItems = tabs.reduce((sum, tabOption) => sum + tabOption.count, 0); + const totalSharedItems = summary?.total ?? 0; + const settingsCount = summary?.settings ? 1 : 0; + const currentItemCount = tab === 'settings' ? settingsCount : allItems.length; + const currentVisibleCount = tab === 'settings' ? settingsCount : filteredItems.length; + const showListStatus = tab === 'settings' || (!isLoading && !isError); const hasNoItems = !isLoading && !isError && allItems.length === 0; const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0; + const sharedResourceAccounts = + accountsView?.accounts.filter( + (account) => (account.shared_resource_mode || 'shared') === 'shared' + ) ?? []; + const profileLocalResourceAccounts = + accountsView?.accounts.filter((account) => account.shared_resource_mode === 'profile-local') ?? + []; // TODO i18n: missing key for "Shared item totals could not be loaded. Listing still works." const summaryErrorMessage = getSharedErrorMessage( @@ -104,7 +131,7 @@ export function SharedPage() { ); return ( -
+
@@ -136,8 +163,8 @@ export function SharedPage() {
- - + +
@@ -183,21 +210,29 @@ export function SharedPage() { )} + + {accountsView ? ( + account.name)} + /> + ) : null}
-
-
-
-
+
+
+
+

{tabLabels[tab]}

- {!isLoading && !isError && ( + {showListStatus && ( - {filteredItems.length}/{allItems.length} + {currentVisibleCount}/{currentItemCount} )}
@@ -213,11 +248,11 @@ export function SharedPage() { />
- {!isLoading && !isError && ( + {showListStatus && (

{t('sharedPage.showing', { - visible: filteredItems.length, - total: allItems.length, + visible: currentVisibleCount, + total: currentItemCount, tab, })} {activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''} @@ -226,8 +261,35 @@ export function SharedPage() { )}

- - {isLoading ? ( + + {tab === 'settings' ? ( +
+ +
+ ) : isLoading ? (
{t('sharedPage.loadingShared', { tab })}
@@ -289,12 +351,94 @@ export function SharedPage() {
-
- {!selectedItem ? ( +
+ {!selectedItem && tab !== 'settings' ? (
{t('sharedPage.selectOne', { tab: tab.slice(0, -1) })}
- ) : ( + ) : tab === 'settings' ? ( + <> +
+
+

settings.json

+ + {t('sharedPage.settings')} + +
+
+ +
+
+
+ + {selectedItemContent?.contentPath ? ( + + ) : null} +
+
+ + + + + {!summary?.settings ? ( +
+ +

+ {t('sharedPage.settingsTitle')} +

+

+ {t('sharedPage.settingsDescription')} +

+
+ {t('sharedPage.settingsInactive')} +
+
+ ) : isContentLoading ? ( +

+ {t('sharedPage.loadingSettings')} +

+ ) : isContentError ? ( + + + {t('sharedPage.failedLoadContent')} + +

{contentErrorMessage}

+
+ +
+
+
+ ) : ( + {}} + language="json" + readonly + minHeight="320px" + /> + )} +
+
+
+
+ + ) : selectedItem ? ( <>
@@ -359,7 +503,7 @@ export function SharedPage() {
- )} + ) : null}
@@ -368,6 +512,58 @@ export function SharedPage() { ); } +function ResourcePoliciesPanel({ + sharedCount, + profileLocalCount, + profileLocalNames, +}: { + sharedCount: number; + profileLocalCount: number; + profileLocalNames: string[]; +}) { + const { t } = useTranslation(); + const displayedProfileLocalNames = profileLocalNames.slice(0, 4); + const hiddenProfileLocalCount = Math.max( + profileLocalNames.length - displayedProfileLocalNames.length, + 0 + ); + + return ( +
+
+
+

{t('sharedPage.resourcePolicies')}

+

+ {t('sharedPage.resourcePoliciesDescription')} +

+
+ +
+ + {t('sharedPage.sharedAccounts', { count: sharedCount })} + + + {t('sharedPage.profileLocalAccounts', { count: profileLocalCount })} + + {displayedProfileLocalNames.map((name) => ( + + {name} + + ))} + {hiddenProfileLocalCount > 0 ? ( + + {t('sharedPage.moreProfileLocalAccounts', { count: hiddenProfileLocalCount })} + + ) : null} +
+
+
+ ); +} + function HeaderMetricCard({ label, value }: { label: string; value: number }) { return (
diff --git a/ui/tests/unit/ui/pages/shared/shared-page.test.tsx b/ui/tests/unit/ui/pages/shared/shared-page.test.tsx index ae70d999..37bc8e6a 100644 --- a/ui/tests/unit/ui/pages/shared/shared-page.test.tsx +++ b/ui/tests/unit/ui/pages/shared/shared-page.test.tsx @@ -21,6 +21,29 @@ function requestUrl(input: RequestInfo | URL): string { return input.url; } +function accountsResponse() { + return jsonResponse({ + default: 'work', + plain_ccs_lane: null, + accounts: [ + { + name: 'work', + type: 'oauth', + created: '2026-05-01T00:00:00.000Z', + context_mode: 'isolated', + shared_resource_mode: 'shared', + }, + { + name: 'sandbox', + type: 'oauth', + created: '2026-05-02T00:00:00.000Z', + context_mode: 'isolated', + shared_resource_mode: 'profile-local', + }, + ], + }); +} + describe('SharedPage', () => { const fetchMock = vi.fn(); @@ -41,10 +64,15 @@ describe('SharedPage', () => { commands: 0, skills: 0, agents: 0, + plugins: 0, + settings: false, total: 0, symlinkStatus: { valid: true, message: 'Symlinks active' }, }); } + if (url.endsWith('/api/accounts')) { + return accountsResponse(); + } if (url.endsWith('/api/shared/commands')) { return jsonResponse({ error: 'Backend unavailable' }, 500); } @@ -67,10 +95,15 @@ describe('SharedPage', () => { commands: 1, skills: 0, agents: 0, + plugins: 0, + settings: false, total: 1, symlinkStatus: { valid: true, message: 'Symlinks active' }, }); } + if (url.endsWith('/api/accounts')) { + return accountsResponse(); + } if (url.endsWith('/api/shared/commands')) { return jsonResponse({ items: [ @@ -109,6 +142,115 @@ describe('SharedPage', () => { expect(await screen.findByText('No commands match "no-match".')).toBeInTheDocument(); }); + it('loads plugin directory content and renders real settings content', async () => { + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.endsWith('/api/shared/summary')) { + return jsonResponse({ + commands: 0, + skills: 0, + agents: 0, + plugins: 1, + settings: true, + total: 2, + symlinkStatus: { valid: true, message: 'Symlinks active' }, + }); + } + if (url.endsWith('/api/accounts')) { + return accountsResponse(); + } + if (url.endsWith('/api/shared/commands')) { + return jsonResponse({ items: [] }); + } + if (url.endsWith('/api/shared/plugins')) { + return jsonResponse({ + items: [ + { + name: 'cache', + description: 'Directory with 2 items: payloads/, plugin-index.json', + path: '/tmp/plugins/cache', + type: 'plugin', + }, + ], + }); + } + if (url.includes('/api/shared/content?') && url.includes('type=plugins')) { + return jsonResponse({ + content: + '# Plugin directory: cache\n\n## Directory contents\n\n- payloads/\n- plugin-index.json', + contentPath: '/tmp/plugins/cache', + }); + } + if (url.includes('/api/shared/content?') && url.includes('type=settings')) { + return jsonResponse({ + content: + '{\n "env": {\n "ANTHROPIC_MODEL": "claude-sonnet-4-5"\n },\n "permissions": {\n "allow": [\n "Bash(git status)"\n ]\n }\n}', + contentPath: '/tmp/.ccs/shared/settings.json', + }); + } + + return jsonResponse({ error: `Unexpected request: ${url}` }, 404); + }); + + render(); + + await userEvent.click(await screen.findByRole('tab', { name: /Plugins/ })); + + expect( + await screen.findByText('Directory with 2 items: payloads/, plugin-index.json') + ).toBeInTheDocument(); + expect(await screen.findByText('plugin-index.json')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('tab', { name: /Settings/ })); + + expect(await screen.findByText('Showing 1 of 1 settings')).toBeInTheDocument(); + expect( + await screen.findByText((content) => + content.includes('"ANTHROPIC_MODEL": "claude-sonnet-4-5"') + ) + ).toBeInTheDocument(); + expect(screen.getByText('/tmp/.ccs/shared/settings.json')).toBeInTheDocument(); + + const requestedUrls = fetchMock.mock.calls.map(([input]) => requestUrl(input)); + expect( + requestedUrls.some( + (url) => url.includes('/api/shared/content?') && url.includes('type=settings') + ) + ).toBe(true); + }); + + it('shows account resource policy context on the shared hub', async () => { + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.endsWith('/api/shared/summary')) { + return jsonResponse({ + commands: 0, + skills: 0, + agents: 0, + plugins: 0, + settings: false, + total: 0, + symlinkStatus: { valid: true, message: 'Symlinks active' }, + }); + } + if (url.endsWith('/api/accounts')) { + return accountsResponse(); + } + if (url.endsWith('/api/shared/commands')) { + return jsonResponse({ items: [] }); + } + + return jsonResponse({ items: [] }); + }); + + render(); + + expect(await screen.findByText('Resource policies')).toBeInTheDocument(); + expect(screen.getByText('1 shared')).toBeInTheDocument(); + expect(screen.getByText('1 profile-local')).toBeInTheDocument(); + expect(screen.getByText('sandbox')).toBeInTheDocument(); + }); + it('shows offline guidance when network request fails', async () => { fetchMock.mockImplementation(async (input) => { const url = requestUrl(input); From 8b340602947312335c368109455488e5ed51cca2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 9 May 2026 03:18:20 -0400 Subject: [PATCH 24/38] fix(dashboard): show real shared plugin registry state --- docs/dashboard-auth-cli.md | 2 +- docs/session-sharing-technical-analysis.md | 2 + src/web-server/shared-routes.ts | 379 ++++++++++++++---- tests/unit/web-server/shared-routes.test.ts | 142 ++++++- ui/src/lib/i18n.ts | 17 +- ui/src/pages/shared.tsx | 95 +++-- .../unit/ui/pages/shared/shared-page.test.tsx | 79 +++- 7 files changed, 587 insertions(+), 129 deletions(-) diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index dd297535..af5f8b52 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -99,7 +99,7 @@ Shared resource editing: - reconciles the account instance after metadata is updated - Dashboard -> Accounts exposes this as a separate Resources action so it is not confused with History Sync. - Dashboard -> Shared Resources shows the shared hub inventory for commands, skills, agents, plugins, and `settings.json`. -- Plugin directories without README/PLUGIN docs render their actual directory inventory. +- The Plugins tab is registry-oriented: installed plugin entries come from `installed_plugins.json`, while internal cache/data/marketplace folders stay hidden unless a real plugin entry exists. - Shared `settings.json` is read-only in the Shared Resources page and still edited through the settings surfaces that own those values. ## Commands diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index 69b9ae11..f3aa0966 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -69,6 +69,7 @@ accounts: work: created: "2026-02-24T00:00:00.000Z" last_used: null + shared_resource_mode: "shared" context_mode: "shared" context_group: "team-alpha" continuity_mode: "deeper" @@ -76,6 +77,7 @@ accounts: Rules: +- `shared_resource_mode` controls commands, skills, agents, plugins, and `settings.json` (`shared` or `profile-local`) - `context_mode` must be `isolated` or `shared` - `context_group` is required when `context_mode=shared` - `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`) diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 3ed44425..5cd449de 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -32,6 +32,8 @@ const MAX_DESCRIPTION_LENGTH = 140; const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB const SHARED_ITEMS_CACHE_TTL_MS = 1000; +const PLUGIN_REGISTRY_PATH_PREFIX = 'plugin-registry:'; +const PLUGIN_INFRASTRUCTURE_DIRECTORIES = new Set(['cache', 'data', 'marketplaces']); type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins'; type SharedContentType = SharedCollectionType | 'settings'; @@ -49,6 +51,11 @@ interface SharedItemsCacheEntry { expiresAt: number; } +interface InstalledPluginRegistry { + version?: number; + plugins: Record; +} + const sharedItemsCache = new Map(); /** @@ -110,9 +117,9 @@ sharedRoutes.get('/content', (req: Request, res: Response) => { const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); const allowedRoots = typeParam === 'settings' - ? new Set([sharedDirRoot]) + ? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot) : resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot); - const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots); + const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots, sharedDirRoot); if (!contentResult) { res.status(404).json({ error: 'Shared content not found' }); @@ -135,10 +142,13 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => { const settingsPath = path.join(ccsDir, 'shared', 'settings.json'); const sharedDirRoot = safeRealPath(path.join(ccsDir, 'shared')); const resolvedSettingsPath = safeRealPath(settingsPath); + const settingsAllowedRoots = sharedDirRoot + ? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot) + : new Set(); const hasSettings = Boolean(sharedDirRoot) && Boolean(resolvedSettingsPath) && - isPathWithin(resolvedSettingsPath as string, sharedDirRoot as string); + isPathWithinAny(resolvedSettingsPath as string, settingsAllowedRoots); res.json({ commands, @@ -180,6 +190,17 @@ function resolveAllowedRoots( ]); } +function resolveSettingsAllowedRoots(ccsDir: string, sharedDirRoot: string): Set { + const claudeConfigDir = getClaudeConfigDir(); + return new Set( + [ + sharedDirRoot, + safeRealPath(path.join(claudeConfigDir, 'settings.json')), + safeRealPath(path.join(ccsDir, '.claude', 'settings.json')), + ].filter((dirPath): dirPath is string => typeof dirPath === 'string') + ); +} + function getSharedItems(type: SharedCollectionType): SharedItem[] { const ccsDir = getCcsDir(); const sharedDir = path.join(ccsDir, 'shared', type); @@ -209,6 +230,16 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] { return commandItems; } + if (type === 'plugins') { + const pluginItems = getPluginItems(sharedDir, allowedRoots); + sharedItemsCache.set(type, { + items: pluginItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return pluginItems; + } + try { const entries = fs.readdirSync(sharedDir, { withFileTypes: true }); @@ -251,6 +282,76 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] { return sortedItems; } +function getPluginItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const registryItems = getPluginRegistryItems(sharedDir, allowedRoots); + const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots); + const itemsByPath = new Map(); + + for (const item of [...registryItems, ...legacyDirectoryItems]) { + itemsByPath.set(item.path, item); + } + + return [...itemsByPath.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +function getPluginRegistryItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const registry = readPluginInstalledRegistry(sharedDir, allowedRoots); + if (!registry) { + return []; + } + + return Object.entries(registry.plugins).map(([pluginName, metadata]) => ({ + name: pluginName, + description: describeInstalledPlugin(pluginName, metadata), + path: encodePluginRegistryPath(pluginName), + type: 'plugin' as const, + })); +} + +function getLegacyPluginDirectoryItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(sharedDir, { withFileTypes: true }); + } catch { + return []; + } + + const items: SharedItem[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || PLUGIN_INFRASTRUCTURE_DIRECTORIES.has(entry.name)) { + continue; + } + + const entryPath = path.join(sharedDir, entry.name); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { + continue; + } + + const description = readFirstMarkdownDescription( + [ + path.join(resolvedEntryPath, 'README.md'), + path.join(resolvedEntryPath, 'readme.md'), + path.join(resolvedEntryPath, 'PLUGIN.md'), + ], + allowedRoots + ); + + if (!description) { + continue; + } + + items.push({ + name: entry.name, + description, + path: entryPath, + type: 'plugin', + }); + } + + return items; +} + function getCommandItems(sharedDir: string, allowedRoots: Set): SharedItem[] { const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots); const items: SharedItem[] = []; @@ -279,35 +380,13 @@ function getCommandItems(sharedDir: string, allowedRoots: Set): SharedIt } function getSkillOrAgentItem( - type: 'skills' | 'agents' | 'plugins', + type: 'skills' | 'agents', entry: fs.Dirent, entryPath: string, resolvedEntryPath: string, allowedRoots: Set, stats: fs.Stats ): SharedItem | null { - if (type === 'plugins') { - if (!stats.isDirectory()) { - return null; - } - - const description = readFirstMarkdownDescription( - [ - path.join(resolvedEntryPath, 'README.md'), - path.join(resolvedEntryPath, 'readme.md'), - path.join(resolvedEntryPath, 'PLUGIN.md'), - ], - allowedRoots - ); - - return { - name: entry.name, - description: description ?? getDirectoryInventoryDescription(resolvedEntryPath, allowedRoots), - path: entryPath, - type: 'plugin', - }; - } - if (type === 'skills') { if (!stats.isDirectory()) { return null; @@ -555,6 +634,89 @@ function readMarkdownContent(markdownPath: string, allowedRoots: Set): s } } +function encodePluginRegistryPath(pluginName: string): string { + return `${PLUGIN_REGISTRY_PATH_PREFIX}${encodeURIComponent(pluginName)}`; +} + +function decodePluginRegistryPath(itemPath: string): string | null { + if (!itemPath.startsWith(PLUGIN_REGISTRY_PATH_PREFIX)) { + return null; + } + + try { + const encodedName = itemPath.slice(PLUGIN_REGISTRY_PATH_PREFIX.length); + const pluginName = decodeURIComponent(encodedName); + return pluginName.length > 0 ? pluginName : null; + } catch { + return null; + } +} + +function readJsonObject( + filePath: string, + allowedRoots: Set +): Record | null { + const resolvedPath = safeRealPath(filePath); + if (!resolvedPath || !isPathWithinAny(resolvedPath, allowedRoots)) { + return null; + } + + try { + const stats = fs.statSync(resolvedPath); + if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) { + return null; + } + + const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + return parsed as Record; + } catch { + return null; + } +} + +function readPluginInstalledRegistry( + sharedDir: string, + allowedRoots: Set +): InstalledPluginRegistry | null { + const parsed = readJsonObject(path.join(sharedDir, 'installed_plugins.json'), allowedRoots); + if ( + !parsed || + !parsed.plugins || + typeof parsed.plugins !== 'object' || + Array.isArray(parsed.plugins) + ) { + return null; + } + + return { + version: typeof parsed.version === 'number' ? parsed.version : undefined, + plugins: parsed.plugins as Record, + }; +} + +function describeInstalledPlugin(pluginName: string, metadata: unknown): string { + const recordCount = Array.isArray(metadata) ? metadata.length : 1; + const marketplace = extractMarketplaceFromPluginName(pluginName); + const recordLabel = `${recordCount} ${recordCount === 1 ? 'record' : 'records'}`; + + return marketplace + ? `Installed from ${marketplace}; ${recordLabel} in shared registry` + : `Installed plugin; ${recordLabel} in shared registry`; +} + +function extractMarketplaceFromPluginName(pluginName: string): string | null { + const atIndex = pluginName.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === pluginName.length - 1) { + return null; + } + + return pluginName.slice(atIndex + 1); +} + function resolveReadableMarkdownPath( markdownPaths: string[], allowedRoots: Set @@ -582,12 +744,20 @@ function resolveReadableMarkdownPath( function getSharedItemContent( type: SharedContentType, itemPath: string, - allowedRoots: Set + allowedRoots: Set, + sharedDir: string ): { content: string; contentPath: string } | null { if (type === 'settings') { return getSharedSettingsContent(itemPath, allowedRoots); } + if (type === 'plugins') { + const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots); + if (pluginRegistryContent) { + return pluginRegistryContent; + } + } + const resolvedItemPath = safeRealPath(itemPath); if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) { return null; @@ -615,7 +785,7 @@ function getSharedItemContent( allowedRoots ); } else if (type === 'plugins') { - if (!itemStats.isDirectory()) { + if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) { return null; } @@ -629,10 +799,7 @@ function getSharedItemContent( ); if (!markdownPath) { - return { - content: renderPluginDirectoryContent(itemPath, resolvedItemPath, allowedRoots), - contentPath: resolvedItemPath, - }; + return null; } } else { if (itemStats.isDirectory()) { @@ -664,6 +831,106 @@ function getSharedItemContent( }; } +function isPluginInfrastructurePath(candidatePath: string, sharedDir: string): boolean { + for (const directoryName of PLUGIN_INFRASTRUCTURE_DIRECTORIES) { + const resolvedInfrastructurePath = safeRealPath(path.join(sharedDir, directoryName)); + if (resolvedInfrastructurePath && isPathWithin(candidatePath, resolvedInfrastructurePath)) { + return true; + } + } + + return false; +} + +function getPluginRegistryContent( + itemPath: string, + sharedDir: string, + allowedRoots: Set +): { content: string; contentPath: string } | null { + const pluginName = decodePluginRegistryPath(itemPath); + if (!pluginName) { + return null; + } + + const registryPath = path.join(sharedDir, 'installed_plugins.json'); + const resolvedRegistryPath = safeRealPath(registryPath); + if (!resolvedRegistryPath || !isPathWithinAny(resolvedRegistryPath, allowedRoots)) { + return null; + } + + const registry = readPluginInstalledRegistry(sharedDir, allowedRoots); + if (!registry || !Object.prototype.hasOwnProperty.call(registry.plugins, pluginName)) { + return null; + } + + const metadata = registry.plugins[pluginName]; + const marketplace = extractMarketplaceFromPluginName(pluginName); + const marketplaceEntry = marketplace + ? readJsonObject(path.join(sharedDir, 'known_marketplaces.json'), allowedRoots)?.[marketplace] + : null; + const blocklistEntry = findPluginBlocklistEntry(sharedDir, allowedRoots, pluginName); + const lines = [ + `# ${pluginName}`, + '', + `Registry: \`${resolvedRegistryPath}\``, + '', + '## Installed Plugin', + '', + `- Source: shared \`installed_plugins.json\` registry`, + `- Registry version: ${registry.version ?? 'unknown'}`, + `- Marketplace: ${marketplace ?? 'not recorded in plugin name'}`, + `- Install records: ${Array.isArray(metadata) ? metadata.length : 1}`, + ]; + + if (marketplaceEntry) { + lines.push( + '', + '## Marketplace Registry Entry', + '', + '```json', + stringifyJson(marketplaceEntry), + '```' + ); + } + + if (blocklistEntry) { + lines.push('', '## Blocklist Notice', '', '```json', stringifyJson(blocklistEntry), '```'); + } + + lines.push('', '## Plugin Registry Entry', '', '```json', stringifyJson(metadata), '```'); + + return { + content: lines.join('\n'), + contentPath: resolvedRegistryPath, + }; +} + +function findPluginBlocklistEntry( + sharedDir: string, + allowedRoots: Set, + pluginName: string +): unknown | null { + const blocklist = readJsonObject(path.join(sharedDir, 'blocklist.json'), allowedRoots); + const plugins = blocklist?.plugins; + if (!Array.isArray(plugins)) { + return null; + } + + return ( + plugins.find( + (entry) => + entry && + typeof entry === 'object' && + 'plugin' in entry && + (entry as { plugin?: unknown }).plugin === pluginName + ) ?? null + ); +} + +function stringifyJson(value: unknown): string { + return JSON.stringify(value, null, 2) ?? 'null'; +} + function safeRealPath(targetPath: string): string | null { try { return fs.realpathSync(targetPath); @@ -720,54 +987,6 @@ function getSharedSettingsContent( } } -function getDirectoryInventoryDescription( - directoryPath: string, - allowedRoots: Set -): string { - const entries = listDirectoryEntryNames(directoryPath, allowedRoots); - if (entries.length === 0) { - return 'Empty directory'; - } - - return trimDescription( - `Directory with ${entries.length} ${entries.length === 1 ? 'item' : 'items'}: ${entries.join(', ')}` - ); -} - -function renderPluginDirectoryContent( - displayPath: string, - resolvedPath: string, - allowedRoots: Set -): string { - const name = path.basename(displayPath); - const lines = [`# Plugin directory: ${name}`, '', `Path: \`${displayPath}\``]; - - const entries = listDirectoryEntryNames(resolvedPath, allowedRoots); - if (entries.length > 0) { - lines.push('', '## Directory contents', '', ...entries.map((entry) => `- ${entry}`)); - } else { - lines.push('', 'No files or folders found in this plugin directory.'); - } - - return lines.join('\n'); -} - -function listDirectoryEntryNames(directoryPath: string, allowedRoots: Set): string[] { - if (!isPathWithinAny(directoryPath, allowedRoots)) { - return []; - } - - try { - return fs - .readdirSync(directoryPath, { withFileTypes: true }) - .slice(0, 50) - .map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`) - .sort((a, b) => a.localeCompare(b)); - } catch { - return []; - } -} - function isPathWithin(candidatePath: string, basePath: string): boolean { const normalizedCandidate = normalizeForPathComparison(candidatePath); const normalizedBase = normalizeForPathComparison(basePath); diff --git a/tests/unit/web-server/shared-routes.test.ts b/tests/unit/web-server/shared-routes.test.ts index 7f23c7e3..182b2063 100644 --- a/tests/unit/web-server/shared-routes.test.ts +++ b/tests/unit/web-server/shared-routes.test.ts @@ -285,11 +285,65 @@ describe('web-server shared-routes', () => { expect(payload.contentPath).toBe(fs.realpathSync(path.join(skillTargetDir, 'SKILL.md'))); }); - it('returns factual directory content for a shared plugin directory without markdown', async () => { + it('hides plugin infrastructure directories when no plugins are installed', async () => { const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); const cacheDir = path.join(pluginsDir, 'cache'); fs.mkdirSync(path.join(cacheDir, 'payloads'), { recursive: true }); fs.writeFileSync(path.join(cacheDir, 'plugin-index.json'), '{}'); + fs.writeFileSync( + path.join(pluginsDir, 'installed_plugins.json'), + JSON.stringify({ version: 2, plugins: {} }, null, 2) + ); + + const listPayload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/plugins'); + + expect(listPayload.items).toEqual([]); + + const contentResponse = await fetch( + `${baseUrl}/api/shared/content?type=plugins&path=${encodeURIComponent(cacheDir)}` + ); + + expect(contentResponse.status).toBe(404); + const contentPayload = (await contentResponse.json()) as { error: string }; + expect(contentPayload.error).toBe('Shared content not found'); + }); + + it('lists installed plugins from the shared plugin registry', async () => { + const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginsDir, 'installed_plugins.json'), + JSON.stringify( + { + version: 2, + plugins: { + 'discord@claude-plugins-official': [ + { + installPath: path.join(pluginsDir, 'cache', 'discord'), + enabled: true, + }, + ], + }, + }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(pluginsDir, 'known_marketplaces.json'), + JSON.stringify( + { + 'claude-plugins-official': { + source: { type: 'github', repo: 'anthropics/claude-plugins' }, + installLocation: path.join(pluginsDir, 'marketplaces', 'claude-plugins-official'), + }, + }, + null, + 2 + ) + ); const listPayload = await getJson<{ items: Array<{ name: string; type: string; description: string; path: string }>; @@ -297,23 +351,41 @@ describe('web-server shared-routes', () => { expect(listPayload.items).toEqual([ { - name: 'cache', + name: 'discord@claude-plugins-official', type: 'plugin', - description: 'Directory with 2 items: payloads/, plugin-index.json', - path: cacheDir, + description: 'Installed from claude-plugins-official; 1 record in shared registry', + path: 'plugin-registry:discord%40claude-plugins-official', }, ]); const contentPayload = await getJson<{ content: string; contentPath: string }>( baseUrl, - `/api/shared/content?type=plugins&path=${encodeURIComponent(cacheDir)}` + `/api/shared/content?type=plugins&path=${encodeURIComponent(listPayload.items[0].path)}` ); - expect(contentPayload.content).toContain('# Plugin directory: cache'); - expect(contentPayload.content).toContain('plugin-index.json'); - expect(contentPayload.content).toContain('payloads/'); - expect(contentPayload.content).not.toContain('Shared plugin payload cache'); - expect(contentPayload.contentPath).toBe(fs.realpathSync(cacheDir)); + expect(contentPayload.content).toContain('# discord@claude-plugins-official'); + expect(contentPayload.content).toContain('## Marketplace Registry Entry'); + expect(contentPayload.content).toContain('## Plugin Registry Entry'); + expect(contentPayload.contentPath).toBe( + fs.realpathSync(path.join(pluginsDir, 'installed_plugins.json')) + ); + }); + + it('rejects plugin registry prototype keys as plugin names', async () => { + const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginsDir, 'installed_plugins.json'), + JSON.stringify({ version: 2, plugins: {} }, null, 2) + ); + + const response = await fetch( + `${baseUrl}/api/shared/content?type=plugins&path=${encodeURIComponent('plugin-registry:toString')}` + ); + + expect(response.status).toBe(404); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toBe('Shared content not found'); }); it('returns real shared settings content when settings.json is present', async () => { @@ -347,6 +419,36 @@ describe('web-server shared-routes', () => { expect(contentPayload.contentPath).toBe(fs.realpathSync(settingsPath)); }); + it('returns shared settings content when settings.json links to the Claude config file', async () => { + const claudeDir = path.join(tempHome, '.claude'); + process.env.CLAUDE_CONFIG_DIR = claudeDir; + fs.mkdirSync(claudeDir, { recursive: true }); + + const claudeSettingsPath = path.join(claudeDir, 'settings.json'); + fs.writeFileSync( + claudeSettingsPath, + JSON.stringify({ hooks: { PreToolUse: [] }, env: { ANTHROPIC_MODEL: 'claude-opus-4-1' } }) + ); + + const sharedSettingsPath = path.join(ccsDir, 'shared', 'settings.json'); + createFileSymlink(claudeSettingsPath, sharedSettingsPath); + + const summaryPayload = await getJson<{ settings: boolean; total: number }>( + baseUrl, + '/api/shared/summary' + ); + expect(summaryPayload.settings).toBe(true); + expect(summaryPayload.total).toBe(1); + + const contentPayload = await getJson<{ content: string; contentPath: string }>( + baseUrl, + `/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}` + ); + + expect(contentPayload.content).toContain('"ANTHROPIC_MODEL": "claude-opus-4-1"'); + expect(contentPayload.contentPath).toBe(fs.realpathSync(claudeSettingsPath)); + }); + it('rejects shared settings content when settings.json resolves outside the shared directory', async () => { const settingsPath = path.join(ccsDir, 'shared', 'settings.json'); const outsideSettingsPath = path.join(tempHome, 'outside-settings.json'); @@ -362,9 +464,27 @@ describe('web-server shared-routes', () => { expect(payload.error).toBe('Shared content not found'); }); + it('rejects shared settings symlinks to non-settings files inside the Claude config directory', async () => { + const claudeDir = path.join(tempHome, '.claude'); + process.env.CLAUDE_CONFIG_DIR = claudeDir; + fs.mkdirSync(claudeDir, { recursive: true }); + + const otherClaudeFile = path.join(claudeDir, 'credentials.json'); + fs.writeFileSync(otherClaudeFile, '{"SECRET":"do-not-read"}'); + createFileSymlink(otherClaudeFile, path.join(ccsDir, 'shared', 'settings.json')); + + const response = await fetch( + `${baseUrl}/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}` + ); + + expect(response.status).toBe(404); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toBe('Shared content not found'); + }); + it('returns README content for a shared plugin directory when available', async () => { const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); - const pluginDir = path.join(pluginsDir, 'marketplaces'); + const pluginDir = path.join(pluginsDir, 'legacy-plugin'); fs.mkdirSync(pluginDir, { recursive: true }); fs.writeFileSync(path.join(pluginDir, 'README.md'), '# Plugin Marketplace\n\nShared docs'); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 99e4e0f7..ec63f655 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1506,10 +1506,12 @@ const resources = { plugins: 'Plugins', settings: 'Settings', title: 'Shared Resources', - subtitle: 'Commands, skills, agents, and configuration shared across accounts', + subtitle: 'Commands, skills, agents, plugins, and configuration shared across accounts', totalShared: 'Total Resources', visible: 'Visible', markdownDetail: 'Markdown detail view', + registryDetail: 'Registry detail view', + maskedSettingsDetail: 'Masked settings view', filterPrefix: 'Filter:', configurationRequired: 'Configuration Required', countsUnavailable: 'Counts unavailable', @@ -1523,6 +1525,8 @@ const resources = { retry: 'Retry', noSharedFound: 'No shared {{tab}} found. Run `ccs sync` or add items in your shared directory.', + noPluginsFound: + 'No shared plugins are installed. Plugin registry files and cache folders are hidden until an actual plugin is present.', noMatch: 'No {{tab}} match "{{query}}".', selectOne: 'Select a {{tab}} to view full content.', pathLabel: 'Path', @@ -4083,6 +4087,8 @@ const resources = { totalShared: '共享总数', visible: '可见', markdownDetail: 'Markdown 详情视图', + registryDetail: '注册表详情视图', + maskedSettingsDetail: '脱敏设置视图', filterPrefix: '筛选:', configurationRequired: '需要配置', countsUnavailable: '统计不可用', @@ -4095,6 +4101,7 @@ const resources = { failedLoadShared: '加载共享{{tab}}失败', retry: '重试', noSharedFound: '未找到共享{{tab}}。请运行 `ccs sync` 或在共享目录添加内容。', + noPluginsFound: '未安装共享插件。真实插件存在前,插件注册表文件和缓存文件夹会保持隐藏。', noMatch: '没有匹配“{{query}}”的{{tab}}。', selectOne: '选择一个{{tab}}查看完整内容。', pathLabel: '路径', @@ -6687,6 +6694,8 @@ const resources = { totalShared: 'Tổng số mục dùng chung', visible: 'Hiển thị', markdownDetail: 'Chế độ xem chi tiết Markdown', + registryDetail: 'Chế độ xem chi tiết registry', + maskedSettingsDetail: 'Chế độ xem cài đặt đã ẩn nhạy cảm', filterPrefix: 'Lọc:', configurationRequired: 'Cần cấu hình', countsUnavailable: 'Không có số liệu', @@ -6700,6 +6709,8 @@ const resources = { retry: 'Thử lại', noSharedFound: 'Không tìm thấy {{tab}} dùng chung. Chạy `ccs sync` hoặc thêm mục trong thư mục dùng chung của bạn.', + noPluginsFound: + 'Chưa cài plugin dùng chung. Tệp registry plugin và thư mục cache sẽ bị ẩn cho đến khi có plugin thật.', noMatch: 'Không có {{tab}} khớp với "{{query}}".', selectOne: 'Chọn {{tab}} để xem toàn bộ nội dung.', pathLabel: 'Đường dẫn', @@ -9317,6 +9328,8 @@ const resources = { totalShared: '共有数', visible: '表示中', markdownDetail: 'Markdown詳細ビュー', + registryDetail: 'レジストリ詳細ビュー', + maskedSettingsDetail: 'マスク済み設定ビュー', filterPrefix: 'フィルター:', configurationRequired: '設定が必要です', countsUnavailable: '件数を取得できません', @@ -9330,6 +9343,8 @@ const resources = { retry: '再試行', noSharedFound: '共有{{tab}}が見つかりません。`ccs sync` を実行するか、共有ディレクトリに項目を追加してください。', + noPluginsFound: + '共有プラグインはインストールされていません。実際のプラグインが存在するまで、プラグインレジストリとキャッシュフォルダーは非表示になります。', noMatch: '{{tab}}で「{{query}}」に一致するものはありません。', selectOne: '内容を表示する{{tab}}を選択してください。', pathLabel: 'パス', diff --git a/ui/src/pages/shared.tsx b/ui/src/pages/shared.tsx index 4b3b6328..2694802e 100644 --- a/ui/src/pages/shared.tsx +++ b/ui/src/pages/shared.tsx @@ -8,7 +8,12 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { CodeEditor } from '@/components/shared/code-editor'; import { useAccounts } from '@/hooks/use-accounts'; -import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared'; +import { + type SharedSummary, + useSharedItemContent, + useSharedItems, + useSharedSummary, +} from '@/hooks/use-shared'; import { cn } from '@/lib/utils'; import '@/lib/i18n'; import { useTranslation } from 'react-i18next'; @@ -30,6 +35,7 @@ type TabType = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings'; export function SharedPage() { const { t } = useTranslation(); const [tab, setTab] = useState('commands'); + const [hasUserSelectedTab, setHasUserSelectedTab] = useState(false); const tabLabels: Record = { commands: t('sharedPage.commands'), skills: t('sharedPage.skills'), @@ -47,8 +53,10 @@ export function SharedPage() { error: summaryError, refetch: refetchSummary, } = useSharedSummary(); + const { data: accountsView } = useAccounts(); - const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(tab); + const activeTab = summary && !hasUserSelectedTab ? getPreferredInitialTab(summary) : tab; + const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(activeTab); const allItems = items?.items ?? []; const normalizedQuery = query.trim().toLowerCase(); const activeQuery = query.trim(); @@ -79,14 +87,14 @@ export function SharedPage() { }, [filteredItems, selectedItemPath]); const contentPath = - tab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null); + activeTab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null); const { data: selectedItemContent, isLoading: isContentLoading, isError: isContentError, error: contentError, refetch: refetchContent, - } = useSharedItemContent(tab, contentPath); + } = useSharedItemContent(activeTab, contentPath); const tabs: { id: TabType; label: string; icon: typeof FileText; count: number | string }[] = [ { id: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 }, @@ -102,9 +110,19 @@ export function SharedPage() { ]; const totalSharedItems = summary?.total ?? 0; const settingsCount = summary?.settings ? 1 : 0; - const currentItemCount = tab === 'settings' ? settingsCount : allItems.length; - const currentVisibleCount = tab === 'settings' ? settingsCount : filteredItems.length; - const showListStatus = tab === 'settings' || (!isLoading && !isError); + const currentItemCount = activeTab === 'settings' ? settingsCount : allItems.length; + const currentVisibleCount = activeTab === 'settings' ? settingsCount : filteredItems.length; + const showListStatus = activeTab === 'settings' || (!isLoading && !isError); + const detailModeLabel = + activeTab === 'plugins' + ? t('sharedPage.registryDetail') + : activeTab === 'settings' + ? t('sharedPage.maskedSettingsDetail') + : t('sharedPage.markdownDetail'); + const emptyStateMessage = + activeTab === 'plugins' + ? t('sharedPage.noPluginsFound') + : t('sharedPage.noSharedFound', { tab: activeTab }); const hasNoItems = !isLoading && !isError && allItems.length === 0; const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0; @@ -123,7 +141,7 @@ export function SharedPage() { ); const itemsErrorMessage = getSharedErrorMessage( error, - `Unable to fetch shared ${tab}. Please try again.` + `Unable to fetch shared ${activeTab}. Please try again.` ); const contentErrorMessage = getSharedErrorMessage( contentError, @@ -141,8 +159,9 @@ export function SharedPage() {
{ + setHasUserSelectedTab(true); setTab(nextTab as TabType); setQuery(''); setSelectedItemPath(null); @@ -163,12 +182,12 @@ export function SharedPage() {
- +
- {t('sharedPage.markdownDetail')} + {detailModeLabel} {activeQuery ? ( {t('sharedPage.filterPrefix')} {activeQuery} @@ -228,7 +247,7 @@ export function SharedPage() {
-

{tabLabels[tab]}

+

{tabLabels[activeTab]}

{showListStatus && ( @@ -242,8 +261,8 @@ export function SharedPage() { setQuery(event.target.value)} - placeholder={t('sharedPage.filterPlaceholder', { tab })} - aria-label={t('sharedPage.filterPlaceholder', { tab })} + placeholder={t('sharedPage.filterPlaceholder', { tab: activeTab })} + aria-label={t('sharedPage.filterPlaceholder', { tab: activeTab })} className="pl-8 h-9" />
@@ -253,7 +272,7 @@ export function SharedPage() { {t('sharedPage.showing', { visible: currentVisibleCount, total: currentItemCount, - tab, + tab: activeTab, })} {activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''} {isFetching ? t('sharedPage.refreshing') : ''} @@ -262,7 +281,7 @@ export function SharedPage() {
- {tab === 'settings' ? ( + {activeTab === 'settings' ? (
) : hasNoItems ? ( -
- {t('sharedPage.noSharedFound', { tab })} +
+ {emptyStateMessage}
) : hasNoMatches ? (
- {t('sharedPage.noMatch', { tab, query: activeQuery })} + {t('sharedPage.noMatch', { tab: activeTab, query: activeQuery })}
) : (
@@ -342,7 +361,7 @@ export function SharedPage() { {item.description}

- {item.path} + {formatSharedItemPath(item.path)}

))} @@ -352,11 +371,11 @@ export function SharedPage() {
- {!selectedItem && tab !== 'settings' ? ( + {!selectedItem && activeTab !== 'settings' ? (
- {t('sharedPage.selectOne', { tab: tab.slice(0, -1) })} + {t('sharedPage.selectOne', { tab: activeTab.slice(0, -1) })}
- ) : tab === 'settings' ? ( + ) : activeTab === 'settings' ? ( <>
@@ -454,7 +473,7 @@ export function SharedPage() {
{selectedItemContent?.contentPath && @@ -512,6 +531,26 @@ export function SharedPage() { ); } +function getPreferredInitialTab(summary: SharedSummary): TabType { + if (summary.commands > 0) { + return 'commands'; + } + if (summary.skills > 0) { + return 'skills'; + } + if (summary.agents > 0) { + return 'agents'; + } + if (summary.plugins > 0) { + return 'plugins'; + } + if (summary.settings) { + return 'settings'; + } + + return 'commands'; +} + function ResourcePoliciesPanel({ sharedCount, profileLocalCount, @@ -604,6 +643,10 @@ function getSharedErrorMessage(error: unknown, fallbackMessage: string): string return error.message || fallbackMessage; } +function formatSharedItemPath(itemPath: string): string { + return itemPath.startsWith('plugin-registry:') ? 'installed_plugins.json' : itemPath; +} + interface MarkdownBlockHeading { type: 'heading'; level: number; diff --git a/ui/tests/unit/ui/pages/shared/shared-page.test.tsx b/ui/tests/unit/ui/pages/shared/shared-page.test.tsx index 37bc8e6a..139b1124 100644 --- a/ui/tests/unit/ui/pages/shared/shared-page.test.tsx +++ b/ui/tests/unit/ui/pages/shared/shared-page.test.tsx @@ -142,7 +142,60 @@ describe('SharedPage', () => { expect(await screen.findByText('No commands match "no-match".')).toBeInTheDocument(); }); - it('loads plugin directory content and renders real settings content', async () => { + it('opens the first populated resource tab instead of an empty commands tab', async () => { + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.endsWith('/api/shared/summary')) { + return jsonResponse({ + commands: 0, + skills: 1, + agents: 0, + plugins: 0, + settings: true, + total: 2, + symlinkStatus: { valid: true, message: 'Symlinks active' }, + }); + } + if (url.endsWith('/api/accounts')) { + return accountsResponse(); + } + if (url.endsWith('/api/shared/commands')) { + return jsonResponse({ items: [] }); + } + if (url.endsWith('/api/shared/skills')) { + return jsonResponse({ + items: [ + { + name: 'review-helper', + description: 'Review the latest PR changes.', + path: '/tmp/skills/review-helper/SKILL.md', + type: 'skill', + }, + ], + }); + } + if (url.includes('/api/shared/content?') && url.includes('type=skills')) { + return jsonResponse({ + content: '# Skill Body\n\nReal shared skill instructions', + contentPath: '/tmp/skills/review-helper/SKILL.md', + }); + } + + return jsonResponse({ items: [] }); + }); + + render(); + + expect(await screen.findByText('Showing 1 of 1 skills')).toBeInTheDocument(); + expect(screen.getByText('Review the latest PR changes.')).toBeInTheDocument(); + expect(await screen.findByText('Skill Body')).toBeInTheDocument(); + expect(screen.queryByText('Select a command to view full content.')).not.toBeInTheDocument(); + + const requestedUrls = fetchMock.mock.calls.map(([input]) => requestUrl(input)); + expect(requestedUrls.some((url) => url.endsWith('/api/shared/skills'))).toBe(true); + }); + + it('loads plugin registry content and renders real settings content', async () => { fetchMock.mockImplementation(async (input) => { const url = requestUrl(input); if (url.endsWith('/api/shared/summary')) { @@ -166,9 +219,9 @@ describe('SharedPage', () => { return jsonResponse({ items: [ { - name: 'cache', - description: 'Directory with 2 items: payloads/, plugin-index.json', - path: '/tmp/plugins/cache', + name: 'discord@claude-plugins-official', + description: 'Installed from claude-plugins-official; 1 record in shared registry', + path: 'plugin-registry:discord%40claude-plugins-official', type: 'plugin', }, ], @@ -177,8 +230,8 @@ describe('SharedPage', () => { if (url.includes('/api/shared/content?') && url.includes('type=plugins')) { return jsonResponse({ content: - '# Plugin directory: cache\n\n## Directory contents\n\n- payloads/\n- plugin-index.json', - contentPath: '/tmp/plugins/cache', + '# discord@claude-plugins-official\n\n## Plugin Registry Entry\n\n```json\n{"enabled":true}\n```', + contentPath: '/tmp/plugins/installed_plugins.json', }); } if (url.includes('/api/shared/content?') && url.includes('type=settings')) { @@ -194,12 +247,11 @@ describe('SharedPage', () => { render(); - await userEvent.click(await screen.findByRole('tab', { name: /Plugins/ })); - + expect(await screen.findByText('Showing 1 of 1 plugins')).toBeInTheDocument(); expect( - await screen.findByText('Directory with 2 items: payloads/, plugin-index.json') + await screen.findByText('Installed from claude-plugins-official; 1 record in shared registry') ).toBeInTheDocument(); - expect(await screen.findByText('plugin-index.json')).toBeInTheDocument(); + expect(await screen.findByText('Plugin Registry Entry')).toBeInTheDocument(); await userEvent.click(screen.getByRole('tab', { name: /Settings/ })); @@ -249,6 +301,13 @@ describe('SharedPage', () => { expect(screen.getByText('1 shared')).toBeInTheDocument(); expect(screen.getByText('1 profile-local')).toBeInTheDocument(); expect(screen.getByText('sandbox')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('tab', { name: /Plugins/ })); + expect( + await screen.findByText( + 'No shared plugins are installed. Plugin registry files and cache folders are hidden until an actual plugin is present.' + ) + ).toBeInTheDocument(); }); it('shows offline guidance when network request fails', async () => { From 35346a981ff7c175cd8097bd3083d0bdd4d377f2 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 9 May 2026 14:20:06 -0400 Subject: [PATCH 25/38] fix(ui): keep profile dialog actions visible on small screens (#1204) --- .../profiles/profile-create-dialog.tsx | 19 ++++++++++---- .../profiles/profile-create-dialog.test.tsx | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 1cb81e9b..4a53e6d4 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -285,8 +285,11 @@ export function ProfileCreateDialog({ return ( - - + + {t('profileCreateDialog.createProfile')} @@ -298,7 +301,10 @@ export function ProfileCreateDialog({ onSubmit={handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 overflow-hidden" > -
+
@@ -398,7 +404,7 @@ export function ProfileCreateDialog({
-
+
{/* Profile Name */}
@@ -672,7 +678,10 @@ export function ProfileCreateDialog({
- + diff --git a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx index 73273d05..345280ec 100644 --- a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx +++ b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx @@ -76,6 +76,31 @@ describe('ProfileCreateDialog', () => { expect(screen.getByText('Local runtimes')).toBeInTheDocument(); }); + it('keeps the provider chooser and form body scrollable while the footer stays visible', () => { + render( + + ); + + expect(screen.getByTestId('profile-create-dialog')).toHaveClass( + 'h-[calc(100dvh-2rem)]', + 'overflow-hidden' + ); + expect(screen.getByTestId('profile-create-provider-chooser')).toHaveClass( + 'max-h-[34dvh]', + 'overflow-y-auto' + ); + expect(screen.getByTestId('profile-create-tab-scroll')).toHaveClass( + 'flex-1', + 'overflow-y-auto' + ); + expect(screen.getByTestId('profile-create-footer')).toHaveClass('shrink-0'); + }); + it('steers the Hugging Face preset to the droid target by default', async () => { render( Date: Sat, 9 May 2026 14:23:46 -0400 Subject: [PATCH 26/38] chore(release): 7.77.1-dev.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8e946f17..1486ddce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1-dev.9", + "version": "7.77.1-dev.10", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 641d492cd696fb8ff82c55bf8dc5495abf81e7c9 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 10 May 2026 15:12:40 -0400 Subject: [PATCH 27/38] feat(cliproxy): add OAuth callback traceability across profiles Introduces structured per-phase tracing for OAuth flows so users see branch-specific error messages instead of a generic "token not found" fallback. - New oauth-trace/ module: recorder, redactor, three sinks (in-memory ring buffer, verbose stdout, opt-in JSONL file at ~/.ccs/logs/oauth-YYYYMMDD.log mode 0o600 via CCS_OAUTH_LOG_FILE=1) - Branch-specific diagnostics for: URL-not-displayed, callback-not-observed, binary-error, token-exchange-error, session-expired, token-file-missing; UNKNOWN fallback preserves prior UX - Trace recorder threaded through oauth-process.ts spawn/stdout/stderr/ exit lifecycle; SIGINT/SIGTERM cleanup flushes recorder and records Cancelled - Redactor strips code, state, access_token, refresh_token, id_token, client_secret, code_verifier, device_code, assertion, subject_token, plus Authorization: Bearer headers; covers URL fragments, URL-encoded keys, and arrays - 68 new tests including adversarial regression cases for redactor bypass attempts Phases for paste-callback path and cross-profile failure-matrix deferred to follow-up #1207. Closes #1206 --- .../__tests__/oauth-diagnose-failure.test.ts | 197 +++++++++++++ .../__tests__/oauth-trace-recorder.test.ts | 128 +++++++++ .../__tests__/oauth-trace-redactor.test.ts | 270 ++++++++++++++++++ .../__tests__/oauth-trace-sink-file.test.ts | 94 ++++++ src/cliproxy/auth/oauth-process.ts | 98 ++++++- .../auth/oauth-trace/diagnose-failure.ts | 214 ++++++++++++++ src/cliproxy/auth/oauth-trace/index.ts | 13 + src/cliproxy/auth/oauth-trace/redactor.ts | 119 ++++++++ src/cliproxy/auth/oauth-trace/sink-file.ts | 84 ++++++ src/cliproxy/auth/oauth-trace/sink-memory.ts | 31 ++ .../auth/oauth-trace/sink-verbose-stdout.ts | 42 +++ src/cliproxy/auth/oauth-trace/trace-events.ts | 63 ++++ .../auth/oauth-trace/trace-recorder.ts | 107 +++++++ 13 files changed, 1457 insertions(+), 3 deletions(-) create mode 100644 src/cliproxy/auth/__tests__/oauth-diagnose-failure.test.ts create mode 100644 src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts create mode 100644 src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts create mode 100644 src/cliproxy/auth/__tests__/oauth-trace-sink-file.test.ts create mode 100644 src/cliproxy/auth/oauth-trace/diagnose-failure.ts create mode 100644 src/cliproxy/auth/oauth-trace/index.ts create mode 100644 src/cliproxy/auth/oauth-trace/redactor.ts create mode 100644 src/cliproxy/auth/oauth-trace/sink-file.ts create mode 100644 src/cliproxy/auth/oauth-trace/sink-memory.ts create mode 100644 src/cliproxy/auth/oauth-trace/sink-verbose-stdout.ts create mode 100644 src/cliproxy/auth/oauth-trace/trace-events.ts create mode 100644 src/cliproxy/auth/oauth-trace/trace-recorder.ts diff --git a/src/cliproxy/auth/__tests__/oauth-diagnose-failure.test.ts b/src/cliproxy/auth/__tests__/oauth-diagnose-failure.test.ts new file mode 100644 index 00000000..12efb200 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-diagnose-failure.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from 'bun:test'; +import { diagnoseFailure, formatErrorMessage } from '../oauth-trace/diagnose-failure'; +import { OAuthTracePhase, type OAuthTraceEvent } from '../oauth-trace/trace-events'; + +let tCounter = 1000; +function ev(phase: OAuthTracePhase, over: Partial = {}): OAuthTraceEvent { + tCounter += 10; + return { + sessionId: 's', + provider: 'codex', + phase, + ts: tCounter, + elapsedMs: tCounter - 1000, + ...over, + }; +} + +function reset() { + tCounter = 1000; +} + +describe('diagnoseFailure', () => { + test('empty snapshot -> UNKNOWN', () => { + expect(diagnoseFailure([]).branchId).toBe('UNKNOWN'); + }); + + test('URL_NOT_DISPLAYED when no AuthUrlDisplayed and exit=0', () => { + reset(); + const snap = [ + ev(OAuthTracePhase.BinarySpawn), + ev(OAuthTracePhase.BinaryExit, { data: { code: 0 } }), + ]; + expect(diagnoseFailure(snap).branchId).toBe('URL_NOT_DISPLAYED'); + }); + + test('BROWSER_NOT_OPENED after URL displayed past heuristic window', () => { + reset(); + const snap: OAuthTraceEvent[] = [ + { ...ev(OAuthTracePhase.AuthUrlDisplayed), ts: 1000 }, + { ...ev(OAuthTracePhase.BinaryStdout), ts: 1000 + 6000 }, + ]; + expect(diagnoseFailure(snap).branchId).toBe('BROWSER_NOT_OPENED'); + }); + + test('CALLBACK_NEVER_OBSERVED when browser opened, exit=0, no callback heuristic', () => { + reset(); + const snap = [ + ev(OAuthTracePhase.AuthUrlDisplayed), + ev(OAuthTracePhase.BrowserOpened), + ev(OAuthTracePhase.BinaryExit, { data: { code: 0 } }), + ]; + expect(diagnoseFailure(snap).branchId).toBe('CALLBACK_NEVER_OBSERVED'); + }); + + test('BINARY_ERROR_EXIT when exit code non-zero', () => { + reset(); + const snap = [ + ev(OAuthTracePhase.BinarySpawn), + ev(OAuthTracePhase.BinaryExit, { data: { code: 2, stderrTail: 'oops' } }), + ]; + const r = diagnoseFailure(snap); + expect(r.branchId).toBe('BINARY_ERROR_EXIT'); + expect(r.data['code']).toBe(2); + }); + + test('TOKEN_FILE_MISSING_POST_EXIT when token-missing event present', () => { + reset(); + const snap = [ + ev(OAuthTracePhase.AuthUrlDisplayed), + ev(OAuthTracePhase.BrowserOpened), + ev(OAuthTracePhase.CallbackObservedHeuristic), + ev(OAuthTracePhase.BinaryExit, { data: { code: 0 } }), + ev(OAuthTracePhase.TokenFileMissing), + ]; + expect(diagnoseFailure(snap).branchId).toBe('TOKEN_FILE_MISSING_POST_EXIT'); + }); + + test('TIMEOUT when timeout event present', () => { + reset(); + const snap = [ + ev(OAuthTracePhase.BinarySpawn), + ev(OAuthTracePhase.Timeout, { data: { timeoutMs: 120000 } }), + ]; + const r = diagnoseFailure(snap); + expect(r.branchId).toBe('TIMEOUT'); + expect(r.data['timeoutMs']).toBe(120000); + }); + + test('SESSION_CANCELLED on cancel event', () => { + reset(); + const snap = [ev(OAuthTracePhase.Cancelled)]; + expect(diagnoseFailure(snap).branchId).toBe('SESSION_CANCELLED'); + }); + + test('TOKEN_EXCHANGE_REJECTED via Error code=CALLBACK_REJECTED', () => { + reset(); + const snap = [ + ev(OAuthTracePhase.PasteCallbackSubmitted), + ev(OAuthTracePhase.Error, { + error: { code: 'CALLBACK_REJECTED', message: 'invalid_grant' }, + }), + ]; + const r = diagnoseFailure(snap); + expect(r.branchId).toBe('TOKEN_EXCHANGE_REJECTED'); + expect(r.data['upstreamError']).toBe('invalid_grant'); + }); + + test('PASTE_INVALID from invalid event', () => { + reset(); + const snap = [ + ev(OAuthTracePhase.PasteCallbackPrompted), + ev(OAuthTracePhase.PasteCallbackInvalid, { data: { reason: 'missing_code' } }), + ]; + const r = diagnoseFailure(snap); + expect(r.branchId).toBe('PASTE_INVALID'); + expect(r.data['reason']).toBe('missing_code'); + }); + + test('GEMINI_PLUS_MISSING_CRED from explicit error', () => { + reset(); + const snap = [ + ev(OAuthTracePhase.Error, { + error: { code: 'GEMINI_PLUS_MISSING_CRED', message: 'missing' }, + }), + ]; + expect(diagnoseFailure(snap).branchId).toBe('GEMINI_PLUS_MISSING_CRED'); + }); + + test('AGY_RESPONSIBILITY_DECLINED from explicit error', () => { + reset(); + const snap = [ + ev(OAuthTracePhase.Error, { + error: { code: 'AGY_RESPONSIBILITY_DECLINED', message: 'declined' }, + }), + ]; + expect(diagnoseFailure(snap).branchId).toBe('AGY_RESPONSIBILITY_DECLINED'); + }); + + test('diagnose is pure: same input -> same output, no side-effects', () => { + reset(); + const snap = [ev(OAuthTracePhase.BinarySpawn), ev(OAuthTracePhase.Timeout)]; + const a = diagnoseFailure(snap); + const b = diagnoseFailure(snap); + expect(a).toEqual(b); + // ensure snapshot wasn't mutated + expect(snap).toHaveLength(2); + }); +}); + +describe('formatErrorMessage', () => { + const baseOpts = { + verbose: false, + platform: 'linux' as NodeJS.Platform, + callbackPort: 1455, + provider: 'codex', + }; + + test('UNKNOWN preserves backward-compat 3-bullet feel and ends with verbose hint', () => { + const lines = formatErrorMessage({ branchId: 'UNKNOWN', data: {} }, baseOpts); + expect(lines.some((l) => l.includes('Token not found'))).toBe(true); + expect(lines.some((l) => l.startsWith('Try: ccs codex --auth --verbose'))).toBe(true); + // body lines (excluding trailing remediation) ≤ 5 -> not exploding + expect(lines.length).toBeLessThanOrEqual(6); + }); + + test('CALLBACK_NEVER_OBSERVED includes paste-callback hint with provider', () => { + const lines = formatErrorMessage({ branchId: 'CALLBACK_NEVER_OBSERVED', data: {} }, baseOpts); + expect(lines.some((l) => l.includes('--no-browser'))).toBe(true); + }); + + test('CALLBACK_NEVER_OBSERVED on win32 appends netsh hint', () => { + const lines = formatErrorMessage( + { branchId: 'CALLBACK_NEVER_OBSERVED', data: {} }, + { ...baseOpts, platform: 'win32' } + ); + expect(lines.some((l) => l.includes('netsh advfirewall'))).toBe(true); + }); + + test('contains no emoji and no sensitive keys', () => { + const lines = formatErrorMessage( + { branchId: 'BINARY_ERROR_EXIT', data: { code: 7, stderrTail: 'fail' } }, + baseOpts + ); + const blob = lines.join('\n'); + // No common emoji ranges + expect(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(blob)).toBe(false); + expect(blob).not.toMatch(/access_token|refresh_token|id_token|client_secret/i); + }); + + test('verbose mode appends trace hint line', () => { + const lines = formatErrorMessage( + { branchId: 'UNKNOWN', data: {} }, + { ...baseOpts, verbose: true } + ); + expect(lines.some((l) => l.includes('--verbose'))).toBe(true); + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts b/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts new file mode 100644 index 00000000..86dc7fc8 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'bun:test'; +import { createOAuthTraceRecorder } from '../oauth-trace/trace-recorder'; +import { OAuthTracePhase } from '../oauth-trace/trace-events'; +import { REDACTED_PLACEHOLDER } from '../oauth-trace/redactor'; + +function makeRecorder(verbose = false, lines: string[] = []) { + let t = 1000; + const rec = createOAuthTraceRecorder({ + sessionId: 'sess-1', + provider: 'codex', + verbose, + now: () => t, + verboseOut: (line) => lines.push(line), + }); + return { + rec, + advance(ms: number) { + t += ms; + }, + }; +} + +describe('createOAuthTraceRecorder', () => { + test('records event with sessionId and provider correlation', () => { + const { rec } = makeRecorder(); + rec.record(OAuthTracePhase.BinarySpawn); + const snap = rec.snapshot(); + expect(snap).toHaveLength(1); + expect(snap[0].sessionId).toBe('sess-1'); + expect(snap[0].provider).toBe('codex'); + expect(snap[0].phase).toBe(OAuthTracePhase.BinarySpawn); + }); + + test('phase ordering preserved with monotonic elapsedMs', () => { + const { rec, advance } = makeRecorder(); + rec.record(OAuthTracePhase.PreflightOk); + advance(10); + rec.record(OAuthTracePhase.BinarySpawn); + advance(50); + rec.record(OAuthTracePhase.AuthUrlDisplayed); + const snap = rec.snapshot(); + expect(snap.map((e) => e.phase)).toEqual([ + OAuthTracePhase.PreflightOk, + OAuthTracePhase.BinarySpawn, + OAuthTracePhase.AuthUrlDisplayed, + ]); + expect(snap[0].elapsedMs).toBe(0); + expect(snap[1].elapsedMs).toBe(10); + expect(snap[2].elapsedMs).toBe(60); + }); + + test('memory sink returns full event log via snapshot()', () => { + const { rec } = makeRecorder(); + for (let i = 0; i < 5; i++) { + rec.record(OAuthTracePhase.BinaryStdout, { i }); + } + expect(rec.snapshot()).toHaveLength(5); + }); + + test('verbose sink writes only when verbose=true', () => { + const linesOff: string[] = []; + const { rec: off } = makeRecorder(false, linesOff); + off.record(OAuthTracePhase.BinarySpawn); + expect(linesOff).toHaveLength(0); + + const linesOn: string[] = []; + const { rec: on } = makeRecorder(true, linesOn); + on.record(OAuthTracePhase.BinarySpawn, { port: 1455 }); + expect(linesOn).toHaveLength(1); + expect(linesOn[0]).toMatch(/^\[oauth-trace\] \+0ms binary\.spawn/); + expect(linesOn[0]).toContain('port=1455'); + }); + + test('summary returns counts and lastPhase', () => { + const { rec, advance } = makeRecorder(); + rec.record(OAuthTracePhase.BinaryStdout); + rec.record(OAuthTracePhase.BinaryStdout); + advance(5); + rec.record(OAuthTracePhase.BinaryExit); + const s = rec.summary(); + expect(s.phaseCounts[OAuthTracePhase.BinaryStdout]).toBe(2); + expect(s.phaseCounts[OAuthTracePhase.BinaryExit]).toBe(1); + expect(s.lastPhase).toBe(OAuthTracePhase.BinaryExit); + expect(s.totalMs).toBe(5); + }); + + test('snapshot during in-flight events does not throw and returns copy', () => { + const { rec } = makeRecorder(); + rec.record(OAuthTracePhase.BinarySpawn); + const snap = rec.snapshot(); + rec.record(OAuthTracePhase.BinaryExit); + expect(snap).toHaveLength(1); // earlier snapshot unaffected + expect(rec.snapshot()).toHaveLength(2); + }); + + test('redactor invoked: raw OAuth params do not reach sinks', () => { + const lines: string[] = []; + const { rec } = makeRecorder(true, lines); + rec.record(OAuthTracePhase.AuthUrlDisplayed, { + url: 'https://example.com/auth?code=AUTHCODE_SECRET&state=ST', + access_token: 'AT_LEAK', + }); + const snap = rec.snapshot(); + const blob = JSON.stringify(snap) + '\n' + lines.join('\n'); + expect(blob).not.toContain('AUTHCODE_SECRET'); + expect(blob).not.toContain('AT_LEAK'); + expect(blob).toContain(REDACTED_PLACEHOLDER); + }); + + test('flush() resolves even with no file sink', async () => { + const { rec } = makeRecorder(); + rec.record(OAuthTracePhase.BinaryExit); + await expect(rec.flush()).resolves.toBeUndefined(); + }); + + test('error param surfaces as event.error', () => { + const { rec } = makeRecorder(); + rec.record(OAuthTracePhase.Error, { branch: 'X' }, { code: 'E1', message: 'boom' }); + const snap = rec.snapshot(); + expect(snap[0].error).toEqual({ code: 'E1', message: 'boom' }); + }); + + test('Error instance accepted', () => { + const { rec } = makeRecorder(); + rec.record(OAuthTracePhase.Error, undefined, new Error('plain')); + expect(rec.snapshot()[0].error?.message).toBe('plain'); + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts b/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts new file mode 100644 index 00000000..beec9578 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from 'bun:test'; +import { + redactString, + redactUrl, + redactJsonShallow, + redactBearer, + REDACTED_PLACEHOLDER, +} from '../oauth-trace/redactor'; +import { createMemorySink, MEMORY_SINK_MAX_EVENTS } from '../oauth-trace/sink-memory'; + +describe('redactString', () => { + test('redacts code= query value', () => { + const out = redactString('http://localhost:1455/cb?code=AUTHCODE_SECRET&foo=bar'); + expect(out).not.toContain('AUTHCODE_SECRET'); + expect(out).toContain(`code=${REDACTED_PLACEHOLDER}`); + expect(out).toContain('foo=bar'); + }); + + test('redacts state= query value', () => { + const out = redactString('?state=STATE_SECRET&x=1'); + expect(out).not.toContain('STATE_SECRET'); + expect(out).toContain(`state=${REDACTED_PLACEHOLDER}`); + }); + + test('redacts access_token, refresh_token, id_token query values', () => { + const s = '?access_token=A&refresh_token=B&id_token=C'; + const out = redactString(s); + expect(out).not.toContain('access_token=A'); + expect(out).not.toContain('refresh_token=B'); + expect(out).not.toContain('id_token=C'); + }); + + test('redacts bearer header value', () => { + expect(redactString('Authorization: Bearer abc.def.ghi')).toContain( + `Bearer ${REDACTED_PLACEHOLDER}` + ); + }); + + test('preserves non-sensitive params and host/path', () => { + const out = redactString('https://example.com/auth/cb?code=X&client_id=public'); + expect(out).toContain('example.com'); + expect(out).toContain('/auth/cb'); + expect(out).toContain('client_id=public'); + }); + + test('idempotent: redacting twice == once', () => { + const once = redactString('?code=X&state=Y'); + expect(redactString(once)).toBe(once); + }); + + test('empty input passthrough', () => { + expect(redactString('')).toBe(''); + }); +}); + +describe('redactUrl', () => { + test('redacts known sensitive params via URL parser', () => { + const out = redactUrl('https://example.com/cb?code=AUTHCODE&state=ST&keep=1'); + expect(out).toContain(`code=${encodeURIComponent(REDACTED_PLACEHOLDER)}`); + expect(out).toContain(`state=${encodeURIComponent(REDACTED_PLACEHOLDER)}`); + expect(out).toContain('keep=1'); + expect(out).not.toContain('AUTHCODE'); + }); + + test('falls back gracefully on invalid URL', () => { + expect(redactUrl('not a url ?code=X')).toContain(REDACTED_PLACEHOLDER); + }); +}); + +describe('redactJsonShallow', () => { + test('replaces sensitive top-level keys', () => { + const out = redactJsonShallow({ + access_token: 'AT', + refresh_token: 'RT', + id_token: 'IT', + client_secret: 'CS', + keep: 'visible', + }); + expect(out['access_token']).toBe(REDACTED_PLACEHOLDER); + expect(out['refresh_token']).toBe(REDACTED_PLACEHOLDER); + expect(out['id_token']).toBe(REDACTED_PLACEHOLDER); + expect(out['client_secret']).toBe(REDACTED_PLACEHOLDER); + expect(out['keep']).toBe('visible'); + }); + + test('redacts string values for sensitive params inside string fields', () => { + const out = redactJsonShallow({ url: 'https://x/cb?code=SECRET' }); + expect(String(out['url'])).not.toContain('SECRET'); + }); + + test('recurses into nested plain objects', () => { + const out = redactJsonShallow({ + headers: { Authorization: 'Bearer XYZ', host: 'a' }, + }); + const headers = out['headers'] as Record; + expect(headers['Authorization']).toBe(REDACTED_PLACEHOLDER); + expect(headers['host']).toBe('a'); + }); + + test('case-insensitive key match', () => { + const out = redactJsonShallow({ Access_Token: 'X', AUTHORIZATION: 'Y' }); + expect(out['Access_Token']).toBe(REDACTED_PLACEHOLDER); + expect(out['AUTHORIZATION']).toBe(REDACTED_PLACEHOLDER); + }); +}); + +describe('redactBearer', () => { + test('replaces bearer value preserving prefix', () => { + expect(redactBearer('Bearer abc.def.ghi')).toBe(`Bearer ${REDACTED_PLACEHOLDER}`); + }); +}); + +// ---- adversarial / edge-case coverage (findings #1-3, #4, #7) ---- + +describe('redactUrl — fragment leak (finding #1)', () => { + test('redacts code in fragment — first param after #', () => { + const out = redactUrl('https://x/cb#code=SECRET&state=X'); + expect(out).not.toContain('SECRET'); + expect(out).not.toContain('state=X'); + }); + + test('redacts access_token in fragment', () => { + const out = redactUrl('https://x/cb#access_token=AT&token_type=bearer'); + expect(out).not.toContain('AT'); + expect(out).toContain('token_type=bearer'); + }); + + test('non-sensitive fragment params preserved', () => { + const out = redactUrl('https://x/cb#section=1&code=S'); + expect(out).toContain('section=1'); + expect(out).not.toContain('=S'); + }); + + test('Google OAuth fragment flow (real shape)', () => { + const url = + 'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount#access_token=ya29.secret&token_type=Bearer&expires_in=3599'; + const out = redactUrl(url); + expect(out).not.toContain('ya29.secret'); + expect(out).toContain('token_type=Bearer'); + }); +}); + +describe('redactUrl — URL-encoded key bypass (finding #2)', () => { + test('redacts %63%6F%64%65 (= "code") key', () => { + // %63%6F%64%65 is "code" percent-encoded + const out = redactUrl('https://x/cb?%63%6F%64%65=SECRET'); + expect(out).not.toContain('SECRET'); + }); + + test('redacts %73%74%61%74%65 (= "state") key', () => { + const out = redactUrl('https://x/cb?%73%74%61%74%65=STATEVAL'); + expect(out).not.toContain('STATEVAL'); + }); +}); + +describe('redactJsonShallow — array passthrough (finding #3)', () => { + test('redacts access_token inside array of objects', () => { + const out = redactJsonShallow({ tokens: [{ access_token: 'AT', scope: 'read' }] }); + const tokens = out['tokens'] as Array>; + expect(tokens[0]['access_token']).toBe(REDACTED_PLACEHOLDER); + expect(tokens[0]['scope']).toBe('read'); + }); + + test('nested arrays of token objects', () => { + const out = redactJsonShallow({ + batches: [{ tokens: [{ refresh_token: 'RT' }] }], + }); + const batches = out['batches'] as Array>; + const inner = (batches[0]['tokens'] as Array>)[0]; + // Only one level of array recursion; inner object is recursed as plain obj + expect(inner['refresh_token']).toBe(REDACTED_PLACEHOLDER); + }); + + test('mixed-case key Code redacted', () => { + const out = redactJsonShallow({ Code: 'abc', CODE: 'xyz' }); + expect(out['Code']).toBe(REDACTED_PLACEHOLDER); + expect(out['CODE']).toBe(REDACTED_PLACEHOLDER); + }); + + test('string value with url-embedded code is redacted', () => { + const out = redactJsonShallow({ log: 'callback?code=SECRET&state=ST' }); + expect(String(out['log'])).not.toContain('SECRET'); + }); +}); + +describe('PKCE / device-flow keys (finding #4)', () => { + test('redactUrl redacts code_verifier', () => { + const out = redactUrl('https://x/token?code_verifier=VERIFIER&grant_type=pkce'); + expect(out).not.toContain('VERIFIER'); + expect(out).toContain('grant_type=pkce'); + }); + + test('redactUrl redacts device_code', () => { + const out = redactUrl('https://x/token?device_code=DC123'); + expect(out).not.toContain('DC123'); + }); + + test('redactUrl redacts assertion', () => { + const out = redactUrl('https://x/token?assertion=JWT_VAL'); + expect(out).not.toContain('JWT_VAL'); + }); + + test('redactUrl redacts subject_token', () => { + const out = redactUrl('https://x/token?subject_token=ST_VAL'); + expect(out).not.toContain('ST_VAL'); + }); + + test('redactString redacts code_verifier in raw string', () => { + const out = redactString('?code_verifier=MY_VERIFIER&other=1'); + expect(out).not.toContain('MY_VERIFIER'); + expect(out).toContain('other=1'); + }); +}); + +describe('createMemorySink — bounded ring buffer (finding #6)', () => { + test('default capacity is MEMORY_SINK_MAX_EVENTS (1000)', () => { + expect(MEMORY_SINK_MAX_EVENTS).toBe(1000); + }); + + test('older events dropped when capacity exceeded', () => { + const sink = createMemorySink(3); + const makeEvent = (i: number) => + ({ + sessionId: 's', + provider: 'p', + phase: `phase.${i}` as never, + ts: i, + elapsedMs: i, + }) as never; + + sink.write(makeEvent(1)); + sink.write(makeEvent(2)); + sink.write(makeEvent(3)); + sink.write(makeEvent(4)); // should drop event 1 + + const snap = sink.snapshot(); + expect(snap).toHaveLength(3); + expect(snap[0].ts).toBe(2); + expect(snap[2].ts).toBe(4); + }); + + test('droppedCount tracks number of dropped events', () => { + const sink = createMemorySink(2); + const makeEvent = (i: number) => + ({ + sessionId: 's', + provider: 'p', + phase: 'p' as never, + ts: i, + elapsedMs: i, + }) as never; + + sink.write(makeEvent(1)); + sink.write(makeEvent(2)); + expect(sink.droppedCount()).toBe(0); + sink.write(makeEvent(3)); + expect(sink.droppedCount()).toBe(1); + sink.write(makeEvent(4)); + expect(sink.droppedCount()).toBe(2); + }); + + test('snapshot returns copy — mutations do not affect buffer', () => { + const sink = createMemorySink(5); + const ev = { sessionId: 's', provider: 'p', phase: 'x' as never, ts: 1, elapsedMs: 0 }; + sink.write(ev as never); + const snap = sink.snapshot(); + snap.pop(); + expect(sink.snapshot()).toHaveLength(1); + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-trace-sink-file.test.ts b/src/cliproxy/auth/__tests__/oauth-trace-sink-file.test.ts new file mode 100644 index 00000000..15e03901 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-trace-sink-file.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { createFileSink } from '../oauth-trace/sink-file'; +import { OAuthTracePhase, type OAuthTraceEvent } from '../oauth-trace/trace-events'; + +let tmpDir: string; + +function makeEvent(over: Partial = {}): OAuthTraceEvent { + return { + sessionId: 'sess-1', + provider: 'codex', + phase: OAuthTracePhase.BinarySpawn, + ts: 1000, + elapsedMs: 0, + ...over, + }; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oauth-trace-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('createFileSink', () => { + test('writes one JSONL line per event', () => { + const fixed = new Date(2026, 4, 10); + const sink = createFileSink({ dir: tmpDir, now: () => fixed }); + sink.write(makeEvent({ phase: OAuthTracePhase.BinarySpawn })); + sink.write(makeEvent({ phase: OAuthTracePhase.BinaryExit, elapsedMs: 5 })); + const file = path.join(tmpDir, 'oauth-20260510.log'); + const lines = fs.readFileSync(file, 'utf8').trim().split('\n'); + expect(lines).toHaveLength(2); + const parsed1 = JSON.parse(lines[0]); + expect(parsed1.phase).toBe(OAuthTracePhase.BinarySpawn); + expect(parsed1.sessionId).toBe('sess-1'); + expect(parsed1.provider).toBe('codex'); + }); + + test('file mode is 0600 (user-only)', () => { + const fixed = new Date(2026, 4, 10); + const sink = createFileSink({ dir: tmpDir, now: () => fixed }); + sink.write(makeEvent()); + const file = path.join(tmpDir, 'oauth-20260510.log'); + const mode = fs.statSync(file).mode & 0o777; + expect(mode).toBe(0o600); + }); + + test('rotates by date — different day yields different file', () => { + let day = new Date(2026, 4, 10); + const sink = createFileSink({ dir: tmpDir, now: () => day }); + sink.write(makeEvent({ sessionId: 'a' })); + day = new Date(2026, 4, 11); + sink.write(makeEvent({ sessionId: 'b' })); + expect(fs.existsSync(path.join(tmpDir, 'oauth-20260510.log'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'oauth-20260511.log'))).toBe(true); + }); + + test('write failure does not throw and notifies once', () => { + const errors: string[] = []; + const badDir = path.join(tmpDir, 'nonexistent', 'a', 'b'); + // Force mkdir failure by simulating EROFS via permission-denied path + // (we bypass mkdir error by making `dir` a file) + fs.writeFileSync(path.join(tmpDir, 'as_file'), 'x'); + const sink = createFileSink({ + dir: path.join(tmpDir, 'as_file', 'sub'), + onError: (msg) => errors.push(msg), + }); + expect(() => sink.write(makeEvent())).not.toThrow(); + expect(errors.length).toBeGreaterThanOrEqual(1); + void badDir; + }); + + test('flush resolves cleanly', async () => { + const sink = createFileSink({ dir: tmpDir }); + sink.write(makeEvent()); + await expect(sink.flush?.()).resolves.toBeUndefined(); + }); + + test('concurrent recorders with separate sessionIds both land in file', () => { + const fixed = new Date(2026, 4, 10); + const sink = createFileSink({ dir: tmpDir, now: () => fixed }); + sink.write(makeEvent({ sessionId: 'A' })); + sink.write(makeEvent({ sessionId: 'B' })); + const file = path.join(tmpDir, 'oauth-20260510.log'); + const lines = fs.readFileSync(file, 'utf8').trim().split('\n'); + const ids = lines.map((l) => JSON.parse(l).sessionId).sort(); + expect(ids).toEqual(['A', 'B']); + }); +}); diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 2557fcb7..10c9a806 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -42,6 +42,12 @@ import { unregisterAuthSession, authSessionEvents, } from '../auth/auth-session-manager'; +import { createOAuthTraceRecorder, OAuthTracePhase, type OAuthTraceRecorder } from './oauth-trace'; +import { redactString } from './oauth-trace/redactor'; +import { createFileSink } from './oauth-trace/sink-file'; +import { diagnoseFailure, formatErrorMessage } from './oauth-trace/diagnose-failure'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; /** Options for OAuth process execution */ export interface OAuthProcessOptions { @@ -575,7 +581,8 @@ async function handleTokenNotFound( nickname: string | undefined, expectedAccountId: string | undefined, verbose: boolean, - failureReason?: string + failureReason?: string, + trace?: OAuthTraceRecorder ): Promise { console.log(''); @@ -611,6 +618,22 @@ async function handleTokenNotFound( return null; } + // Branch-specific diagnosis when trace recorder is wired (Phase 7). + if (trace) { + const diagnosis = diagnoseFailure(trace.snapshot()); + if (diagnosis.branchId !== 'UNKNOWN') { + console.log(fail('Authentication failed')); + const lines = formatErrorMessage(diagnosis, { + verbose, + platform: process.platform, + callbackPort, + provider, + }); + for (const line of lines) console.log(line); + return null; + } + } + console.log(fail('Token not found after authentication')); console.log(''); console.log('The browser showed success but callback was not received.'); @@ -698,10 +721,22 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise | null = null; + // Mutable ref so cleanup/handleCancel can flush trace even though `trace` + // is assigned after them. DEFERRED: per-event syscall throttling. + let traceRef: OAuthTraceRecorder | null = null; + // H5: Signal handling - properly kill child process on SIGINT/SIGTERM // H8: Also clear stdinKeepalive interval to prevent memory leak const cleanup = () => { if (stdinKeepalive) clearInterval(stdinKeepalive); + if (traceRef) { + try { + traceRef.record(OAuthTracePhase.Cancelled, { reason: 'signal' }); + void traceRef.flush(); + } catch { + // never block shutdown on trace errors + } + } if (authProcess && authProcess.exitCode === null) { killWithEscalation(authProcess); } @@ -724,6 +759,25 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { if (cancelledSessionId === state.sessionId && authProcess && authProcess.exitCode === null) { log('Session cancelled externally'); + if (traceRef) { + try { + traceRef.record(OAuthTracePhase.Cancelled, { reason: 'external' }); + void traceRef.flush(); + } catch { + // never block shutdown on trace errors + } + } killWithEscalation(authProcess); } }; @@ -754,13 +816,30 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { - await handleStdout(data.toString(), state, options, authProcess, log); + const out = data.toString(); + const wasUrlDisplayed = state.urlDisplayed; + const wasBrowserOpened = state.browserOpened; + await handleStdout(out, state, options, authProcess, log); + if (!wasUrlDisplayed && state.urlDisplayed) { + trace.record(OAuthTracePhase.AuthUrlDisplayed, {}); + } + if (!wasBrowserOpened && state.browserOpened) { + trace.record(OAuthTracePhase.BrowserOpened, { port: callbackPort }); + // CLIProxyAPI emits "Callback server listening" when bind succeeds; treat as + // best-available signal that the loopback path is reachable. This is a heuristic. + trace.record(OAuthTracePhase.CallbackObservedHeuristic, { port: callbackPort }); + } + // Capture redacted snippet (cap at 200 chars; high-frequency lines accepted as data). + const snippet = redactString(out.trim().slice(0, 200)); + if (snippet) trace.record(OAuthTracePhase.BinaryStdout, { snippet }); }); authProcess.stderr?.on('data', async (data: Buffer) => { const output = data.toString(); state.stderrData += output; log(`stderr: ${output.trim()}`); + const snippet = redactString(output.trim().slice(0, 200)); + if (snippet) trace.record(OAuthTracePhase.BinaryStderr, { snippet }); if (headless && !state.urlDisplayed) { displayUrlFromStderr(output, state, oauthConfig); } @@ -828,6 +907,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise; +} + +const BROWSER_OPEN_HEURISTIC_MS = 5000; + +/** + * Pure function: read a recorder snapshot and decide which failure branch fits best. + * No side effects, no console writes. + */ +export function diagnoseFailure(snapshot: OAuthTraceEvent[]): DiagnosisResult { + if (snapshot.length === 0) { + return { branchId: 'UNKNOWN', data: {} }; + } + + const has = (phase: OAuthTracePhase) => snapshot.some((e) => e.phase === phase); + const last = (phase: OAuthTracePhase) => [...snapshot].reverse().find((e) => e.phase === phase); + const lastError = [...snapshot].reverse().find((e) => e.phase === OAuthTracePhase.Error); + + // Provider gate aborts (highest priority — explicit error code) + if (lastError?.error?.code === 'GEMINI_PLUS_MISSING_CRED') { + return { branchId: 'GEMINI_PLUS_MISSING_CRED', data: lastError.data ?? {} }; + } + if (lastError?.error?.code === 'AGY_RESPONSIBILITY_DECLINED') { + return { branchId: 'AGY_RESPONSIBILITY_DECLINED', data: lastError.data ?? {} }; + } + if (lastError?.error?.code === 'CALLBACK_REJECTED') { + return { + branchId: 'TOKEN_EXCHANGE_REJECTED', + data: { upstreamError: lastError.error.message, ...(lastError.data ?? {}) }, + }; + } + + if (has(OAuthTracePhase.PasteCallbackInvalid)) { + const ev = last(OAuthTracePhase.PasteCallbackInvalid); + return { branchId: 'PASTE_INVALID', data: ev?.data ?? {} }; + } + + if (has(OAuthTracePhase.Cancelled)) { + return { branchId: 'SESSION_CANCELLED', data: {} }; + } + + if (has(OAuthTracePhase.Timeout)) { + const ev = last(OAuthTracePhase.Timeout); + return { branchId: 'TIMEOUT', data: ev?.data ?? {} }; + } + + const exitEv = last(OAuthTracePhase.BinaryExit); + if (exitEv) { + const code = (exitEv.data?.code as number | undefined) ?? null; + if (code !== null && code !== 0) { + return { + branchId: 'BINARY_ERROR_EXIT', + data: { + code, + stderrTail: exitEv.data?.stderrTail ?? '', + }, + }; + } + } + + // exit=0 (or no exit) plus token-file states + if (has(OAuthTracePhase.TokenFileMissing)) { + return { branchId: 'TOKEN_FILE_MISSING_POST_EXIT', data: {} }; + } + + if (!has(OAuthTracePhase.AuthUrlDisplayed)) { + return { branchId: 'URL_NOT_DISPLAYED', data: {} }; + } + + // URL was displayed: did browser open within heuristic window? + if (!has(OAuthTracePhase.BrowserOpened)) { + const urlEv = last(OAuthTracePhase.AuthUrlDisplayed); + const lastTs = snapshot[snapshot.length - 1].ts; + if (urlEv && lastTs - urlEv.ts >= BROWSER_OPEN_HEURISTIC_MS) { + return { branchId: 'BROWSER_NOT_OPENED', data: {} }; + } + } + + if ( + has(OAuthTracePhase.BrowserOpened) && + !has(OAuthTracePhase.CallbackObservedHeuristic) && + has(OAuthTracePhase.BinaryExit) + ) { + return { branchId: 'CALLBACK_NEVER_OBSERVED', data: {} }; + } + + return { branchId: 'UNKNOWN', data: {} }; +} + +export interface FormatErrorOptions { + verbose: boolean; + platform: NodeJS.Platform; + callbackPort: number | null; + provider: string; +} + +/** + * Map a diagnosed branch to user-facing message lines (ASCII only, no emojis). + * Always ends with a concrete next-step command. + */ +export function formatErrorMessage(result: DiagnosisResult, opts: FormatErrorOptions): string[] { + const { branchId, data } = result; + const { provider, callbackPort, platform, verbose } = opts; + const lines: string[] = []; + + switch (branchId) { + case 'URL_NOT_DISPLAYED': + lines.push('OAuth URL was never produced.'); + lines.push('The CLIProxy binary may have failed to start or exited too early.'); + lines.push(`Try: ccs ${provider} --auth --verbose`); + break; + + case 'BROWSER_NOT_OPENED': + lines.push('OAuth URL was displayed but the browser did not open.'); + lines.push('Copy the URL above and open it manually in any browser.'); + break; + + case 'CALLBACK_NEVER_OBSERVED': + lines.push( + `Browser completed login but no callback reached localhost:${callbackPort ?? '?'}.` + ); + lines.push('Common cause: firewall, antivirus, or browser on a different machine.'); + lines.push(`Try paste-callback mode: ccs ${provider} --auth --no-browser`); + if (platform === 'win32' && callbackPort) { + lines.push('On Windows, try as Administrator:'); + lines.push( + ` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${callbackPort}` + ); + } + break; + + case 'BINARY_ERROR_EXIT': { + const code = (data['code'] as number | undefined) ?? '?'; + lines.push(`CLIProxy binary exited with code ${code}.`); + const tail = String(data['stderrTail'] ?? '').trim(); + if (tail) lines.push(` ${tail}`); + lines.push(`Try: ccs ${provider} --auth --verbose`); + break; + } + + case 'TOKEN_FILE_MISSING_POST_EXIT': + lines.push('Authentication appeared to succeed but no token file was created.'); + lines.push('Update CLIProxy and retry: ccs update'); + break; + + case 'TIMEOUT': { + const min = data['timeoutMs'] ? Math.round((data['timeoutMs'] as number) / 60000) : '?'; + lines.push(`OAuth flow timed out after ${min} minutes.`); + lines.push(`Re-run and complete login faster: ccs ${provider} --auth`); + break; + } + + case 'SESSION_CANCELLED': + lines.push('OAuth flow was cancelled.'); + break; + + case 'TOKEN_EXCHANGE_REJECTED': + lines.push( + `Token exchange rejected by provider: ${String(data['upstreamError'] ?? 'unknown')}.` + ); + lines.push(`Try: ccs ${provider} --auth --verbose`); + break; + + case 'PASTE_INVALID': + lines.push(`Pasted callback URL invalid: ${String(data['reason'] ?? 'unknown')}.`); + lines.push('Re-run and paste the full URL after browser login.'); + break; + + case 'GEMINI_PLUS_MISSING_CRED': + lines.push('Gemini-plus OAuth credentials missing.'); + lines.push('See: docs/providers/gemini.md'); + break; + + case 'AGY_RESPONSIBILITY_DECLINED': + lines.push('Antigravity responsibility prompt was declined.'); + lines.push(`Re-run and accept to proceed: ccs ${provider} --auth`); + break; + + case 'UNKNOWN': + default: + lines.push('Token not found after authentication'); + lines.push('Common causes:'); + lines.push(' 1. OAuth session timed out'); + lines.push(' 2. Callback server could not receive the redirect'); + lines.push(' 3. Browser did not redirect to localhost properly'); + lines.push(`Try: ccs ${provider} --auth --verbose`); + break; + } + + if (verbose) { + lines.push(''); + lines.push('Run with --verbose for the trace summary.'); + } + + return lines; +} diff --git a/src/cliproxy/auth/oauth-trace/index.ts b/src/cliproxy/auth/oauth-trace/index.ts new file mode 100644 index 00000000..d81164bf --- /dev/null +++ b/src/cliproxy/auth/oauth-trace/index.ts @@ -0,0 +1,13 @@ +export { OAuthTracePhase, type OAuthTraceEvent, type OAuthTraceSink } from './trace-events'; +export { + createOAuthTraceRecorder, + type OAuthTraceRecorder, + type OAuthTraceRecorderOptions, +} from './trace-recorder'; +export { + redactString, + redactUrl, + redactJsonShallow, + redactBearer, + REDACTED_PLACEHOLDER, +} from './redactor'; diff --git a/src/cliproxy/auth/oauth-trace/redactor.ts b/src/cliproxy/auth/oauth-trace/redactor.ts new file mode 100644 index 00000000..328d9744 --- /dev/null +++ b/src/cliproxy/auth/oauth-trace/redactor.ts @@ -0,0 +1,119 @@ +/** + * OAuth secret redactor — single choke point. + * + * Every value reaching any sink passes through these helpers first. + * Adding a new sensitive key here is the only place it has to change. + * + * DEFERRED: per-event syscall throttling; fsync / file-size cap on file sink. + */ + +const SENSITIVE_QUERY_KEYS = [ + 'code', + 'state', + 'access_token', + 'refresh_token', + 'id_token', + 'client_secret', + 'authorization', + // PKCE / device-flow / assertion keys + 'code_verifier', + 'device_code', + 'assertion', + 'subject_token', +] as const; + +const SENSITIVE_OBJECT_KEYS = new Set( + SENSITIVE_QUERY_KEYS.map((k) => k.toLowerCase()).concat(['authorization', 'bearer', 'token']) +); + +const REDACTED = '***REDACTED***'; + +/** + * Matches sensitive keys in query strings, fragments, and standard params. + * Lookbehind covers: `?`, `&`, `#`, and `&#` (fragment-then-amp) delimiters. + * Keys are decoded before matching to catch URL-encoded bypass attempts. + */ +const QUERY_PARAM_REGEX = new RegExp( + `(?<=[?&#])(${SENSITIVE_QUERY_KEYS.join('|')})=[^&#\\s]+`, + 'gi' +); + +const BEARER_REGEX = /Bearer\s+[A-Za-z0-9._\-~+/=]+/gi; + +/** Redact sensitive query-param values inside any string. Idempotent. */ +export function redactString(s: string): string { + if (!s) return s; + return s + .replace(QUERY_PARAM_REGEX, (_full, key) => `${key}=${REDACTED}`) + .replace(BEARER_REGEX, `Bearer ${REDACTED}`); +} + +/** Redact a parsed URL by name; returns redacted href or original on parse error. */ +export function redactUrl(u: string): string { + try { + const url = new URL(u); + + // Redact query params — URL parser already decoded keys, compare decoded. + for (const key of SENSITIVE_QUERY_KEYS) { + if (url.searchParams.has(key)) url.searchParams.set(key, REDACTED); + } + // Also catch URL-encoded key names that URL.searchParams may not normalise + // (e.g. `?%63%6F%64%65=SECRET`). Decode all keys and re-check. + for (const [rawKey, rawVal] of [...url.searchParams.entries()]) { + const decoded = decodeURIComponent(rawKey).toLowerCase(); + if ( + (SENSITIVE_OBJECT_KEYS.has(decoded) || SENSITIVE_QUERY_KEYS.includes(decoded as never)) && + rawVal !== REDACTED + ) { + url.searchParams.set(rawKey, REDACTED); + } + } + + // Redact fragment — strip leading `#`, prepend `?` so QUERY_PARAM_REGEX + // matches the first param (lookbehind requires `?`, `&`, or `#`). + if (url.hash) { + const bare = url.hash.slice(1); // remove leading '#' + const fakeQuery = `?${bare}`; + const redacted = redactString(fakeQuery); + url.hash = redacted.slice(1); // put back without the fake '?' + } + + return url.toString(); + } catch { + return redactString(u); + } +} + +/** + * Shallow-redact a plain object. Returns a new object; original is not mutated. + * Arrays are recursed so token arrays (e.g. `{tokens:[{access_token:'AT'}]}`) + * do not bypass redaction. + */ +export function redactJsonShallow(input: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (SENSITIVE_OBJECT_KEYS.has(key.toLowerCase())) { + out[key] = REDACTED; + } else if (typeof value === 'string') { + out[key] = redactString(value); + } else if (Array.isArray(value)) { + out[key] = value.map((item) => + item && typeof item === 'object' && !Array.isArray(item) + ? redactJsonShallow(item as Record) + : item + ); + } else if (value && typeof value === 'object') { + out[key] = redactJsonShallow(value as Record); + } else { + out[key] = value; + } + } + return out; +} + +/** Redact an Authorization header value. */ +export function redactBearer(header: string): string { + return header.replace(BEARER_REGEX, `Bearer ${REDACTED}`); +} + +export const REDACTED_PLACEHOLDER = REDACTED; diff --git a/src/cliproxy/auth/oauth-trace/sink-file.ts b/src/cliproxy/auth/oauth-trace/sink-file.ts new file mode 100644 index 00000000..1e3e5e39 --- /dev/null +++ b/src/cliproxy/auth/oauth-trace/sink-file.ts @@ -0,0 +1,84 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { OAuthTraceEvent, OAuthTraceSink } from './trace-events'; + +/** + * Append-mode JSONL file sink. Off by default; enabled when callers pass an instance. + * + * - File path: `${dir}/oauth-YYYYMMDD.log` (date from `now()` per write call). + * - Permissions: dir 0o700, file 0o600 (user-only). World-readable would leak machine info. + * - Failure-tolerant: if write fails (disk full, perm denied), logs once to stderr and + * keeps dropping events silently — sink must never throw out of `write()`. + */ +export interface FileSinkOptions { + dir: string; + /** Test seam — defaults to `() => new Date()`. */ + now?: () => Date; + /** Test seam — error notifier (defaults to one-shot stderr write). */ + onError?: (msg: string) => void; +} + +export function createFileSink(options: FileSinkOptions): OAuthTraceSink { + const now = options.now ?? (() => new Date()); + let warned = false; + const onError = + options.onError ?? + ((msg: string) => { + if (!warned) { + warned = true; + process.stderr.write(`[oauth-trace] file sink disabled: ${msg}\n`); + } + }); + + let cachedDate: string | null = null; + + function ensureDir(): void { + fs.mkdirSync(options.dir, { recursive: true, mode: 0o700 }); + } + + function dateStr(d: Date): string { + const yyyy = d.getFullYear().toString().padStart(4, '0'); + const mm = (d.getMonth() + 1).toString().padStart(2, '0'); + const dd = d.getDate().toString().padStart(2, '0'); + return `${yyyy}${mm}${dd}`; + } + + function pathForToday(): string { + const ds = dateStr(now()); + cachedDate = ds; + return path.join(options.dir, `oauth-${ds}.log`); + } + + function appendOne(event: OAuthTraceEvent): void { + const file = pathForToday(); + const line = JSON.stringify(event) + '\n'; + let fd: number | null = null; + try { + ensureDir(); + fd = fs.openSync(file, 'a', 0o600); + fs.writeSync(fd, line); + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // ignore + } + } + } + } + + return { + write(event) { + try { + appendOne(event); + } catch (err) { + onError((err as Error).message); + } + }, + async flush() { + // appendSync paths are flushed per-write; no buffer to drain. + void cachedDate; + }, + }; +} diff --git a/src/cliproxy/auth/oauth-trace/sink-memory.ts b/src/cliproxy/auth/oauth-trace/sink-memory.ts new file mode 100644 index 00000000..6a98b773 --- /dev/null +++ b/src/cliproxy/auth/oauth-trace/sink-memory.ts @@ -0,0 +1,31 @@ +import { OAuthTraceEvent, OAuthTraceSink } from './trace-events'; + +/** Default ring-buffer capacity. Keeps latest N events; oldest are dropped. */ +export const MEMORY_SINK_MAX_EVENTS = 1000; + +/** + * In-memory ring buffer sink. Used for diagnose-failure analysis after a flow ends. + * Caps at MAX_EVENTS to bound memory; oldest are dropped when full. + * `droppedCount` tracks how many events were discarded so callers know data loss occurred. + */ +export function createMemorySink( + maxEvents = MEMORY_SINK_MAX_EVENTS +): OAuthTraceSink & { snapshot(): OAuthTraceEvent[]; droppedCount(): number } { + const events: OAuthTraceEvent[] = []; + let dropped = 0; + return { + write(event) { + if (events.length >= maxEvents) { + events.shift(); + dropped++; + } + events.push(event); + }, + snapshot() { + return events.slice(); + }, + droppedCount() { + return dropped; + }, + }; +} diff --git a/src/cliproxy/auth/oauth-trace/sink-verbose-stdout.ts b/src/cliproxy/auth/oauth-trace/sink-verbose-stdout.ts new file mode 100644 index 00000000..b2275453 --- /dev/null +++ b/src/cliproxy/auth/oauth-trace/sink-verbose-stdout.ts @@ -0,0 +1,42 @@ +import { OAuthTraceSink } from './trace-events'; + +/** + * Verbose stdout sink. Writes one ASCII line per event when verbose=true. + * Format: `[oauth-trace] +{elapsedMs}ms {phase} {key=val ...}` + * No emojis (CCS terminal rule). Goes to stderr to avoid mingling with normal stdout. + */ +export function createVerboseStdoutSink(opts: { + enabled: boolean; + out?: (line: string) => void; +}): OAuthTraceSink { + const out = opts.out ?? ((line: string) => process.stderr.write(line + '\n')); + return { + write(event) { + if (!opts.enabled) return; + const parts: string[] = []; + parts.push(`[oauth-trace] +${event.elapsedMs}ms ${event.phase}`); + if (event.data) { + for (const [k, v] of Object.entries(event.data)) { + if (v === undefined) continue; + parts.push(`${k}=${formatValue(v)}`); + } + } + if (event.error) { + parts.push(`error_code=${event.error.code ?? 'unknown'}`); + parts.push(`error_msg="${event.error.message.replace(/"/g, "'")}"`); + } + out(parts.join(' ')); + }, + }; +} + +function formatValue(v: unknown): string { + if (v === null) return 'null'; + if (typeof v === 'string') return v.includes(' ') ? `"${v.replace(/"/g, "'")}"` : v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + try { + return JSON.stringify(v); + } catch { + return '[unserializable]'; + } +} diff --git a/src/cliproxy/auth/oauth-trace/trace-events.ts b/src/cliproxy/auth/oauth-trace/trace-events.ts new file mode 100644 index 00000000..b7671d71 --- /dev/null +++ b/src/cliproxy/auth/oauth-trace/trace-events.ts @@ -0,0 +1,63 @@ +/** + * OAuth trace event taxonomy. + * + * Single source of truth for phase IDs that flow through the OAuth pipeline. + * Additive only — never remove or renumber. + */ + +export enum OAuthTracePhase { + // Pre-flight + PreflightStart = 'preflight.start', + PreflightOk = 'preflight.ok', + PreflightPortBlocked = 'preflight.port_blocked', + + // Binary process lifecycle (CLIProxyAPI Go binary) + BinarySpawn = 'binary.spawn', + BinaryStdout = 'binary.stdout', + BinaryStderr = 'binary.stderr', + BinaryExit = 'binary.exit', + + // Browser / URL flow (Authorization Code) + AuthUrlDisplayed = 'auth.url_displayed', + BrowserOpened = 'browser.opened', + CallbackObservedHeuristic = 'callback.observed_heuristic', + + // Paste-callback (CCS-owned end-to-end) + PasteCallbackPrompted = 'paste.prompted', + PasteCallbackReceived = 'paste.received', + PasteCallbackInvalid = 'paste.invalid', + PasteCallbackSubmitted = 'paste.submitted', + + // Token exchange + persistence + TokenExchangePending = 'token.exchange_pending', + TokenFileAppeared = 'token.file_appeared', + TokenFileMissing = 'token.file_missing', + + // Provider-specific gates (Phase 4/5 extend) + ProjectSelectionPrompted = 'project.selection_prompted', + ProjectSelectionResolved = 'project.selection_resolved', + AgyResponsibilityPrompted = 'agy.responsibility_prompted', + AgyResponsibilityResolved = 'agy.responsibility_resolved', + + // Terminal states + Timeout = 'timeout', + Cancelled = 'cancelled', + Error = 'error', +} + +/** A single OAuth trace event. `data` MUST be redacted before construction. */ +export interface OAuthTraceEvent { + sessionId: string; + provider: string; + phase: OAuthTracePhase; + ts: number; // Date.now() + elapsedMs: number; // since recorder.start() + data?: Record; + error?: { code?: string; message: string }; +} + +/** Sink interface — accept events that have already been redacted. */ +export interface OAuthTraceSink { + write(event: OAuthTraceEvent): void; + flush?(): Promise; +} diff --git a/src/cliproxy/auth/oauth-trace/trace-recorder.ts b/src/cliproxy/auth/oauth-trace/trace-recorder.ts new file mode 100644 index 00000000..5cf14c17 --- /dev/null +++ b/src/cliproxy/auth/oauth-trace/trace-recorder.ts @@ -0,0 +1,107 @@ +import { OAuthTraceEvent, OAuthTracePhase, OAuthTraceSink } from './trace-events'; +import { createMemorySink } from './sink-memory'; +import { createVerboseStdoutSink } from './sink-verbose-stdout'; +import { redactJsonShallow } from './redactor'; + +export interface OAuthTraceRecorder { + record( + phase: OAuthTracePhase, + data?: Record, + error?: { code?: string; message: string } | Error + ): void; + snapshot(): OAuthTraceEvent[]; + summary(): { + totalMs: number; + phaseCounts: Record; + lastPhase?: OAuthTracePhase; + }; + flush(): Promise; +} + +export interface OAuthTraceRecorderOptions { + sessionId: string; + provider: string; + verbose: boolean; + fileSink?: OAuthTraceSink; + /** Test seam — defaults to `Date.now`. */ + now?: () => number; + /** Test seam — override verbose sink output channel. */ + verboseOut?: (line: string) => void; +} + +/** + * Create a per-attempt recorder. Always wires: + * - memory sink (read via `snapshot()`) + * - verbose stdout sink (no-op when verbose=false) + * - optional file sink (Phase 8) + * + * All event data passes through the redactor before reaching any sink. + */ +export function createOAuthTraceRecorder(options: OAuthTraceRecorderOptions): OAuthTraceRecorder { + const now = options.now ?? Date.now; + const start = now(); + const memory = createMemorySink(); + const verbose = createVerboseStdoutSink({ enabled: options.verbose, out: options.verboseOut }); + const sinks: OAuthTraceSink[] = [memory, verbose]; + if (options.fileSink) sinks.push(options.fileSink); + + const phaseCounts: Record = {}; + let lastPhase: OAuthTracePhase | undefined; + + function toErrorObj( + err: { code?: string; message: string } | Error | undefined + ): { code?: string; message: string } | undefined { + if (!err) return undefined; + if (err instanceof Error) { + return { message: err.message }; + } + return { code: err.code, message: err.message }; + } + + return { + record(phase, data, error) { + const ts = now(); + const elapsedMs = ts - start; + const redacted = data ? redactJsonShallow(data) : undefined; + const event: OAuthTraceEvent = { + sessionId: options.sessionId, + provider: options.provider, + phase, + ts, + elapsedMs, + data: redacted, + error: toErrorObj(error), + }; + phaseCounts[phase] = (phaseCounts[phase] ?? 0) + 1; + lastPhase = phase; + for (const sink of sinks) { + try { + sink.write(event); + } catch { + // Sinks must never throw out — drop on failure. + } + } + }, + snapshot() { + return memory.snapshot(); + }, + summary() { + return { + totalMs: now() - start, + phaseCounts: { ...phaseCounts }, + lastPhase, + }; + }, + async flush() { + for (const sink of sinks) { + if (sink.flush) { + try { + await sink.flush(); + } catch { + // ignore + } + } + } + }, + }; +} From 15e0e62bd884283f8076f137a3ee65a7ed321973 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 10 May 2026 18:22:17 -0400 Subject: [PATCH 28/38] ci: trigger fresh CI run after claw runner recovery From c02c66dcd116715e7ea7557a0b539920acc24038 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 10 May 2026 18:23:53 -0400 Subject: [PATCH 29/38] ci(ai-review): move PR-Agent jobs to ubuntu-latest The qodo-ai/pr-agent Docker action requires the GitHub event payload at /github/workflow/event.json inside the action container. On self-hosted runners that themselves run inside a Docker container (e.g. myoung34/github-runner), the host docker daemon resolves the volume mount /tmp/runner/work/_temp/_github_workflow:/github/workflow against the host filesystem, where the runner's /tmp path does not exist. The action container starts with an empty /github/workflow mount and fails with FileNotFoundError on event.json. AI review jobs do not need self-hosted runner access (no bun cache, no internal infra). Switch both dispatch-review and pr-agent jobs to ubuntu-latest so the volume mount resolves on the same host where the action expects it. --- .github/workflows/ai-review.yml | 4 ++-- tests/unit/scripts/github/ai-review-workflow.test.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 40e84de8..b8125720 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -37,7 +37,7 @@ jobs: dispatch-review: name: Queue /review comment if: github.event_name == 'workflow_dispatch' - runs-on: [self-hosted, cliproxy] + runs-on: ubuntu-latest permissions: issues: write @@ -86,7 +86,7 @@ jobs: ) ) ) - runs-on: [self-hosted, cliproxy] + runs-on: ubuntu-latest timeout-minutes: 20 permissions: issues: write diff --git a/tests/unit/scripts/github/ai-review-workflow.test.ts b/tests/unit/scripts/github/ai-review-workflow.test.ts index 55d3efb3..d6787594 100644 --- a/tests/unit/scripts/github/ai-review-workflow.test.ts +++ b/tests/unit/scripts/github/ai-review-workflow.test.ts @@ -7,7 +7,7 @@ function resolvePath(relativePath: string) { } describe('PR-Agent review lane migration', () => { - test('keeps ai-review.yml as the PR-Agent workflow on the self-hosted cliproxy runner', () => { + test('keeps ai-review.yml as the PR-Agent workflow on a GitHub-hosted runner', () => { const workflowPath = resolvePath('../../../../.github/workflows/ai-review.yml'); const prAgentConfigPath = resolvePath('../../../../.pr_agent.toml'); @@ -19,7 +19,14 @@ describe('PR-Agent review lane migration', () => { const appTokenUsages = workflow.match(/uses: actions\/create-github-app-token@v1/g) ?? []; expect(workflow).toContain('name: AI Code Review'); - expect(workflow).toContain('runs-on: [self-hosted, cliproxy]'); + // AI review jobs run on GitHub-hosted runners. Self-hosted runners that + // run inside a Docker container (myoung34/github-runner) cannot host + // qodo-ai/pr-agent because the action's docker run command uses a volume + // mount path (/tmp/runner/work/_temp/_github_workflow) that resolves on + // the host filesystem, not inside the runner container, leaving + // /github/workflow/event.json missing in the action. + expect(workflow).toContain('runs-on: ubuntu-latest'); + expect(workflow).not.toContain('runs-on: [self-hosted, cliproxy]'); expect(workflow).toContain('uses: qodo-ai/pr-agent'); expect(appTokenUsages).toHaveLength(2); expect(workflow).toContain('OPENAI.API_BASE'); From 43ece40c2165c6dc989d368f8bceaed3d00f3dd6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 10 May 2026 18:30:05 -0400 Subject: [PATCH 30/38] Revert "ci(ai-review): move PR-Agent jobs to ubuntu-latest" This reverts the runs-on change for ai-review.yml. All CI/CD must stay on home infra (claw, docker LXC, etc); GitHub-hosted runners are forbidden. The qodo-ai/pr-agent docker action mount issue will be addressed by either configuring the self-hosted runner image to use path-identical bind mounts so nested-docker volume sources resolve correctly, or by switching to native CLI invocation (pip install pr-agent) instead of the docker action. Tracked separately; not blocking PR #1209. --- .github/workflows/ai-review.yml | 4 ++-- tests/unit/scripts/github/ai-review-workflow.test.ts | 11 ++--------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index b8125720..40e84de8 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -37,7 +37,7 @@ jobs: dispatch-review: name: Queue /review comment if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest + runs-on: [self-hosted, cliproxy] permissions: issues: write @@ -86,7 +86,7 @@ jobs: ) ) ) - runs-on: ubuntu-latest + runs-on: [self-hosted, cliproxy] timeout-minutes: 20 permissions: issues: write diff --git a/tests/unit/scripts/github/ai-review-workflow.test.ts b/tests/unit/scripts/github/ai-review-workflow.test.ts index d6787594..55d3efb3 100644 --- a/tests/unit/scripts/github/ai-review-workflow.test.ts +++ b/tests/unit/scripts/github/ai-review-workflow.test.ts @@ -7,7 +7,7 @@ function resolvePath(relativePath: string) { } describe('PR-Agent review lane migration', () => { - test('keeps ai-review.yml as the PR-Agent workflow on a GitHub-hosted runner', () => { + test('keeps ai-review.yml as the PR-Agent workflow on the self-hosted cliproxy runner', () => { const workflowPath = resolvePath('../../../../.github/workflows/ai-review.yml'); const prAgentConfigPath = resolvePath('../../../../.pr_agent.toml'); @@ -19,14 +19,7 @@ describe('PR-Agent review lane migration', () => { const appTokenUsages = workflow.match(/uses: actions\/create-github-app-token@v1/g) ?? []; expect(workflow).toContain('name: AI Code Review'); - // AI review jobs run on GitHub-hosted runners. Self-hosted runners that - // run inside a Docker container (myoung34/github-runner) cannot host - // qodo-ai/pr-agent because the action's docker run command uses a volume - // mount path (/tmp/runner/work/_temp/_github_workflow) that resolves on - // the host filesystem, not inside the runner container, leaving - // /github/workflow/event.json missing in the action. - expect(workflow).toContain('runs-on: ubuntu-latest'); - expect(workflow).not.toContain('runs-on: [self-hosted, cliproxy]'); + expect(workflow).toContain('runs-on: [self-hosted, cliproxy]'); expect(workflow).toContain('uses: qodo-ai/pr-agent'); expect(appTokenUsages).toHaveLength(2); expect(workflow).toContain('OPENAI.API_BASE'); From 9d0690e1a86291e93ac775c9ffd06f8e0a0dc974 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 10 May 2026 20:23:02 -0400 Subject: [PATCH 31/38] test(ci): make routing mock complete and websearch trace path env-robust Two pre-existing test bugs surfaced under CCS CI on the new claw self- hosted runners but stayed hidden locally: 1. tests/unit/commands/cliproxy-routing-subcommand.test.ts mocks the routing-subcommand module with only 3 exports, but src/commands/cliproxy/index.ts statically imports 6. Bun resolves the static import graph against the mock and reports SyntaxError: Export named 'handleRoutingAffinitySet' not found for every test in the file. Add the missing 3 exports (handleRoutingAffinityStatus, handleRoutingAffinityHelp, handleRoutingAffinitySet) to the mock and document why the mock must mirror every named export of the target module. 2. tests/unit/hooks/websearch-transformer.test.ts builds the "disallowed" trace path from process.cwd() and from os.homedir(). Both fall under the os.tmpdir() safe-prefix in two real environments: CI runners with cwd == /tmp/runner/work/... and Bun test isolation that re-roots HOME under tmpdir. The hook treats them as safe, writes the trace, and the assertion that the file does NOT exist fails. Anchor disallowedTracePath under /etc/... instead so it cannot satisfy the tmpdir, /var/log, or /.ccs/logs prefixes in any host environment. Both fixes are independent of the OAuth callback traceability change that this branch otherwise carries, but ship together so the PR clears CI on the new runner stack. --- .../commands/cliproxy-routing-subcommand.test.ts | 12 ++++++++++++ tests/unit/hooks/websearch-transformer.test.ts | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/unit/commands/cliproxy-routing-subcommand.test.ts b/tests/unit/commands/cliproxy-routing-subcommand.test.ts index 40d6385c..217179bc 100644 --- a/tests/unit/commands/cliproxy-routing-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-routing-subcommand.test.ts @@ -16,6 +16,18 @@ describe('cliproxy routing command dispatch', () => { handleRoutingSet: async (args: string[]) => { calls.push(`set:${args.join(' ')}`); }, + // Mock module must expose every named export `cliproxy/index.ts` + // statically imports from this module, otherwise Bun reports + // `Export named 'X' not found` at module-graph resolution time. + handleRoutingAffinityStatus: async () => { + calls.push('affinity:status'); + }, + handleRoutingAffinityHelp: async () => { + calls.push('affinity:help'); + }, + handleRoutingAffinitySet: async (args: string[]) => { + calls.push(`affinity:set:${args.join(' ')}`); + }, })); }); diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 6aea3175..646d1530 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -618,7 +618,15 @@ global.fetch = async (url) => { 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'); + // disallowedTracePath MUST NOT start with any safe prefix (tmpdir(), + // /var/log, or /.ccs/logs). Anchoring under cwd or homedir + // is unsafe in CI/Bun-test environments where those paths can fall + // under /tmp (CI runner workspace) or under tmpdir (Bun's HOME + // isolation), which would falsely satisfy the tmpdir prefix. Use a + // root-anchored path outside every safe prefix so the hook MUST + // reject it. The hook writes via try/catch; a permission denial here + // also leaves the file absent. + const disallowedTracePath = '/etc/ccs-test-disallowed-websearch-trace-' + Date.now() + '.jsonl'; const html = ` Example title Example snippet From 4ba9df54251baa60c13f146820f06076c585aa45 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 10 May 2026 15:47:05 -0400 Subject: [PATCH 32/38] fix(cliproxy/auth): generalize Plus OAuth credential guards for gemini and agy Refactor getGeminiPlusOAuthCredentialError / getGeminiAuthUrlCredentialError behind a provider-table-driven helper (PLUS_OAUTH_ENV_BY_PROVIDER) and add getPlusOAuthCredentialError / getPlusAuthUrlCredentialError exports covering both gemini and agy. Existing gemini-named exports preserved as aliases so PR #1131 test surface remains unchanged. New AGY-parity test cases mirror the original gemini diagnostics. CLI call sites in triggerOAuth / handlePasteCallbackMode stay gemini-only; dashboard handler in the follow-up commits will use the generalized exports for both providers. Refs #1208 --- .../oauth-handler-paste-callback.test.ts | 62 +++++++++ src/cliproxy/auth/oauth-handler.ts | 123 +++++++++++++++--- 2 files changed, 166 insertions(+), 19 deletions(-) diff --git a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts index 025e03fc..33f62b38 100644 --- a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts @@ -143,6 +143,68 @@ describe('Gemini Plus OAuth credential diagnostics', () => { }); }); +describe('Antigravity Plus OAuth credential diagnostics', () => { + it('fails fast when AGY uses Plus without CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_ID/SECRET', async () => { + const { getPlusOAuthCredentialError } = await import( + `../oauth-handler?agy-plus-missing-env=${Date.now()}` + ); + + const error = getPlusOAuthCredentialError('agy', 'plus', {}); + + expect(error).toContain('Antigravity OAuth from CLIProxy Plus is missing'); + expect(error).toContain('CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_ID'); + expect(error).toContain('CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_SECRET'); + expect(error).toContain('Antigravity'); + }); + + it('allows AGY Plus when both AGY OAuth client env values exist', async () => { + const { getPlusOAuthCredentialError } = await import( + `../oauth-handler?agy-plus-env-present=${Date.now()}` + ); + + expect( + getPlusOAuthCredentialError('agy', 'plus', { + CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_ID: 'client-id', + CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_SECRET: 'client-secret', + }) + ).toBeNull(); + }); + + it('does not warn for AGY on the original backend', async () => { + const { getPlusOAuthCredentialError } = await import( + `../oauth-handler?agy-original-backend=${Date.now()}` + ); + + expect(getPlusOAuthCredentialError('agy', 'original', {})).toBeNull(); + }); + + it('detects AGY auth URLs missing client_id before display', async () => { + const { getPlusAuthUrlCredentialError } = await import( + `../oauth-handler?agy-auth-url-missing-client=${Date.now()}` + ); + + const error = getPlusAuthUrlCredentialError( + 'agy', + 'https://accounts.google.com/o/oauth2/v2/auth?client_id=&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback&state=test' + ); + + expect(error).toContain('Antigravity OAuth from CLIProxy Plus is missing'); + }); + + it('allows AGY auth URLs with client_id present', async () => { + const { getPlusAuthUrlCredentialError } = await import( + `../oauth-handler?agy-auth-url-client-present=${Date.now()}` + ); + + expect( + getPlusAuthUrlCredentialError( + 'agy', + 'https://accounts.google.com/o/oauth2/v2/auth?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback&state=test' + ) + ).toBeNull(); + }); +}); + describe('usesKiroLocalCallbackReplay', () => { it('limits local callback replay to CLI auth-code flows', async () => { const { usesKiroLocalCallbackReplay } = await import( diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index c8dd2f74..ac58e0bc 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -87,30 +87,122 @@ const GEMINI_PLUS_CLIENT_SECRET_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET'; const logger = createLogger('cliproxy:auth:oauth'); -function buildGeminiPlusOAuthCredentialMessage(missing?: string[]): string { +/** + * Table of providers that require Google OAuth client credentials when running + * against CLIProxy Plus. Keyed by CLIProxyProvider value. + * + * Used by the generalized helpers so the dashboard handler can guard any + * table-listed provider without duplicating env-var names. + */ +export const PLUS_OAUTH_ENV_BY_PROVIDER: Partial< + Record +> = { + gemini: { + idEnv: GEMINI_PLUS_CLIENT_ID_ENV, + secretEnv: GEMINI_PLUS_CLIENT_SECRET_ENV, + displayName: 'Gemini', + }, + agy: { + idEnv: 'CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_ID', + secretEnv: 'CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_SECRET', + displayName: 'Antigravity', + }, +}; + +/** + * Build a human-readable error message for a provider whose Plus OAuth client + * credentials are missing. + * + * @param displayName - Human-readable provider name (e.g. "Gemini", "Antigravity") + * @param idEnv - Name of the client-ID env var + * @param secretEnv - Name of the client-secret env var + * @param missing - Which of the two vars are absent (omit to suppress the "Missing:" prefix) + */ +function buildPlusOAuthCredentialMessage( + displayName: string, + idEnv: string, + secretEnv: string, + missing?: string[] +): string { const missingText = missing?.length ? ` Missing: ${missing.join(', ')}.` : ''; return ( - 'Gemini OAuth from CLIProxy Plus is missing Google OAuth client credentials.' + + `${displayName} OAuth from CLIProxy Plus is missing Google OAuth client credentials.` + missingText + - ` Set ${GEMINI_PLUS_CLIENT_ID_ENV} and ${GEMINI_PLUS_CLIENT_SECRET_ENV} before starting CLIProxy Plus,` + - ' or switch `cliproxy.backend` to `original` for Gemini.' + ` Set ${idEnv} and ${secretEnv} before starting CLIProxy Plus,` + + ` or switch \`cliproxy.backend\` to \`original\` for ${displayName}.` ); } +/** + * Generalized credential-missing guard for any provider in PLUS_OAUTH_ENV_BY_PROVIDER. + * + * Returns null when: + * - provider is not in the table (not a Plus-credentialed provider) + * - backend is not 'plus' + * - both credential env vars are set and non-empty + * + * Returns an error string when Plus is active and one or both vars are missing. + */ +export function getPlusOAuthCredentialError( + provider: CLIProxyProvider, + backend: CLIProxyBackend, + env: NodeJS.ProcessEnv = process.env +): string | null { + const entry = PLUS_OAUTH_ENV_BY_PROVIDER[provider]; + if (!entry || backend !== 'plus') { + return null; + } + + const missing = [entry.idEnv, entry.secretEnv].filter((name) => !env[name]?.trim()); + return missing.length > 0 + ? buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv, missing) + : null; +} + +/** + * Generalized auth-URL guard for any provider in PLUS_OAUTH_ENV_BY_PROVIDER. + * + * Returns null when: + * - provider is not in the table + * - authUrl cannot be parsed as a URL (ignore malformed upstream responses) + * - client_id query param is present and non-empty + * + * Returns an error string when client_id is absent or empty. + */ +export function getPlusAuthUrlCredentialError( + provider: CLIProxyProvider, + authUrl: string +): string | null { + const entry = PLUS_OAUTH_ENV_BY_PROVIDER[provider]; + if (!entry) { + return null; + } + + try { + const parsed = new URL(authUrl); + const clientId = parsed.searchParams.get('client_id')?.trim(); + return clientId + ? null + : buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Gemini-specific aliases — kept for backward-compat with PR #1131's callers. +// These lock the provider to 'gemini' and delegate to the generalized helpers. +// --------------------------------------------------------------------------- + export function getGeminiPlusOAuthCredentialError( provider: CLIProxyProvider, backend: CLIProxyBackend, env: NodeJS.ProcessEnv = process.env ): string | null { - if (provider !== 'gemini' || backend !== 'plus') { + if (provider !== 'gemini') { return null; } - - const missing = [GEMINI_PLUS_CLIENT_ID_ENV, GEMINI_PLUS_CLIENT_SECRET_ENV].filter( - (name) => !env[name]?.trim() - ); - - return missing.length > 0 ? buildGeminiPlusOAuthCredentialMessage(missing) : null; + return getPlusOAuthCredentialError(provider, backend, env); } export function getGeminiAuthUrlCredentialError( @@ -120,14 +212,7 @@ export function getGeminiAuthUrlCredentialError( if (provider !== 'gemini') { return null; } - - try { - const parsed = new URL(authUrl); - const clientId = parsed.searchParams.get('client_id')?.trim(); - return clientId ? null : buildGeminiPlusOAuthCredentialMessage(); - } catch { - return null; - } + return getPlusAuthUrlCredentialError(provider, authUrl); } export async function requestPasteCallbackStart( From 31076dff3e494645e8177e19aed64961d073b206 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 10 May 2026 15:47:22 -0400 Subject: [PATCH 33/38] fix(web-server): gate Gemini/AGY dashboard OAuth on Plus credential availability The dashboard Add Account flow hits POST /api/cliproxy/auth/:provider/start-url which fetches the OAuth consent URL directly from the CLIProxy Plus management API. With cliproxy.backend=plus and unset CLIPROXY_{GEMINI,ANTIGRAVITY}_OAUTH_CLIENT_ID/SECRET, Plus returns a URL with empty client_id= and Google rejects with 400 invalid_request. PR #1131 only guarded the CLI /start path; /start-url was unguarded. - Pre-fetch guard via getPlusOAuthCredentialError -> 400 plus_oauth_credentials_missing before contacting the Plus binary. - Post-fetch guard via getPlusAuthUrlCredentialError -> 502 plus_oauth_url_missing_client_id if Plus still emits a URL without client_id (logged with query string redacted). - New integration test (cliproxy-auth-routes-oauth-guard.test.ts) covers gemini/agy guard firing, non-table providers passing through, and both error body contracts. 15 cases pass. Closes #1208 --- .../cliproxy-auth-routes-oauth-guard.test.ts | 291 ++++++++++++++++++ src/web-server/routes/cliproxy-auth-routes.ts | 42 +++ 2 files changed, 333 insertions(+) create mode 100644 src/web-server/routes/__tests__/cliproxy-auth-routes-oauth-guard.test.ts diff --git a/src/web-server/routes/__tests__/cliproxy-auth-routes-oauth-guard.test.ts b/src/web-server/routes/__tests__/cliproxy-auth-routes-oauth-guard.test.ts new file mode 100644 index 00000000..a9db07c1 --- /dev/null +++ b/src/web-server/routes/__tests__/cliproxy-auth-routes-oauth-guard.test.ts @@ -0,0 +1,291 @@ +/** + * Integration tests for the OAuth credential guard wired into the + * /:provider/start-url route (Phase 3 + Phase 4). + * + * These tests verify the guard functions that are called inline by the route + * handler, using the same pattern as oauth-handler-paste-callback.test.ts. + * Dynamic imports with cache-busting query strings prevent module-cache + * interference between test cases. + * + * Test isolation: guard functions under test only read process.env and + * their CLIProxyProvider/CLIProxyBackend arguments — no disk access, + * no real ~/.ccs reads required. + */ + +import { afterEach, describe, expect, it } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Restore any env vars mutated during tests +// --------------------------------------------------------------------------- +const GEMINI_ID_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_ID'; +const GEMINI_SECRET_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET'; +const AGY_ID_ENV = 'CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_ID'; +const AGY_SECRET_ENV = 'CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_SECRET'; + +function unsetGeminiEnv(): void { + delete process.env[GEMINI_ID_ENV]; + delete process.env[GEMINI_SECRET_ENV]; +} + +function setGeminiEnv(): void { + process.env[GEMINI_ID_ENV] = 'test-client-id'; + process.env[GEMINI_SECRET_ENV] = 'test-client-secret'; +} + +function unsetAgyEnv(): void { + delete process.env[AGY_ID_ENV]; + delete process.env[AGY_SECRET_ENV]; +} + +function setAgyEnv(): void { + process.env[AGY_ID_ENV] = 'test-agy-client-id'; + process.env[AGY_SECRET_ENV] = 'test-agy-client-secret'; +} + +afterEach(() => { + // Clean up any env vars set in tests + delete process.env[GEMINI_ID_ENV]; + delete process.env[GEMINI_SECRET_ENV]; + delete process.env[AGY_ID_ENV]; + delete process.env[AGY_SECRET_ENV]; +}); + +// --------------------------------------------------------------------------- +// Phase 3: pre-fetch credential guard (getPlusOAuthCredentialError) +// The route calls this before making any fetch to the Plus binary. +// --------------------------------------------------------------------------- + +describe('start-url route: Phase 3 pre-fetch credential guard', () => { + it('fires for gemini on plus backend when both env vars are missing', async () => { + unsetGeminiEnv(); + + const { getPlusOAuthCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-guard-gemini-missing-${Date.now()}` + ); + + const error = getPlusOAuthCredentialError('gemini', 'plus'); + + // Guard must return a non-null string (route returns 400 with this as message) + expect(error).not.toBeNull(); + expect(typeof error).toBe('string'); + expect(error).toContain('Gemini OAuth from CLIProxy Plus is missing'); + expect(error).toContain(GEMINI_ID_ENV); + expect(error).toContain(GEMINI_SECRET_ENV); + // Message must tell user how to fix (set env vars or switch backend) + expect(error).toContain('original'); + }); + + it('fires for gemini on plus backend when only client ID is missing', async () => { + delete process.env[GEMINI_ID_ENV]; + process.env[GEMINI_SECRET_ENV] = 'has-secret'; + + const { getPlusOAuthCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-guard-gemini-id-only-${Date.now()}` + ); + + const error = getPlusOAuthCredentialError('gemini', 'plus'); + expect(error).not.toBeNull(); + // Missing var should be listed + expect(error).toContain(GEMINI_ID_ENV); + delete process.env[GEMINI_SECRET_ENV]; + }); + + it('fires for agy on plus backend when both env vars are missing', async () => { + unsetAgyEnv(); + + const { getPlusOAuthCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-guard-agy-missing-${Date.now()}` + ); + + const error = getPlusOAuthCredentialError('agy', 'plus'); + + expect(error).not.toBeNull(); + expect(typeof error).toBe('string'); + expect(error).toContain('Antigravity OAuth from CLIProxy Plus is missing'); + expect(error).toContain(AGY_ID_ENV); + expect(error).toContain(AGY_SECRET_ENV); + }); + + it('returns null for gemini on plus when both env vars are present (guard does not fire)', async () => { + setGeminiEnv(); + + const { getPlusOAuthCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-guard-gemini-ok-${Date.now()}` + ); + + expect(getPlusOAuthCredentialError('gemini', 'plus')).toBeNull(); + }); + + it('returns null for agy on plus when both env vars are present (guard does not fire)', async () => { + setAgyEnv(); + + const { getPlusOAuthCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-guard-agy-ok-${Date.now()}` + ); + + expect(getPlusOAuthCredentialError('agy', 'plus')).toBeNull(); + }); + + it('returns null for ghcp provider on plus backend (not in guard table)', async () => { + // ghcp is NOT in PLUS_OAUTH_ENV_BY_PROVIDER — guard must not fire + const { getPlusOAuthCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-guard-ghcp-${Date.now()}` + ); + + expect(getPlusOAuthCredentialError('ghcp', 'plus')).toBeNull(); + }); + + it('returns null for gemini when backend is original (guard only applies to plus)', async () => { + unsetGeminiEnv(); // env vars absent, but backend is original + + const { getPlusOAuthCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-guard-gemini-original-${Date.now()}` + ); + + // original backend → guard returns null regardless of env + expect(getPlusOAuthCredentialError('gemini', 'original', {})).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 4: post-fetch auth-URL guard (getPlusAuthUrlCredentialError) +// The route calls this after fetching the authUrl from Plus, before responding. +// --------------------------------------------------------------------------- + +describe('start-url route: Phase 4 post-fetch auth-URL guard', () => { + it('fires for gemini when Plus emits auth URL with empty client_id (502 contract)', async () => { + const { getPlusAuthUrlCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-url-guard-gemini-empty-${Date.now()}` + ); + + const badUrl = + 'https://accounts.google.com/o/oauth2/v2/auth' + + '?client_id=&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback&state=abc'; + const error = getPlusAuthUrlCredentialError('gemini', badUrl); + + expect(error).not.toBeNull(); + expect(typeof error).toBe('string'); + expect(error).toContain('Gemini OAuth from CLIProxy Plus is missing'); + }); + + it('fires for agy when Plus emits auth URL with empty client_id (502 contract)', async () => { + const { getPlusAuthUrlCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-url-guard-agy-empty-${Date.now()}` + ); + + const badUrl = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=&state=abc'; + const error = getPlusAuthUrlCredentialError('agy', badUrl); + + expect(error).not.toBeNull(); + expect(error).toContain('Antigravity OAuth from CLIProxy Plus is missing'); + }); + + it('returns null for gemini when client_id is present (guard must not fire)', async () => { + const { getPlusAuthUrlCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-url-guard-gemini-ok-${Date.now()}` + ); + + const goodUrl = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=real-id&state=abc'; + expect(getPlusAuthUrlCredentialError('gemini', goodUrl)).toBeNull(); + }); + + it('returns null for ghcp (not in guard table) even with empty client_id', async () => { + const { getPlusAuthUrlCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-url-guard-ghcp-${Date.now()}` + ); + + // ghcp is not in PLUS_OAUTH_ENV_BY_PROVIDER — URL guard never fires + const anyUrl = 'https://example.com/oauth?client_id=&state=abc'; + expect(getPlusAuthUrlCredentialError('ghcp', anyUrl)).toBeNull(); + }); + + it('returns null for malformed authUrl (guard must not throw)', async () => { + const { getPlusAuthUrlCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?route-url-guard-malformed-${Date.now()}` + ); + + // Guard must swallow parse errors — route should not 502 on malformed URLs + expect(getPlusAuthUrlCredentialError('gemini', 'not-a-url')).toBeNull(); + expect(getPlusAuthUrlCredentialError('gemini', '')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 3+4: HTTP response body contract +// Verifies the exact JSON shape the route would return so the UI hook can +// match on data.error and surface data.message to the user. +// --------------------------------------------------------------------------- + +describe('start-url route: response body contract', () => { + it('credential-missing 400 body shape: error=plus_oauth_credentials_missing, message=string, provider=string', async () => { + unsetGeminiEnv(); + + const { getPlusOAuthCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?body-shape-missing-${Date.now()}` + ); + + const message = getPlusOAuthCredentialError('gemini', 'plus'); + + // Replicate what the route handler does when credentialError is non-null + const body = { + error: 'plus_oauth_credentials_missing' as const, + provider: 'gemini' as const, + message, + }; + + expect(body.error).toBe('plus_oauth_credentials_missing'); + expect(typeof body.message).toBe('string'); + // Human-readable message must be meaningful + expect((body.message ?? '').length).toBeGreaterThan(10); + expect(body.provider).toBe('gemini'); + }); + + it('auth-url 502 body shape: error=plus_oauth_url_missing_client_id, message=string, provider=string', async () => { + const { getPlusAuthUrlCredentialError } = await import( + `../../../cliproxy/auth/oauth-handler?body-shape-url-${Date.now()}` + ); + + const badUrl = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=&state=abc'; + const message = getPlusAuthUrlCredentialError('gemini', badUrl); + + // Replicate what the route handler does when authUrlError is non-null + const body = { + error: 'plus_oauth_url_missing_client_id' as const, + provider: 'gemini' as const, + message, + }; + + expect(body.error).toBe('plus_oauth_url_missing_client_id'); + expect(typeof body.message).toBe('string'); + expect((body.message ?? '').length).toBeGreaterThan(10); + expect(body.provider).toBe('gemini'); + }); + + it('UI hook can distinguish credential errors by data.error code', () => { + // The UI hook checks: data.error === 'plus_oauth_credentials_missing' + // or data.error === 'plus_oauth_url_missing_client_id' to decide + // whether to use data.message instead of data.error as the displayed text. + const missingCreds = { error: 'plus_oauth_credentials_missing', message: 'Friendly message' }; + const missingUrl = { + error: 'plus_oauth_url_missing_client_id', + message: 'Friendly URL message', + }; + const generic = { error: 'some_other_error' }; + + function simulateHookErrorResolution(data: Record): string { + const isPlusCredentialError = + data.error === 'plus_oauth_credentials_missing' || + data.error === 'plus_oauth_url_missing_client_id'; + return isPlusCredentialError && typeof data.message === 'string' + ? data.message + : typeof data.error === 'string' + ? data.error + : 'Unknown error'; + } + + expect(simulateHookErrorResolution(missingCreds)).toBe('Friendly message'); + expect(simulateHookErrorResolution(missingUrl)).toBe('Friendly URL message'); + // Generic errors still use data.error (the code) + expect(simulateHookErrorResolution(generic)).toBe('some_other_error'); + }); +}); diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index a1547ce7..90834888 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -70,6 +70,11 @@ import { import { createRouteErrorHelpers } from './route-helpers'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; +import { + getPlusOAuthCredentialError, + getPlusAuthUrlCredentialError, +} from '../../cliproxy/auth/oauth-handler'; +import { getStoredConfiguredBackend } from '../../cliproxy/binary-manager'; const router = Router(); const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000; @@ -1042,6 +1047,24 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + // Phase 3: Pre-fetch credential guard for Plus-backend OAuth providers (gemini, agy). + // Returns null for providers not in the table or when backend is not 'plus'. + const credentialError = getPlusOAuthCredentialError( + provider as CLIProxyProvider, + getStoredConfiguredBackend() + ); + if (credentialError) { + console.error( + `[cliproxy-auth-routes] start-url credential guard fired for provider=${provider}: ${credentialError}` + ); + res.status(400).json({ + error: 'plus_oauth_credentials_missing', + provider, + message: credentialError, + }); + return; + } + try { const authUrlProvider = CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; @@ -1078,6 +1101,25 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise method?: string; }; const authUrl = data.url || data.auth_url; + + // Phase 4: Post-fetch auth-URL guard — detect Plus emitting an OAuth URL with empty client_id. + // Only fires for table-listed providers (gemini, agy); returns null for all others. + if (authUrl) { + const authUrlError = getPlusAuthUrlCredentialError(provider as CLIProxyProvider, authUrl); + if (authUrlError) { + const redactedUrl = authUrl.split('?')[0]; + console.error( + `[cliproxy-auth-routes] Plus emitted OAuth URL without client_id for provider=${provider} url=${redactedUrl}` + ); + res.status(502).json({ + error: 'plus_oauth_url_missing_client_id', + provider, + message: authUrlError, + }); + return; + } + } + const oauthState = data.state || parseAuthUrlState(authUrl); // Some upstream flows return state first and provide auth_url in subsequent status polling. From 497bca8684d77cd0242eddea6162b2986b976f80 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 10 May 2026 15:48:09 -0400 Subject: [PATCH 34/38] fix(ui): surface Plus OAuth credential diagnostics in Add Account dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When POST /api/cliproxy/auth/:provider/start-url returns 400 plus_oauth_credentials_missing or 502 plus_oauth_url_missing_client_id, surface the server-provided human-readable message (env var names and backend=original switch hint) instead of the raw error code. Error propagates through the existing toast/inline-alert path in add-account-dialog.tsx — no new component primitives. Refs #1208 --- ui/src/hooks/use-cliproxy-auth-flow.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index b9dca50d..bba9788a 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -371,8 +371,17 @@ export function useCliproxyAuthFlow() { const success = data.success === true; if (!response.ok || !success) { + // For Plus OAuth credential errors the server sends a human-readable + // explanation in `data.message`; prefer it over the machine error code. + const isPlusCredentialError = + data.error === 'plus_oauth_credentials_missing' || + data.error === 'plus_oauth_url_missing_client_id'; const errorMsg = - typeof data.error === 'string' ? data.error : t('toasts.providerStartOAuthFailed'); + isPlusCredentialError && typeof data.message === 'string' + ? data.message + : typeof data.error === 'string' + ? data.error + : t('toasts.providerStartOAuthFailed'); throw new Error(errorMsg); } From f52b919d73754633932732b4fc9fd44cb1b94644 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 May 2026 01:57:27 +0000 Subject: [PATCH 35/38] chore(release): 7.77.1-dev.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1486ddce..1b9893b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1-dev.10", + "version": "7.77.1-dev.11", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 119f7da980e0cf609b95f01a184235024808521b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 May 2026 02:08:09 +0000 Subject: [PATCH 36/38] chore(release): 7.77.1-dev.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1b9893b0..bbd92e06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1-dev.11", + "version": "7.77.1-dev.12", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From a983332016b8f0572cceb8575516ff37178c5c9b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 10 May 2026 22:15:33 -0400 Subject: [PATCH 37/38] refactor(web-server): modularize shared-routes and Windows-aware symlink status --- src/web-server/shared-routes-collections.ts | 192 ++++ src/web-server/shared-routes-content.ts | 157 +++ .../shared-routes-markdown-walker.ts | 90 ++ src/web-server/shared-routes-markdown.ts | 163 +++ src/web-server/shared-routes-path-guards.ts | 77 ++ .../shared-routes-plugin-registry-content.ts | 103 ++ src/web-server/shared-routes-plugins.ts | 199 ++++ .../shared-routes-symlink-status.ts | 144 +++ src/web-server/shared-routes-types.ts | 32 + src/web-server/shared-routes.ts | 947 +----------------- .../shared-routes-symlink-status.test.ts | 170 ++++ 11 files changed, 1348 insertions(+), 926 deletions(-) create mode 100644 src/web-server/shared-routes-collections.ts create mode 100644 src/web-server/shared-routes-content.ts create mode 100644 src/web-server/shared-routes-markdown-walker.ts create mode 100644 src/web-server/shared-routes-markdown.ts create mode 100644 src/web-server/shared-routes-path-guards.ts create mode 100644 src/web-server/shared-routes-plugin-registry-content.ts create mode 100644 src/web-server/shared-routes-plugins.ts create mode 100644 src/web-server/shared-routes-symlink-status.ts create mode 100644 src/web-server/shared-routes-types.ts create mode 100644 tests/unit/web-server/shared-routes-symlink-status.test.ts diff --git a/src/web-server/shared-routes-collections.ts b/src/web-server/shared-routes-collections.ts new file mode 100644 index 00000000..fa73a423 --- /dev/null +++ b/src/web-server/shared-routes-collections.ts @@ -0,0 +1,192 @@ +/** + * Shared Routes — Collection item list builders + * + * Builds SharedItem arrays for commands, skills, and agents with + * a short-lived in-memory cache keyed by collection type. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { getCcsDir } from '../config/config-loader-facade'; +import { safeRealPath, isPathWithinAny, resolveAllowedRoots } from './shared-routes-path-guards'; +import { + readMarkdownDescription, + readFirstMarkdownDescription, + collectMarkdownFiles, +} from './shared-routes-markdown'; +import { getPluginItems } from './shared-routes-plugins'; +import type { + SharedItem, + SharedItemsCacheEntry, + SharedCollectionType, +} from './shared-routes-types'; + +const SHARED_ITEMS_CACHE_TTL_MS = 1000; + +const sharedItemsCache = new Map(); + +// --------------------------------------------------------------------------- +// Per-type item builders +// --------------------------------------------------------------------------- + +export function getCommandItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots); + const items: SharedItem[] = []; + + for (const markdownFile of markdownFiles) { + const description = readMarkdownDescription(markdownFile.resolvedPath, allowedRoots); + if (!description) { + continue; + } + + const relativePath = path.relative(sharedDir, markdownFile.displayPath); + const normalizedName = relativePath.split(path.sep).join('/').replace(/\.md$/i, ''); + if (!normalizedName) { + continue; + } + + items.push({ + name: normalizedName, + description, + path: markdownFile.displayPath, + type: 'command', + }); + } + + return items.sort((a, b) => a.name.localeCompare(b.name)); +} + +export function getSkillOrAgentItem( + type: 'skills' | 'agents', + entry: fs.Dirent, + entryPath: string, + resolvedEntryPath: string, + allowedRoots: Set, + stats: fs.Stats +): SharedItem | null { + if (type === 'skills') { + if (!stats.isDirectory()) { + return null; + } + const description = readMarkdownDescription( + path.join(resolvedEntryPath, 'SKILL.md'), + allowedRoots + ); + if (!description) { + return null; + } + return { name: entry.name, description, path: entryPath, type: 'skill' }; + } + + // agents + if (stats.isDirectory()) { + const description = readFirstMarkdownDescription( + [ + path.join(resolvedEntryPath, 'prompt.md'), + path.join(resolvedEntryPath, 'AGENT.md'), + path.join(resolvedEntryPath, 'agent.md'), + ], + allowedRoots + ); + if (!description) { + return null; + } + return { name: entry.name, description, path: entryPath, type: 'agent' }; + } + + if (!stats.isFile() || !entry.name.toLowerCase().endsWith('.md')) { + return null; + } + + const description = readMarkdownDescription(resolvedEntryPath, allowedRoots); + if (!description) { + return null; + } + return { name: entry.name.replace(/\.md$/i, ''), description, path: entryPath, type: 'agent' }; +} + +// --------------------------------------------------------------------------- +// Public cache-backed entry point +// --------------------------------------------------------------------------- + +export function getSharedItems(type: SharedCollectionType): SharedItem[] { + const ccsDir = getCcsDir(); + const sharedDir = path.join(ccsDir, 'shared', type); + const now = Date.now(); + + if (!fs.existsSync(sharedDir)) { + sharedItemsCache.delete(type); + return []; + } + + const cached = sharedItemsCache.get(type); + if (cached && cached.sharedDir === sharedDir && cached.expiresAt > now) { + return cached.items; + } + + const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); + const allowedRoots = resolveAllowedRoots(type, ccsDir, sharedDirRoot); + + if (type === 'commands') { + const commandItems = getCommandItems(sharedDir, allowedRoots); + sharedItemsCache.set(type, { + items: commandItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return commandItems; + } + + if (type === 'plugins') { + const pluginItems = getPluginItems(sharedDir, allowedRoots); + sharedItemsCache.set(type, { + items: pluginItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return pluginItems; + } + + // skills | agents + const items: SharedItem[] = []; + try { + const entries = fs.readdirSync(sharedDir, { withFileTypes: true }); + + for (const entry of entries) { + try { + const entryPath = path.join(sharedDir, entry.name); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { + continue; + } + + const stats = fs.statSync(resolvedEntryPath); + const item = getSkillOrAgentItem( + type, + entry, + entryPath, + resolvedEntryPath, + allowedRoots, + stats + ); + if (!item) { + continue; + } + items.push(item); + } catch { + // Fail soft per entry so one bad item does not hide valid results. + } + } + } catch { + // Directory read failed — return empty list + } + + const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name)); + sharedItemsCache.set(type, { + items: sortedItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return sortedItems; +} diff --git a/src/web-server/shared-routes-content.ts b/src/web-server/shared-routes-content.ts new file mode 100644 index 00000000..705497b9 --- /dev/null +++ b/src/web-server/shared-routes-content.ts @@ -0,0 +1,157 @@ +/** + * Shared Routes — Item content readers + * + * Resolves the full markdown content for a given shared item path, + * routing by collection type and enforcing allowed-root checks. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards'; +import { + readMarkdownContent, + resolveReadableMarkdownPath, + MAX_CONTENT_FILE_BYTES, +} from './shared-routes-markdown'; +import { isPluginInfrastructurePath } from './shared-routes-plugins'; +import { getPluginRegistryContent } from './shared-routes-plugin-registry-content'; +import type { SharedContentType } from './shared-routes-types'; + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +export function getSharedSettingsContent( + itemPath: string, + allowedRoots: Set +): { content: string; contentPath: string } | null { + if (path.basename(itemPath) !== 'settings.json') { + return null; + } + + const sharedRoot = Array.from(allowedRoots)[0]; + if (!sharedRoot) { + return null; + } + + const settingsPath = path.join(sharedRoot, 'settings.json'); + const resolvedSettingsPath = safeRealPath(settingsPath); + if (!resolvedSettingsPath || !isPathWithinAny(resolvedSettingsPath, allowedRoots)) { + return null; + } + + let stats: fs.Stats; + try { + stats = fs.statSync(resolvedSettingsPath); + } catch { + return null; + } + + if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) { + return null; + } + + try { + const rawContent = fs.readFileSync(resolvedSettingsPath, 'utf8'); + const parsed = JSON.parse(rawContent) as unknown; + return { + content: JSON.stringify(parsed, null, 2), + contentPath: resolvedSettingsPath, + }; + } catch { + const content = readMarkdownContent(resolvedSettingsPath, allowedRoots); + return content ? { content, contentPath: resolvedSettingsPath } : null; + } +} + +// --------------------------------------------------------------------------- +// Generic item content +// --------------------------------------------------------------------------- + +export function getSharedItemContent( + type: SharedContentType, + itemPath: string, + allowedRoots: Set, + sharedDir: string +): { content: string; contentPath: string } | null { + if (type === 'settings') { + return getSharedSettingsContent(itemPath, allowedRoots); + } + + if (type === 'plugins') { + const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots); + if (pluginRegistryContent) { + return pluginRegistryContent; + } + } + + const resolvedItemPath = safeRealPath(itemPath); + if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) { + return null; + } + + let itemStats: fs.Stats; + try { + itemStats = fs.statSync(resolvedItemPath); + } catch { + return null; + } + + let markdownPath: string | null = null; + + if (type === 'commands') { + if (!itemStats.isFile() || !itemPath.toLowerCase().endsWith('.md')) { + return null; + } + markdownPath = resolvedItemPath; + } else if (type === 'skills') { + if (!itemStats.isDirectory()) { + return null; + } + markdownPath = resolveReadableMarkdownPath( + [path.join(resolvedItemPath, 'SKILL.md')], + allowedRoots + ); + } else if (type === 'plugins') { + if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) { + return null; + } + markdownPath = resolveReadableMarkdownPath( + [ + path.join(resolvedItemPath, 'README.md'), + path.join(resolvedItemPath, 'readme.md'), + path.join(resolvedItemPath, 'PLUGIN.md'), + ], + allowedRoots + ); + if (!markdownPath) { + return null; + } + } else { + // agents + if (itemStats.isDirectory()) { + markdownPath = resolveReadableMarkdownPath( + [ + path.join(resolvedItemPath, 'prompt.md'), + path.join(resolvedItemPath, 'AGENT.md'), + path.join(resolvedItemPath, 'agent.md'), + ], + allowedRoots + ); + } else if (itemStats.isFile() && itemPath.toLowerCase().endsWith('.md')) { + markdownPath = resolvedItemPath; + } + } + + if (!markdownPath) { + return null; + } + + const content = readMarkdownContent(markdownPath, allowedRoots); + if (!content) { + return null; + } + + return { content, contentPath: markdownPath }; +} diff --git a/src/web-server/shared-routes-markdown-walker.ts b/src/web-server/shared-routes-markdown-walker.ts new file mode 100644 index 00000000..3ba1e545 --- /dev/null +++ b/src/web-server/shared-routes-markdown-walker.ts @@ -0,0 +1,90 @@ +/** + * Shared Routes — Markdown directory walker + * + * Iterative BFS/DFS over a shared directory collecting all .md files + * within allowed roots and the configured traversal depth limit. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards'; + +const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10; + +export interface MarkdownFileEntry { + displayPath: string; + resolvedPath: string; +} + +export function collectMarkdownFiles( + sharedDir: string, + allowedRoots: Set +): MarkdownFileEntry[] { + const directoriesToVisit: Array<{ path: string; depth: number }> = [ + { path: sharedDir, depth: 0 }, + ]; + const visitedDirectories = new Set(); + const markdownFiles: MarkdownFileEntry[] = []; + + while (directoriesToVisit.length > 0) { + const current = directoriesToVisit.pop(); + if (!current) { + continue; + } + + const currentDir = current.path; + const resolvedCurrentDir = safeRealPath(currentDir); + if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) { + continue; + } + + const normalizedDirPath = + process.platform === 'win32' + ? path.resolve(resolvedCurrentDir).toLowerCase() + : path.resolve(resolvedCurrentDir); + + if (visitedDirectories.has(normalizedDirPath)) { + continue; + } + visitedDirectories.add(normalizedDirPath); + + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { + continue; + } + + let stats: fs.Stats; + try { + stats = fs.statSync(resolvedEntryPath); + } catch { + continue; + } + + if (stats.isDirectory()) { + if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) { + directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 }); + } + continue; + } + + if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) { + markdownFiles.push({ + displayPath: entryPath, + resolvedPath: resolvedEntryPath, + }); + } + } + } + + return markdownFiles; +} diff --git a/src/web-server/shared-routes-markdown.ts b/src/web-server/shared-routes-markdown.ts new file mode 100644 index 00000000..8887ca66 --- /dev/null +++ b/src/web-server/shared-routes-markdown.ts @@ -0,0 +1,163 @@ +/** + * Shared Routes — Markdown file reading and description extraction + * + * Handles YAML frontmatter parsing, body-line extraction, and safe + * bounded reads for both description snippets and full content. + * + * Directory walking is in shared-routes-markdown-walker.ts. + */ + +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; + +import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards'; + +const MAX_DESCRIPTION_LENGTH = 140; +const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB + +/** Exported for content module which uses a larger limit. */ +export const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB + +// Re-export walker types so callers only need one import for markdown concerns. +export type { MarkdownFileEntry } from './shared-routes-markdown-walker'; +export { collectMarkdownFiles } from './shared-routes-markdown-walker'; + +// --------------------------------------------------------------------------- +// Description extraction helpers +// --------------------------------------------------------------------------- + +function isDescriptionBodyLine(line: string): boolean { + if (!line) { + return false; + } + if (line === '---' || line === '...') { + return false; + } + return !line.startsWith('#') && !line.startsWith('