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/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index f8c83fc5..af5f8b52 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. @@ -26,8 +26,18 @@ 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 +- `accounts..shared_resource_mode`: controls plugins/commands/skills/agents/settings.json sharing -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 +49,23 @@ 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, shared resource mode, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane. + +Non-bare account profiles share Claude-local resources with native Claude: + +```text +~/.ccs/instances//settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json +``` + +This keeps ordinary Claude settings, plugins, commands, skills, and agents in sync without copying account tokens. Existing accounts can opt out or back in: + +```bash +ccs auth resources work --mode profile-local +ccs auth resources work --mode shared +``` + +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 `-` @@ -64,6 +91,17 @@ Dashboard accounts context editing: - rejects CLIProxy OAuth account keys for this route - applies normalization/validation rules above +Shared resource editing: + +- `PUT /api/accounts/:name/shared-resources` updates `shared_resource_mode` for existing auth accounts +- accepts only `shared` or `profile-local` +- rejects CLIProxy OAuth account keys for this route +- 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`. +- 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 ### `ccs config auth setup` 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 2521db5c..3d7bf32f 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-09 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -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,10 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-05-09**: **#1199** Existing Claude auth accounts now have dashboard-visible Shared Resources controls separate from History Sync. Accounts exposes a dedicated Resources action for `shared` vs `profile-local`, `/shared` now inventories commands, skills, agents, plugins, and `settings.json`, plugin directories without docs show factual directory contents, and shared settings content is inspectable read-only through the localhost-gated shared-content API. +- **2026-05-07**: **#760** Codex GPT fast mode is now a first-class CLIProxy model tuning suffix. CCS accepts `gpt-5.4-fast`, `gpt-5.4-high-fast`, and equivalent canonicalized forms in raw env configs, CLI variant creation, and the dashboard model picker; runtime requests now send the base upstream model with `reasoning.effort` plus `service_tier: "priority"` instead of leaking the suffixed alias to CLIProxy upstream routing. +- **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. - **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. @@ -213,7 +217,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) --- @@ -224,7 +228,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/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index 551f6b42..f3aa0966 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,48 @@ 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. + +Shared Resources are separate from History Sync. By default, non-bare account profiles inherit Claude-local resources from native Claude: + +```text +~/.ccs/instances//settings.json + -> ~/.ccs/shared/settings.json + -> ~/.claude/settings.json +``` + +This covers ordinary Claude Code `settings.json`, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show ` reports `Resources`, `Settings`, `History`, and `Plain ccs` lanes so users can see whether shared resources and resume history are aligned. + +For existing accounts, change Shared Resources from the CLI: + +```bash +ccs auth resources work --mode profile-local +ccs auth resources work --mode shared +``` + +- `shared`: link plugins, commands, skills, agents, and `settings.json` from the shared Claude resource layout. +- `profile-local`: detach those shared resources for the account. This is the existing `--bare` behavior exposed as an existing-account setting. + +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 History Sync, 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. History Sync does not control plugins or `settings.json`; use `ccs auth resources` for that. + ## 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. @@ -27,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" @@ -34,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`) @@ -93,23 +137,49 @@ 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 +History Sync: + - Open `ccs config` - Go to `Accounts` - Click the pencil icon (`Edit History Sync`) - Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity +Shared Resources: + +```bash +ccs auth resources work --mode profile-local +ccs auth resources work --mode shared +``` + +Dashboard: + +- Open `ccs config` +- Go to `Accounts` +- Use `Resources` to switch an existing account between `shared` and `profile-local` +- Go to `Shared Resources` to inspect the shared commands, skills, agents, plugins, and `settings.json` hub + No account recreation required for this workflow. ### Backup Before Changing Sync @@ -130,6 +200,7 @@ ccs auth backup default - Shared context is local filesystem sharing. It does not bypass remote provider permission models. - Session continuity still depends on what the upstream tool/provider stores and allows. - Context sharing should only be enabled for accounts you intentionally trust to share workspace history. +- Shared Resources inspection is read-only in the dashboard. Editing individual files still belongs to the owning command, skill, plugin, or settings surface. ## Alternative: CLIProxy Claude Pool diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 2eb12953..3a6f8d41 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. @@ -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,10 +40,15 @@ 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 | | + | +---> 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 @@ -68,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 ``` @@ -80,7 +85,8 @@ 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 @@ -293,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) | | @@ -385,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. @@ -585,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/package.json b/package.json index e362e0dd..0eee5da1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.1", + "version": "7.77.1-dev.13", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", 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..7556094e 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -24,6 +24,7 @@ import { handleBackup, handleList, handleShow, + handleResources, handleRemove, handleDefault, handleResetDefault, @@ -74,6 +75,9 @@ class AuthCommands { ); console.log(` ${color('list', 'command')} List all saved profiles`); console.log(` ${color('show ', 'command')} Show profile details`); + console.log( + ` ${color('resources ', 'command')} Show or change shared resource mode` + ); console.log(` ${color('remove ', 'command')} Remove saved profile`); console.log(` ${color('default ', 'command')} Set default profile`); console.log( @@ -81,23 +85,34 @@ 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')}`); console.log(` ${color('ccs auth create sandbox --bare', 'command')}`); console.log(''); + console.log(` ${dim('# Change shared resources for an existing account')}`); + console.log(` ${color('ccs auth resources work --mode profile-local', 'command')}`); + console.log(` ${color('ccs auth resources work --mode shared', 'command')}`); + console.log(''); console.log(` ${dim('# Set work as default')}`); console.log(` ${color('ccs auth default work', 'command')}`); console.log(''); @@ -113,9 +128,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)` @@ -132,6 +144,9 @@ class AuthCommands { console.log( ` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)` ); + console.log( + ` ${color('--mode ', 'command')} Shared resource mode for resources: shared|profile-local` + ); console.log( ` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)` ); @@ -147,16 +162,32 @@ 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( + ` Shared Resources control plugins/commands/skills/agents/settings.json; History Sync controls project/session continuity only.` + ); + 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( - ` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.` + ` 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 history sync: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.` + ); + console.log( + ` Existing shared resources: use ${color('ccs auth resources --mode shared|profile-local', 'command')}.` ); console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`); console.log( @@ -190,6 +221,10 @@ class AuthCommands { return handleShow(this.getContext(), args); } + async handleResources(args: string[]): Promise { + return handleResources(this.getContext(), args); + } + /** * Remove profile - delegates to remove-command.ts */ @@ -207,8 +242,8 @@ class AuthCommands { /** * Reset default profile - delegates to default-command.ts */ - async handleResetDefault(): Promise { - return handleResetDefault(this.getContext()); + async handleResetDefault(args: string[] = []): Promise { + return handleResetDefault(this.getContext(), args); } /** @@ -249,6 +284,10 @@ class AuthCommands { await this.handleShow(commandArgs); break; + case 'resources': + await this.handleResources(commandArgs); + break; + case 'remove': await this.handleRemove(commandArgs); break; @@ -258,7 +297,7 @@ class AuthCommands { break; case 'reset-default': - await this.handleResetDefault(); + await this.handleResetDefault(commandArgs); break; case 'current': diff --git a/src/auth/commands/backup-command.ts b/src/auth/commands/backup-command.ts index 4a682367..f6fe167d 100644 --- a/src/auth/commands/backup-command.ts +++ b/src/auth/commands/backup-command.ts @@ -10,7 +10,8 @@ import { resolveRuntimePlainCcsResumeLane, } from '../resume-lane-diagnostics'; import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context'; -import { CommandContext, parseArgs } from './types'; +import { isProfileLocalSharedResourceMode } from '../shared-resource-policy'; +import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types'; interface BackupManifest { target: string; @@ -36,7 +37,11 @@ function copyDirectoryIfPresent(sourcePath: string, targetPath: string): boolean export async function handleBackup(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, json } = parseArgs(args); + const parsed = parseArgs(args); + const { profileName, json } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth backup [--json]', + }); if (!profileName) { console.log(fail('Profile name is required')); @@ -69,7 +74,7 @@ export async function handleBackup(ctx: CommandContext, args: string[]): Promise isAccountContextMetadata(profile) ? profile : undefined ); sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, { - bare: profile.bare === true, + bare: isProfileLocalSharedResourceMode(profile), }); artifactNames = getContinuityArtifactNames('account'); } diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index f071e001..b0cd4aad 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -21,9 +21,13 @@ import { isValidAccountProfileName, resolveAccountContextPolicy, } from '../account-context'; +import { + isProfileLocalSharedResourceMode, + sharedResourceModeToMetadata, +} from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { CommandContext, parseArgs } from './types'; +import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types'; import { stripAmbientProviderCredentials } from './create-command-env'; import { isUnifiedMode } from '../../config/config-loader-facade'; @@ -36,20 +40,13 @@ function sanitizeProfileNameForInstance(name: string): string { */ export async function handleCreate(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, force, shareContext, contextGroup, deeperContinuity, bare, unknownFlags } = - parseArgs(args); + const parsed = parseArgs(args); + const { profileName, force, shareContext, contextGroup, deeperContinuity, bare } = parsed; - if (unknownFlags && unknownFlags.length > 0) { - const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', '); - console.log(fail(`Unknown option(s): ${unknownList}`)); - console.log(''); - console.log( - `Usage: ${color('ccs auth create [--force] [--bare] [--share-context] [--context-group ] [--deeper-continuity]', 'command')}` - ); - console.log(`Help: ${color('ccs auth --help', 'command')}`); - console.log(''); - exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR); - } + rejectUnsupportedAuthOptions(parsed, { + usage: + 'ccs auth create [--force] [--bare] [--share-context] [--context-group ] [--deeper-continuity]', + }); if (!profileName) { console.log(fail('Profile name is required')); @@ -117,8 +114,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise ? ctx.registry.getAllAccountsUnified()[profileName] : undefined; const previousBare = - previousLegacyProfile?.bare === true || previousUnifiedProfile?.bare === true; + isProfileLocalSharedResourceMode(previousLegacyProfile) || + isProfileLocalSharedResourceMode(previousUnifiedProfile); const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare); + const resourceMetadata = effectiveBare ? sharedResourceModeToMetadata('profile-local') : {}; const previousContextPolicy = profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile) ? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile) @@ -200,13 +199,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise ctx.registry.updateAccountUnified(profileName, { context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); ctx.registry.touchAccountUnified(profileName); } else { ctx.registry.createAccountUnified(profileName, { ...contextMetadata, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); } } else { @@ -216,14 +215,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise type: 'account', context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); } else { ctx.registry.createProfile(profileName, { type: 'account', context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); } } @@ -280,7 +279,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` + + `Resources: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude'}` + (effectiveBare ? '\nMode: bare (no shared symlinks)' : ''), 'Profile Created' ) @@ -289,6 +290,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/default-command.ts b/src/auth/commands/default-command.ts index 2800f931..8836aee1 100644 --- a/src/auth/commands/default-command.ts +++ b/src/auth/commands/default-command.ts @@ -8,7 +8,7 @@ import { initUI, color, dim, ok, fail } from '../../utils/ui'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { CommandContext, parseArgs } from './types'; +import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types'; import { isUnifiedMode } from '../../config/config-loader-facade'; /** @@ -16,7 +16,11 @@ import { isUnifiedMode } from '../../config/config-loader-facade'; */ export async function handleDefault(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName } = parseArgs(args); + const parsed = parseArgs(args); + const { profileName } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth default ', + }); if (!profileName) { console.log(fail('Profile name is required')); @@ -48,8 +52,12 @@ export async function handleDefault(ctx: CommandContext, args: string[]): Promis /** * Handle the reset-default command (clear the custom default) */ -export async function handleResetDefault(ctx: CommandContext): Promise { +export async function handleResetDefault(ctx: CommandContext, args: string[] = []): Promise { await initUI(); + const parsed = parseArgs(args); + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth reset-default', + }); try { // Use unified or legacy based on config mode diff --git a/src/auth/commands/index.ts b/src/auth/commands/index.ts index 81d55688..e21869d5 100644 --- a/src/auth/commands/index.ts +++ b/src/auth/commands/index.ts @@ -19,5 +19,6 @@ export { handleCreate } from './create-command'; export { handleBackup } from './backup-command'; export { handleList } from './list-command'; export { handleShow } from './show-command'; +export { handleResources } from './resources-command'; export { handleRemove } from './remove-command'; export { handleDefault, handleResetDefault } from './default-command'; diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts index f25ab770..88646d0d 100644 --- a/src/auth/commands/list-command.ts +++ b/src/auth/commands/list-command.ts @@ -7,16 +7,27 @@ import { ProfileMetadata } from '../../types'; import { initUI, header, color, dim, warn, table } from '../../utils/ui'; import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context'; +import { resolveSharedResourcePolicy } from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types'; +import { + CommandContext, + ListOutput, + parseArgs, + formatRelativeTime, + rejectUnsupportedAuthOptions, +} from './types'; /** * Handle the list command */ export async function handleList(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { verbose, json } = parseArgs(args); + const parsed = parseArgs(args); + const { verbose, json } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth list [--verbose] [--json]', + }); try { // Get profiles from both legacy (profiles.json) and unified config (config.yaml) @@ -33,6 +44,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise { const profile = profiles[name]; const contextPolicy = resolveAccountContextPolicy(profile); + const resourcePolicy = resolveSharedResourcePolicy(profile); const isDefault = name === defaultProfile; const instancePath = ctx.instanceMgr.getInstancePath(name); @@ -58,6 +72,9 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, yes } = parseArgs(args); + const parsed = parseArgs(args); + const { profileName, yes } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth remove [--yes]', + }); if (!profileName) { console.log(fail('Profile name is required')); diff --git a/src/auth/commands/resources-command.ts b/src/auth/commands/resources-command.ts new file mode 100644 index 00000000..cf6666c3 --- /dev/null +++ b/src/auth/commands/resources-command.ts @@ -0,0 +1,154 @@ +import { initUI, header, color, fail, ok, table } from '../../utils/ui'; +import { resolveAccountContextPolicy } from '../account-context'; +import { + isSharedResourceMode, + resolveSharedResourcePolicy, + sharedResourceModeToMetadata, + type SharedResourceMode, +} from '../shared-resource-policy'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types'; + +function formatMode(mode: SharedResourceMode): string { + return mode === 'profile-local' ? 'profile-local' : 'shared'; +} + +function modeDescription(mode: SharedResourceMode): string { + return mode === 'profile-local' + ? 'profile-local resources; plugins/settings/commands/skills/agents are not linked from ~/.claude' + : 'shared resources from ~/.claude; plugins/settings/commands/skills/agents are linked into the account'; +} + +export async function handleResources(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const parsed = parseArgs(args, { allowMode: true }); + const { profileName, mode, json } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth resources [--mode shared|profile-local] [--json]', + allowMode: true, + }); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log( + `Usage: ${color('ccs auth resources [--mode shared|profile-local] [--json]', 'command')}` + ); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + const profiles = ctx.registry.getAllProfilesMerged(); + const currentProfile = profiles[profileName]; + if (!currentProfile || currentProfile.type !== 'account') { + exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); + } + + const currentPolicy = resolveSharedResourcePolicy(currentProfile); + + if (mode === '') { + exitWithError( + 'Missing value for --mode: expected shared|profile-local', + ExitCode.PROFILE_ERROR + ); + } + + if (mode !== undefined && !isSharedResourceMode(mode)) { + exitWithError( + 'Invalid shared resource mode: expected shared|profile-local', + ExitCode.PROFILE_ERROR + ); + } + + if (mode === undefined) { + if (json) { + console.log( + JSON.stringify( + { + name: profileName, + shared_resource_mode: currentPolicy.mode, + shared_resource_inferred: currentPolicy.inferred, + bare: currentPolicy.profileLocal ? true : undefined, + }, + null, + 2 + ) + ); + return; + } + + console.log(header(`Shared Resources: ${profileName}`)); + console.log(''); + console.log( + table( + [ + ['Mode', formatMode(currentPolicy.mode)], + ['Effective', modeDescription(currentPolicy.mode)], + ], + { colWidths: [14, 70] } + ) + ); + console.log(''); + return; + } + + const existsUnified = ctx.registry.hasAccountUnified(profileName); + const existsLegacy = ctx.registry.hasProfile(profileName); + const previousUnified = existsUnified + ? ctx.registry.getAllAccountsUnified()[profileName] + : undefined; + const previousLegacy = existsLegacy ? ctx.registry.getProfile(profileName) : undefined; + const contextPolicy = resolveAccountContextPolicy(currentProfile); + const selectedMode: SharedResourceMode = mode; + const metadata = sharedResourceModeToMetadata(selectedMode); + + try { + if (existsUnified) { + ctx.registry.updateAccountUnified(profileName, metadata); + } + if (existsLegacy) { + ctx.registry.updateProfile(profileName, metadata); + } + + await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, { + bare: selectedMode === 'profile-local', + }); + } catch (error) { + if (existsUnified && previousUnified) { + ctx.registry.updateAccountUnified(profileName, { + ...previousUnified, + shared_resource_mode: previousUnified.shared_resource_mode, + bare: previousUnified.bare, + }); + } + if (existsLegacy && previousLegacy) { + ctx.registry.updateProfile(profileName, { + ...previousLegacy, + shared_resource_mode: previousLegacy.shared_resource_mode, + bare: previousLegacy.bare, + }); + } + throw error; + } + + if (json) { + console.log( + JSON.stringify( + { + name: profileName, + shared_resource_mode: selectedMode, + shared_resource_inferred: false, + bare: selectedMode === 'profile-local' ? true : undefined, + }, + null, + 2 + ) + ); + return; + } + + console.log(ok(`Shared resources for "${profileName}" set to ${formatMode(selectedMode)}`)); + console.log(''); + console.log(modeDescription(selectedMode)); + console.log(''); +} diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts index 63aab594..37602688 100644 --- a/src/auth/commands/show-command.ts +++ b/src/auth/commands/show-command.ts @@ -8,16 +8,29 @@ 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 { resolveSharedResourcePolicy } from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { CommandContext, ProfileOutput, parseArgs } from './types'; +import { CommandContext, ProfileOutput, parseArgs, rejectUnsupportedAuthOptions } 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 */ export async function handleShow(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, json } = parseArgs(args); + const parsed = parseArgs(args); + const { profileName, json } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth show [--json]', + }); if (!profileName) { console.log(fail('Profile name is required')); @@ -37,6 +50,12 @@ 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; @@ -61,9 +80,29 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise, + options: { usage: string; allowMode?: boolean } +): void { + const unsupportedOptions = [ + ...(parsed.unknownFlags ?? []), + ...(!options.allowMode && parsed.mode !== undefined ? ['--mode'] : []), + ]; + + if (unsupportedOptions.length === 0) { + return; + } + + const unknownList = unsupportedOptions.map((flag) => `"${flag}"`).join(', '); + console.log(fail(`Unknown option(s): ${unknownList}`)); + console.log(''); + console.log(`Usage: ${color(options.usage, 'command')}`); + console.log(`Help: ${color('ccs auth --help', 'command')}`); + console.log(''); + exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR); +} + +interface ParseArgsOptions { + allowMode?: boolean; +} + /** * Parse command arguments from raw args array */ -export function parseArgs(args: string[]): AuthCommandArgs { +export function parseArgs(args: string[], options: ParseArgsOptions = {}): AuthCommandArgs { let profileName: string | undefined; let contextGroup: string | undefined; + let mode: string | undefined; const unknownFlags = new Set(); const knownBooleanFlags = new Set([ '--force', @@ -78,6 +131,9 @@ export function parseArgs(args: string[]): AuthCommandArgs { '--bare', ]); const knownValueFlags = new Set(['--context-group']); + if (options.allowMode) { + knownValueFlags.add('--mode'); + } for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -94,11 +150,28 @@ export function parseArgs(args: string[]): AuthCommandArgs { continue; } + if (options.allowMode && arg === '--mode') { + const next = args[i + 1]; + if (!next || next.startsWith('-')) { + mode = ''; + continue; + } + + mode = next; + i++; + continue; + } + if (arg.startsWith('--context-group=')) { contextGroup = arg.slice('--context-group='.length); continue; } + if (options.allowMode && arg.startsWith('--mode=')) { + mode = arg.slice('--mode='.length); + continue; + } + if (arg.startsWith('-')) { const normalizedFlag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg; const isKnownFlag = @@ -129,6 +202,7 @@ export function parseArgs(args: string[]): AuthCommandArgs { shareContext: args.includes('--share-context'), deeperContinuity: args.includes('--deeper-continuity'), bare: args.includes('--bare'), + mode, contextGroup, unknownFlags: [...unknownFlags], }; diff --git a/src/auth/profile-continuity-inheritance.ts b/src/auth/profile-continuity-inheritance.ts index 1af11922..5d6575e3 100644 --- a/src/auth/profile-continuity-inheritance.ts +++ b/src/auth/profile-continuity-inheritance.ts @@ -4,6 +4,7 @@ import { warn } from '../utils/ui'; import InstanceManager from '../management/instance-manager'; import ProfileRegistry from './profile-registry'; import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context'; +import { isProfileLocalSharedResourceMode } from './shared-resource-policy'; import type { ProfileType } from '../types/profile'; import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat'; import { @@ -155,7 +156,7 @@ export async function resolveProfileContinuityInheritance( ); const instanceMgr = new InstanceManager(); const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy, { - bare: mappedProfile.bare === true, + bare: isProfileLocalSharedResourceMode(mappedProfile), }); return { diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index f577c367..8fa35701 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -213,6 +213,7 @@ class ProfileDetector { context_mode: account.context_mode, context_group: account.context_group, continuity_mode: account.continuity_mode, + shared_resource_mode: account.shared_resource_mode, bare: account.bare, }, }; diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index 31c5482d..50f43107 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -12,6 +12,7 @@ import { loadOrCreateUnifiedConfig, mutateConfig, } from '../config/config-loader-facade'; +import { normalizeSharedResourceMetadata, type SharedResourceMode } from './shared-resource-policy'; const logger = createLogger('auth:profile-registry'); @@ -50,6 +51,7 @@ interface CreateMetadata { context_mode?: 'isolated' | 'shared'; context_group?: string; continuity_mode?: 'standard' | 'deeper'; + shared_resource_mode?: SharedResourceMode; bare?: boolean; } @@ -90,7 +92,7 @@ export class ProfileRegistry { normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard'; } - return normalized; + return normalizeSharedResourceMetadata(normalized); } private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig { @@ -110,7 +112,7 @@ export class ProfileRegistry { normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard'; } - return normalized; + return normalizeSharedResourceMetadata(normalized); } /** @@ -179,6 +181,7 @@ export class ProfileRegistry { context_mode: metadata.context_mode, context_group: metadata.context_group, continuity_mode: metadata.continuity_mode, + shared_resource_mode: metadata.shared_resource_mode, bare: metadata.bare, }); @@ -335,6 +338,7 @@ export class ProfileRegistry { context_mode: metadata.context_mode, context_group: metadata.context_group, continuity_mode: metadata.continuity_mode, + shared_resource_mode: metadata.shared_resource_mode, bare: metadata.bare, }); }); @@ -462,6 +466,7 @@ export class ProfileRegistry { context_mode: account.context_mode, context_group: account.context_group, continuity_mode: account.continuity_mode, + shared_resource_mode: account.shared_resource_mode, bare: account.bare, }; } diff --git a/src/auth/shared-resource-policy.ts b/src/auth/shared-resource-policy.ts new file mode 100644 index 00000000..a569a714 --- /dev/null +++ b/src/auth/shared-resource-policy.ts @@ -0,0 +1,97 @@ +export type SharedResourceMode = 'shared' | 'profile-local'; + +export interface SharedResourceMetadata { + shared_resource_mode?: unknown; + bare?: unknown; +} + +export interface SharedResourcePolicy { + mode: SharedResourceMode; + inferred: boolean; + profileLocal: boolean; +} + +export interface SharedResourceMetadataUpdate { + shared_resource_mode: SharedResourceMode; + bare?: boolean | undefined; +} + +export function isSharedResourceMode(value: unknown): value is SharedResourceMode { + return value === 'shared' || value === 'profile-local'; +} + +export function resolveSharedResourcePolicy( + metadata?: SharedResourceMetadata | null +): SharedResourcePolicy { + const explicitMode = metadata?.shared_resource_mode; + if (isSharedResourceMode(explicitMode)) { + return { + mode: explicitMode, + inferred: false, + profileLocal: explicitMode === 'profile-local', + }; + } + + if (metadata?.bare === true) { + return { + mode: 'profile-local', + inferred: true, + profileLocal: true, + }; + } + + return { + mode: 'shared', + inferred: true, + profileLocal: false, + }; +} + +export function isProfileLocalSharedResourceMode( + metadata?: SharedResourceMetadata | null +): boolean { + return resolveSharedResourcePolicy(metadata).profileLocal; +} + +export function sharedResourceModeToMetadata( + mode: SharedResourceMode +): SharedResourceMetadataUpdate { + if (mode === 'profile-local') { + return { + shared_resource_mode: 'profile-local', + bare: true, + }; + } + + return { + shared_resource_mode: 'shared', + bare: undefined, + }; +} + +export function normalizeSharedResourceMetadata(metadata: T): T { + const normalized = { ...metadata } as T & { + shared_resource_mode?: SharedResourceMode; + bare?: boolean; + }; + + if (normalized.shared_resource_mode === 'profile-local') { + normalized.bare = true; + return normalized; + } + + if (normalized.shared_resource_mode === 'shared') { + delete normalized.bare; + return normalized; + } + + delete normalized.shared_resource_mode; + if (normalized.bare === true) { + normalized.shared_resource_mode = 'profile-local'; + normalized.bare = true; + } else { + delete normalized.bare; + } + + return normalized; +} 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/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/ai-providers/__tests__/codex-plan-compatibility.test.ts b/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts index dd4833e0..793a6623 100644 --- a/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts +++ b/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts @@ -16,6 +16,8 @@ describe('codex plan compatibility', () => { it('maps paid-only free-plan models to safe fallbacks', () => { expect(getFreePlanFallbackCodexModel('gpt-5.5')).toBe('gpt-5.4'); expect(getFreePlanFallbackCodexModel('gpt-5.5-xhigh')).toBe('gpt-5.4'); + expect(getFreePlanFallbackCodexModel('gpt-5.5-high-fast')).toBe('gpt-5.4'); + expect(getFreePlanFallbackCodexModel('gpt-5.5-fast-high')).toBe('gpt-5.4'); expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5.4'); expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5.4'); expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5.4'); diff --git a/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts b/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts index f0cfd728..6fd5be8f 100644 --- a/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts +++ b/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts @@ -147,6 +147,60 @@ describe('CodexReasoningProxy extended-context compatibility', () => { expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high'); }); + it('translates codex fast model suffixes into service_tier', async () => { + const capturedBodies: JsonRecord[] = []; + + const upstream = http.createServer((req, res) => { + let rawBody = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + rawBody += chunk; + }); + req.on('end', () => { + capturedBodies.push(rawBody ? (JSON.parse(rawBody) as JsonRecord) : {}); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + cleanupServers.push(upstream); + + const upstreamPort = await listenOnRandomPort(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + modelMap: { + defaultModel: 'gpt-5.4', + }, + defaultEffort: 'medium', + }); + + const proxyPort = await proxy.start(); + const highFastResponse = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.4-high-fast', + messages: [], + } + ); + const fastHighResponse = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.4-fast-high', + messages: [], + } + ); + + proxy.stop(); + + expect(highFastResponse.statusCode).toBe(200); + expect(fastHighResponse.statusCode).toBe(200); + expect(capturedBodies[0]?.model).toBe('gpt-5.4'); + expect((capturedBodies[0]?.reasoning as JsonRecord | undefined)?.effort).toBe('high'); + expect(capturedBodies[0]?.service_tier).toBe('priority'); + expect(capturedBodies[1]?.model).toBe('gpt-5.4'); + expect((capturedBodies[1]?.reasoning as JsonRecord | undefined)?.effort).toBe('high'); + expect(capturedBodies[1]?.service_tier).toBe('priority'); + }); + it('skips reasoning injection when disableEffort is enabled', async () => { let capturedBody: JsonRecord | null = null; @@ -189,6 +243,49 @@ describe('CodexReasoningProxy extended-context compatibility', () => { expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined(); }); + it('keeps fast service tier when disableEffort is enabled', async () => { + let capturedBody: JsonRecord | null = null; + + const upstream = http.createServer((req, res) => { + let rawBody = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + rawBody += chunk; + }); + req.on('end', () => { + capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {}; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + cleanupServers.push(upstream); + + const upstreamPort = await listenOnRandomPort(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + modelMap: { + defaultModel: 'gpt-5.4', + }, + disableEffort: true, + }); + + const proxyPort = await proxy.start(); + const response = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.4-high-fast', + messages: [], + } + ); + + proxy.stop(); + + expect(response.statusCode).toBe(200); + expect(capturedBody?.model).toBe('gpt-5.4'); + expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined(); + expect(capturedBody?.service_tier).toBe('priority'); + }); + it('does not strip unknown model ids that merely end with "-high"', async () => { let capturedBody: JsonRecord | null = null; diff --git a/src/cliproxy/ai-providers/__tests__/model-id-normalizer.test.ts b/src/cliproxy/ai-providers/__tests__/model-id-normalizer.test.ts index 4a3dadb3..76ee7dc8 100644 --- a/src/cliproxy/ai-providers/__tests__/model-id-normalizer.test.ts +++ b/src/cliproxy/ai-providers/__tests__/model-id-normalizer.test.ts @@ -123,10 +123,17 @@ describe('model-id-normalizer', () => { expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4'); expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]'); expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high'); + expect(normalizeCodexLegacyModelAliases('gpt-5-codex-fast-high')).toBe('gpt-5.4-high-fast'); expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]'); + expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high-fast[1m]')).toBe( + 'gpt-5.4-high-fast[1m]' + ); expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2'); expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini'); expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high'); + expect(canonicalizeModelIdForProvider('gpt-5-codex-fast-high', 'codex')).toBe( + 'gpt-5.4-high-fast' + ); }); }); diff --git a/src/cliproxy/ai-providers/codex-plan-compatibility.ts b/src/cliproxy/ai-providers/codex-plan-compatibility.ts index 3205b069..7a9a0ce1 100644 --- a/src/cliproxy/ai-providers/codex-plan-compatibility.ts +++ b/src/cliproxy/ai-providers/codex-plan-compatibility.ts @@ -10,7 +10,8 @@ export type CodexPlanType = CodexQuotaResult['planType']; const FREE_SAFE_DEFAULT_MODEL = 'gpt-5.4'; const FREE_SAFE_FAST_MODEL = 'gpt-5.4-mini'; -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const CODEX_TUNING_SUFFIX_REGEX = + /(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i; const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i; const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; const KNOWN_CODEX_MODELS = new Set( @@ -57,7 +58,7 @@ export function normalizeCodexModelId(model: string): string { .trim() .replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '') .replace(CODEX_PAREN_SUFFIX_REGEX, '') - .replace(CODEX_EFFORT_SUFFIX_REGEX, '') + .replace(CODEX_TUNING_SUFFIX_REGEX, '') .trim(); return normalizeModelIdForProvider(stripped, 'codex').trim().toLowerCase(); } diff --git a/src/cliproxy/ai-providers/codex-reasoning-proxy.ts b/src/cliproxy/ai-providers/codex-reasoning-proxy.ts index f79dec22..c63852ea 100644 --- a/src/cliproxy/ai-providers/codex-reasoning-proxy.ts +++ b/src/cliproxy/ai-providers/codex-reasoning-proxy.ts @@ -9,6 +9,8 @@ import { import { getModelMaxLevel } from '../model-catalog'; export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh'; +export type CodexServiceTier = 'fast'; +type CodexServiceTierRequestValue = 'priority'; export interface CodexReasoningModelMap { opusModel?: string; @@ -41,10 +43,15 @@ interface ForwardJsonContext { requestedModel: string | null; attemptedUpstreamModel: string | null; effort: CodexReasoningEffort | null; + serviceTier: CodexServiceTier | null; retryCount: number; } const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; +const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(xhigh|high|medium|fast)$/i; +const CODEX_SERVICE_TIER_REQUEST_VALUE: Record = { + fast: 'priority', +}; function stripExtendedContextSuffix(model: string): string { return model.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '').trim(); @@ -58,16 +65,35 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function parseModelEffortSuffix( - model: string -): { upstreamModel: string; effort: CodexReasoningEffort } | null { +function parseModelTuningSuffix(model: string): { + upstreamModel: string; + effort: CodexReasoningEffort | null; + serviceTier: CodexServiceTier | null; +} | null { const normalizedModel = stripExtendedContextSuffix(model); - const match = normalizedModel.match(/^(.*)-(xhigh|high|medium)$/i); - if (!match) return null; - const upstreamModel = match[1]?.trim(); - const effort = match[2]?.toLowerCase() as CodexReasoningEffort; + let upstreamModel = normalizedModel; + let effort: CodexReasoningEffort | null = null; + let serviceTier: CodexServiceTier | null = null; + + for (let consumed = 0; consumed < 2; consumed += 1) { + const match = upstreamModel.match(CODEX_TUNING_SUFFIX_TOKEN_REGEX); + if (!match?.[1]) break; + + const token = match[1].toLowerCase(); + if (token === 'fast') { + if (serviceTier) break; + serviceTier = 'fast'; + } else { + if (effort) break; + effort = token as CodexReasoningEffort; + } + + upstreamModel = upstreamModel.slice(0, -match[0].length).trim(); + } + + if (!effort && !serviceTier) return null; if (!upstreamModel) return null; - return { upstreamModel, effort }; + return { upstreamModel, effort, serviceTier }; } function isKnownCodexModelId( @@ -154,19 +180,36 @@ export function getEffortForModel( export function injectReasoningEffortIntoBody( body: unknown, effort: CodexReasoningEffort +): unknown { + return injectCodexRequestTuningIntoBody(body, { effort, serviceTier: null }); +} + +export function injectCodexRequestTuningIntoBody( + body: unknown, + tuning: { + effort: CodexReasoningEffort | null; + serviceTier: CodexServiceTier | null; + } ): unknown { if (!isRecord(body)) return body; // OpenAI Responses API knob: reasoning: { effort: "..." } // Always override effort (user expectation). const existingReasoning = isRecord(body.reasoning) ? body.reasoning : {}; - return { - ...body, - reasoning: { + const tunedBody = { ...body }; + + if (tuning.effort) { + tunedBody.reasoning = { ...existingReasoning, - effort, - }, - }; + effort: tuning.effort, + }; + } + + if (tuning.serviceTier) { + tunedBody.service_tier = CODEX_SERVICE_TIER_REQUEST_VALUE[tuning.serviceTier]; + } + + return tunedBody; } export class CodexReasoningProxy { @@ -191,7 +234,8 @@ export class CodexReasoningProxy { at: string; model: string | null; upstreamModel: string | null; - effort: CodexReasoningEffort; + effort: CodexReasoningEffort | null; + serviceTier: CodexServiceTier | null; path: string; }> = []; private readonly counts: Record = { medium: 0, high: 0, xhigh: 0 }; @@ -226,14 +270,21 @@ export class CodexReasoningProxy { private buildForwardBody( body: unknown, upstreamModel: string | null, - effort: CodexReasoningEffort | null + tuning: { + effort: CodexReasoningEffort | null; + serviceTier: CodexServiceTier | null; + } ): unknown { const withUpstreamModel = upstreamModel && isRecord(body) ? { ...body, model: upstreamModel } : body; - if (this.config.disableEffort || !effort) { + const effort = this.config.disableEffort ? null : tuning.effort; + if (!effort && !tuning.serviceTier) { return withUpstreamModel; } - return injectReasoningEffortIntoBody(withUpstreamModel, effort); + return injectCodexRequestTuningIntoBody(withUpstreamModel, { + effort, + serviceTier: tuning.serviceTier, + }); } private sendBufferedResponse( @@ -247,14 +298,17 @@ export class CodexReasoningProxy { } /** - * Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models. + * Treat trailing "-high/-medium/-xhigh" and "-fast" as Codex tuning aliases + * only for known codex models. * Prevents stripping legitimate upstream model IDs that happen to end with those tokens. */ - private parseEffortAlias( - model: string | null - ): { upstreamModel: string; effort: CodexReasoningEffort } | null { + private parseTuningAlias(model: string | null): { + upstreamModel: string; + effort: CodexReasoningEffort | null; + serviceTier: CodexServiceTier | null; + } | null { if (!model) return null; - const parsed = parseModelEffortSuffix(model); + const parsed = parseModelTuningSuffix(model); if (!parsed) return null; if (!isKnownCodexModelId(parsed.upstreamModel, this.modelEffort)) { return null; @@ -296,11 +350,21 @@ export class CodexReasoningProxy { private record( model: string | null, upstreamModel: string | null, - effort: CodexReasoningEffort, + effort: CodexReasoningEffort | null, + serviceTier: CodexServiceTier | null, path: string ): void { - this.counts[effort] += 1; - this.recent.push({ at: new Date().toISOString(), model, upstreamModel, effort, path }); + if (effort) { + this.counts[effort] += 1; + } + this.recent.push({ + at: new Date().toISOString(), + model, + upstreamModel, + effort, + serviceTier, + path, + }); if (this.recent.length > 50) this.recent.shift(); } @@ -418,12 +482,13 @@ export class CodexReasoningProxy { ? stripExtendedContextSuffix(originalModel) : null; - // Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to: - // - upstream model: `gpt-5.2-codex` - // - reasoning.effort: `xhigh` + // Support "model aliases" like `gpt-5.4-high-fast` by translating to: + // - upstream model: `gpt-5.4` + // - reasoning.effort: `high` + // - service_tier: `priority` (Codex request value for fast mode) // - // This allows tier→effort mapping without inventing upstream model IDs. - const suffixParsed = this.parseEffortAlias(normalizedRequestModel); + // This allows tier/speed mapping without inventing upstream model IDs. + const suffixParsed = this.parseTuningAlias(normalizedRequestModel); const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel); const upstreamModel = rememberedFallback ?? requestedUpstreamModel; @@ -436,14 +501,15 @@ export class CodexReasoningProxy { : !this.config.disableEffort ? requestedEffort : null; - const rewritten = this.buildForwardBody(parsed, upstreamModel, effort); + const serviceTier = suffixParsed?.serviceTier ?? null; + const rewritten = this.buildForwardBody(parsed, upstreamModel, { effort, serviceTier }); - if (effort) { - this.record(originalModel, upstreamModel, effort, requestPath); + if (effort || serviceTier) { + this.record(originalModel, upstreamModel, effort, serviceTier, requestPath); this.trace( `[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${ upstreamModel ?? 'null' - } effort=${effort} path=${requestPath}` + } effort=${effort ?? 'null'} serviceTier=${serviceTier ?? 'null'} path=${requestPath}` ); } else { this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`); @@ -458,6 +524,7 @@ export class CodexReasoningProxy { requestedModel: requestedUpstreamModel, attemptedUpstreamModel: upstreamModel, effort, + serviceTier, retryCount: 0, }); } catch (error) { @@ -630,7 +697,10 @@ export class CodexReasoningProxy { !this.config.disableEffort && context.effort ? capEffortAtModelMax(fallbackModel, context.effort) : null; - const retryBody = this.buildForwardBody(body, fallbackModel, retryEffort); + const retryBody = this.buildForwardBody(body, fallbackModel, { + effort: retryEffort, + serviceTier: context.serviceTier, + }); this.log( `Upstream rejected model "${context.attemptedUpstreamModel}". Retrying ${context.requestPath} with "${fallbackModel}".` diff --git a/src/cliproxy/ai-providers/model-id-normalizer.ts b/src/cliproxy/ai-providers/model-id-normalizer.ts index 3c1c6ffa..9686311a 100644 --- a/src/cliproxy/ai-providers/model-id-normalizer.ts +++ b/src/cliproxy/ai-providers/model-id-normalizer.ts @@ -26,7 +26,7 @@ const DENIED_ANTIGRAVITY_OPUS_45_REGEX = const DENIED_ANTIGRAVITY_SONNET_45_REGEX = /claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi; const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking'; -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(xhigh|high|medium|fast)$/i; const CODEX_LEGACY_MODEL_ALIASES: Readonly> = Object.freeze({ 'gpt-5-codex': 'gpt-5.4', 'gpt-5-codex-mini': 'gpt-5.4-mini', @@ -65,15 +65,31 @@ function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: st }; } -function splitCodexEffortSuffix(model: string): { baseModel: string; suffix: string } { - const match = model.match(CODEX_EFFORT_SUFFIX_REGEX); - if (!match?.[0]) { - return { baseModel: model, suffix: '' }; +function splitCodexTuningSuffix(model: string): { baseModel: string; suffix: string } { + let baseModel = model; + let effort: string | null = null; + let serviceTier: 'fast' | null = null; + + for (let consumed = 0; consumed < 2; consumed += 1) { + const match = baseModel.match(CODEX_TUNING_SUFFIX_TOKEN_REGEX); + if (!match?.[1]) break; + + const token = match[1].toLowerCase(); + if (token === 'fast') { + if (serviceTier) break; + serviceTier = 'fast'; + } else { + if (effort) break; + effort = token; + } + + baseModel = baseModel.slice(0, -match[0].length); } + const suffix = [effort, serviceTier].filter(Boolean).join('-'); return { - baseModel: model.slice(0, -match[0].length), - suffix: match[0], + baseModel, + suffix: suffix ? `-${suffix}` : '', }; } @@ -109,11 +125,11 @@ export function isIFlowProvider(provider: ProviderLike): boolean { return provider.trim().toLowerCase() === 'iflow'; } -/** Strip Codex effort suffixes while preserving trailing config suffixes. */ +/** Strip Codex effort/service-tier suffixes while preserving trailing config suffixes. */ export function stripCodexEffortSuffix(model: string): string { const trimmed = trimModelId(model); const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); - const { baseModel: withoutEffort } = splitCodexEffortSuffix(baseModel); + const { baseModel: withoutEffort } = splitCodexTuningSuffix(baseModel); return `${withoutEffort}${suffix}`; } @@ -121,12 +137,12 @@ export function stripCodexEffortSuffix(model: string): string { export function normalizeCodexLegacyModelAliases(model: string): string { const trimmed = trimModelId(model); const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); - const { baseModel: baseWithoutEffort, suffix: effortSuffix } = splitCodexEffortSuffix(baseModel); + const { baseModel: baseWithoutEffort, suffix: tuningSuffix } = splitCodexTuningSuffix(baseModel); const replacement = CODEX_LEGACY_MODEL_ALIASES[baseWithoutEffort.trim().toLowerCase()]; if (!replacement) { return trimmed; } - return `${replacement}${effortSuffix}${suffix}`; + return `${replacement}${tuningSuffix}${suffix}`; } /** 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-handler-paste-callback.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts index 40d3321b..33f62b38 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,131 @@ 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('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/__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-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 3777e9a3..ac58e0bc 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,139 @@ 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'); +/** + * 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 ( + `${displayName} OAuth from CLIProxy Plus is missing Google OAuth client credentials.` + + missingText + + ` 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') { + return null; + } + return getPlusOAuthCredentialError(provider, backend, env); +} + +export function getGeminiAuthUrlCredentialError( + provider: CLIProxyProvider, + authUrl: string +): string | null { + if (provider !== 'gemini') { + return null; + } + return getPlusAuthUrlCredentialError(provider, authUrl); +} + export async function requestPasteCallbackStart( provider: CLIProxyProvider, target: ProxyTarget, @@ -549,6 +679,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 +1056,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( 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 + } + } + } + }, + }; +} 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/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index b4d545f9..8f5e043f 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -16,9 +16,15 @@ import { getThinkingConfig } from '../../config/config-loader-facade'; /** Model tier types for thinking budget defaults */ export type ModelTier = 'opus' | 'sonnet' | 'haiku'; +const CODEX_EFFORT_REGEX = /^(medium|high|xhigh)$/i; +const CODEX_FAST_TUNING_VALUE_REGEX = + /^(?:(medium|high|xhigh)-fast|fast-(medium|high|xhigh)|fast)$/i; +const CODEX_TUNING_SUFFIX_REGEX = + /(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i; + /** * Normalize model ID for provider capability lookup. - * Codex may carry effort suffixes in either legacy "(high)" or current "-high" forms. + * Codex may carry effort/service-tier suffixes in legacy "(high)" or current "-high-fast" forms. */ function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvider): string { const withoutExtendedContext = model.replace(/\[1m\]$/i, '').trim(); @@ -26,8 +32,10 @@ function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvid if (provider !== 'codex') return providerNormalized; - // New codex suffix form: gpt-5.3-codex-high -> gpt-5.3-codex - const codexSuffixMatch = providerNormalized.match(/^(.*)-(xhigh|high|medium)$/i); + // New codex suffix forms: gpt-5.4-high-fast, gpt-5.4-fast-high -> gpt-5.4 + const codexSuffixMatch = providerNormalized.match( + /^(.*?)(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i + ); if (codexSuffixMatch?.[1]) { return codexSuffixMatch[1].trim(); } @@ -82,8 +90,6 @@ function applyThinkingSuffixForProvider( thinkingValue: string | number, provider?: CLIProxyProvider ): string { - const codexEffortRegex = /^(medium|high|xhigh)$/i; - const codexModelSuffixRegex = /-(medium|high|xhigh)$/i; const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/); // Existing parenthesized suffix: @@ -93,7 +99,7 @@ function applyThinkingSuffixForProvider( if (parenthesizedSuffixMatch) { if (provider === 'codex') { const normalizedParensValue = parenthesizedSuffixMatch[1]?.trim().toLowerCase() || ''; - if (codexEffortRegex.test(normalizedParensValue)) { + if (CODEX_EFFORT_REGEX.test(normalizedParensValue)) { return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`); } } @@ -110,11 +116,11 @@ function applyThinkingSuffixForProvider( } if (provider === 'codex') { - if (codexModelSuffixRegex.test(model)) { + if (CODEX_TUNING_SUFFIX_REGEX.test(model)) { return model; } const normalized = String(thinkingValue).trim().toLowerCase(); - if (codexEffortRegex.test(normalized)) { + if (CODEX_EFFORT_REGEX.test(normalized) || CODEX_FAST_TUNING_VALUE_REGEX.test(normalized)) { return `${model}-${normalized}`; } } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 3317f96d..29f6052e 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -66,6 +66,8 @@ export interface ModelEntry { extendedContext?: boolean; /** Whether model can read image inputs natively without the Image transformer */ nativeImageInput?: boolean; + /** Additional Codex service-tier suffixes supported by this model. */ + codexServiceTiers?: Array<'fast'>; } /** @@ -200,6 +202,7 @@ export const MODEL_CATALOG: Partial> = maxLevel: 'xhigh', dynamicAllowed: false, }, + codexServiceTiers: ['fast'], }, { id: 'gpt-5.4', @@ -211,6 +214,7 @@ export const MODEL_CATALOG: Partial> = maxLevel: 'xhigh', dynamicAllowed: false, }, + codexServiceTiers: ['fast'], }, { id: 'gpt-5.4-mini', diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index bafd436a..1d020d77 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -100,7 +100,7 @@ export const PROVIDER_CAPABILITIES: Record(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/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; +} diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index aedf6001..1a538e14 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -130,6 +130,64 @@ function formatModelOption(model: ModelEntry): string { return `${model.name}${tierBadge}`; } +const CODEX_EFFORTS_IN_ORDER = ['medium', 'high', 'xhigh'] as const; +type CodexSelectableEffort = (typeof CODEX_EFFORTS_IN_ORDER)[number]; + +function getCodexSelectableEfforts(model: ModelEntry): CodexSelectableEffort[] { + const maxLevel = model.thinking?.maxLevel; + const maxIndex = CODEX_EFFORTS_IN_ORDER.findIndex((effort) => effort === maxLevel); + if (maxIndex < 0) return []; + return CODEX_EFFORTS_IN_ORDER.slice(0, maxIndex + 1); +} + +function getCodexModelOptionValues(model: ModelEntry, modelId: string): string[] { + const serviceTiers = model.codexServiceTiers ?? []; + const optionValues: string[] = [modelId]; + + for (const serviceTier of serviceTiers) { + optionValues.push(`${modelId}-${serviceTier}`); + } + + for (const effort of getCodexSelectableEfforts(model)) { + const effortModelId = `${modelId}-${effort}`; + optionValues.push(effortModelId); + for (const serviceTier of serviceTiers) { + optionValues.push(`${effortModelId}-${serviceTier}`); + } + } + + return optionValues; +} + +function getModelOptionsForProvider( + provider: CLIProxyProvider, + catalog: NonNullable>, + routing: CliproxyProviderRoutingHints | undefined +): Array<{ id: string; label: string }> { + return catalog.models.flatMap((model) => { + const modelId = getSelectableModelId(model.id, routing); + const optionValues = + provider === 'codex' ? getCodexModelOptionValues(model, modelId) : [modelId]; + return optionValues.map((optionValue) => ({ + id: optionValue, + label: + provider === 'codex' + ? `${optionValue} (${formatModelOption(model)})` + : formatModelOption(model), + })); + }); +} + +function getDefaultModelOptionIndex( + modelOptions: Array<{ id: string }>, + catalog: NonNullable>, + routing: CliproxyProviderRoutingHints | undefined +): number { + const defaultModelId = getSelectableModelId(catalog.defaultModel, routing); + const defaultIdx = modelOptions.findIndex((option) => option.id === defaultModelId); + return defaultIdx >= 0 ? defaultIdx : 0; +} + function getSelectableModelId( modelId: string, routing: CliproxyProviderRoutingHints | undefined @@ -207,13 +265,13 @@ async function selectTierConfig( const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider]; const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { - const modelOptions = catalog.models.map((m) => ({ - id: getSelectableModelId(m.id, routing), - label: formatModelOption(m), - })); - const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); + const modelOptions = getModelOptionsForProvider( + provider as CLIProxyProvider, + catalog, + routing + ); model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, { - defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, + defaultIndex: getDefaultModelOptionIndex(modelOptions, catalog, routing), }); } } @@ -468,13 +526,13 @@ export async function handleCreate( const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider]; const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { - const modelOptions = catalog.models.map((m) => ({ - id: getSelectableModelId(m.id, routing), - label: formatModelOption(m), - })); - const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); + const modelOptions = getModelOptionsForProvider( + provider as CLIProxyProvider, + catalog, + routing + ); model = await InteractivePrompt.selectFromList('Select model:', modelOptions, { - defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, + defaultIndex: getDefaultModelOptionIndex(modelOptions, catalog, routing), }); } } diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 89d3e5c7..3dbe0371 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -129,7 +129,7 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [ }, { name: 'copilot', - summary: 'Run or manage the GitHub Copilot bridge', + summary: 'Run or manage the deprecated GitHub Copilot bridge', group: 'runtime', visibility: 'public', }, @@ -198,7 +198,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO qwen: 'Qwen Code via CLIProxy OAuth', iflow: 'iFlow via CLIProxy OAuth', kiro: 'Kiro via CLIProxy OAuth', - ghcp: 'GitHub Copilot via CLIProxy OAuth', + ghcp: 'Deprecated GitHub Copilot via CLIProxy OAuth', claude: 'Claude via CLIProxy OAuth', kimi: 'Kimi via CLIProxy OAuth', cursor: 'Cursor via CLIProxy OAuth', @@ -248,6 +248,7 @@ export const AUTH_SUBCOMMANDS = [ 'backup', 'list', 'show', + 'resources', 'remove', 'default', 'reset-default', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 660503af..78dfca09 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -108,6 +108,13 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']); if (subcommand === 'show') return completeSubcommands(getProfileNames('accounts'), ['--json']); + if (subcommand === 'resources') + return completeSubcommands(getProfileNames('accounts'), [ + '--mode', + '--mode=shared', + '--mode=profile-local', + '--json', + ]); if (subcommand === 'remove') return completeSubcommands(getProfileNames('accounts'), ['--yes', '-y']); if (subcommand === 'default') return completeSubcommands(getProfileNames('accounts')); diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts index 4483f045..d469c2d1 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -73,7 +73,13 @@ function printCopilotWarnings(messages: string[]): void { * Show help for copilot commands. */ function handleHelp(): number { - console.log('GitHub Copilot Integration'); + console.log('GitHub Copilot Integration (deprecated)'); + console.log(''); + console.log( + warn( + 'Deprecated: GitHub usage-based Copilot billing begins June 1, 2026. Existing local setups remain available for compatibility; prefer Codex or another active provider for new work.' + ) + ); console.log(''); console.log('Usage: ccs copilot '); console.log(''); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 20fd265a..4dd736fb 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -54,6 +54,7 @@ async function showProfilesHelp(writeLine: HelpWriter): Promise { 'Profile Types', [ { name: 'ccs auth create ', summary: 'Concurrent Claude account profile' }, + { name: 'ccs auth resources ', summary: 'Shared resources for an account profile' }, { name: 'ccs api create', summary: 'API-backed settings profile' }, { name: 'ccs cliproxy create ', summary: 'Named CLIProxy variant profile' }, { name: 'ccs env ', summary: 'Export an existing profile for other tools' }, @@ -290,7 +291,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/commands/sync-command.ts b/src/commands/sync-command.ts index 004c5903..d24b9051 100644 --- a/src/commands/sync-command.ts +++ b/src/commands/sync-command.ts @@ -45,13 +45,14 @@ export async function handleSyncCommand(): Promise { const { InstanceManager } = await import('../management/instance-manager'); const instanceMgr = new InstanceManager(); const ProfileRegistry = (await import('../auth/profile-registry')).default; + const { isProfileLocalSharedResourceMode } = await import('../auth/shared-resource-policy'); const registry = new ProfileRegistry(); const allProfiles = registry.getAllProfilesMerged(); let mcpSynced = 0; for (const [name, profile] of Object.entries(allProfiles)) { - if (profile.bare) { - continue; // Skip bare profiles + if (isProfileLocalSharedResourceMode(profile)) { + continue; // Skip profile-local shared-resource profiles } if (!instanceMgr.hasInstance(name)) { 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/migration-manager.ts b/src/config/migration-manager.ts index 650cd07d..f7490f7b 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -22,6 +22,7 @@ import { createEmptyUnifiedConfig } from './unified-config-types'; import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader'; import { isValidContextGroupName, normalizeContextGroupName } from '../auth/account-context'; +import { isSharedResourceMode, resolveSharedResourcePolicy } from '../auth/shared-resource-policy'; import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; @@ -222,6 +223,16 @@ export async function migrate(dryRun = false): Promise { context_group: contextMode === 'shared' ? contextGroup : undefined, continuity_mode: contextMode === 'shared' ? continuityMode : undefined, }; + const resourcePolicy = resolveSharedResourcePolicy({ + shared_resource_mode: metadata.shared_resource_mode, + bare: metadata.bare, + }); + if (resourcePolicy.mode === 'profile-local') { + account.shared_resource_mode = 'profile-local'; + account.bare = true; + } else if (isSharedResourceMode(metadata.shared_resource_mode)) { + account.shared_resource_mode = 'shared'; + } unifiedConfig.accounts[name] = account; } migratedFiles.push('profiles.json → config.yaml.accounts'); diff --git a/src/config/schemas/auth.ts b/src/config/schemas/auth.ts index 4056ecf8..2b4eb944 100644 --- a/src/config/schemas/auth.ts +++ b/src/config/schemas/auth.ts @@ -27,6 +27,8 @@ export interface AccountConfig { context_group?: string; /** Shared continuity depth when context_mode='shared' */ continuity_mode?: 'standard' | 'deeper'; + /** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */ + shared_resource_mode?: 'shared' | 'profile-local'; /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ bare?: boolean; } 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/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/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/account-flow.ts b/src/dispatcher/flows/account-flow.ts index 6fec0073..bf67bc80 100644 --- a/src/dispatcher/flows/account-flow.ts +++ b/src/dispatcher/flows/account-flow.ts @@ -7,6 +7,7 @@ import { execClaude } from '../../utils/shell-executor'; import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning'; +import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy'; import { resolveNativeClaudeLaunchArgs } from '../environment-builder'; import type { ProfileDispatchContext } from '../dispatcher-context'; @@ -26,10 +27,11 @@ export async function runAccountFlow(ctx: ProfileDispatchContext): Promise const accountMetadata = isAccountContextMetadata(profileInfo.profile) ? profileInfo.profile : undefined; - const isBareProfile = - typeof profileInfo.profile === 'object' && - profileInfo.profile !== null && - (profileInfo.profile as { bare?: unknown }).bare === true; + const isBareProfile = isProfileLocalSharedResourceMode( + typeof profileInfo.profile === 'object' && profileInfo.profile !== null + ? (profileInfo.profile as { shared_resource_mode?: unknown; bare?: unknown }) + : undefined + ); const contextPolicy = resolveAccountContextPolicy(accountMetadata); // Ensure instance exists (lazy init if needed) 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 { + // 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/account-route-helpers.ts b/src/web-server/routes/account-route-helpers.ts index bbf05469..21f0e2e2 100644 --- a/src/web-server/routes/account-route-helpers.ts +++ b/src/web-server/routes/account-route-helpers.ts @@ -10,6 +10,9 @@ export interface MergedAccountEntry { continuity_mode?: 'standard' | 'deeper'; context_inferred?: boolean; continuity_inferred?: boolean; + shared_resource_mode?: 'shared' | 'profile-local'; + shared_resource_inferred?: boolean; + bare?: boolean; provider?: string; displayName?: string; } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index df520087..50294ae8 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -26,6 +26,11 @@ import { normalizeContextGroupName, resolveAccountContextPolicy, } from '../../auth/account-context'; +import { + isSharedResourceMode, + resolveSharedResourcePolicy, + sharedResourceModeToMetadata, +} from '../../auth/shared-resource-policy'; import { buildCliproxyAccountKey, parseCliproxyKey, @@ -79,6 +84,7 @@ router.get('/', async (_req: Request, res: Response): Promise => { // Add legacy profiles first for (const [name, meta] of Object.entries(legacyProfiles)) { const contextPolicy = resolveAccountContextPolicy(meta); + const resourcePolicy = resolveSharedResourcePolicy(meta); const hasExplicitContextMode = meta.context_mode === 'isolated' || meta.context_mode === 'shared'; const hasExplicitContinuityMode = @@ -93,6 +99,9 @@ router.get('/', async (_req: Request, res: Response): Promise => { context_inferred: !hasExplicitContextMode, continuity_inferred: contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined, + shared_resource_mode: resourcePolicy.mode, + shared_resource_inferred: resourcePolicy.inferred, + ...(resourcePolicy.profileLocal ? { bare: true } : {}), }; } @@ -100,6 +109,7 @@ router.get('/', async (_req: Request, res: Response): Promise => { for (const [name, account] of Object.entries(unifiedAccounts)) { const rawAccount = rawUnifiedAccounts[name]; const contextPolicy = resolveAccountContextPolicy(account); + const resourcePolicy = resolveSharedResourcePolicy(account); const hasExplicitContextMode = rawAccount?.context_mode === 'isolated' || rawAccount?.context_mode === 'shared'; const hasExplicitContinuityMode = @@ -114,6 +124,9 @@ router.get('/', async (_req: Request, res: Response): Promise => { context_inferred: !hasExplicitContextMode, continuity_inferred: contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined, + shared_resource_mode: resourcePolicy.mode, + shared_resource_inferred: resourcePolicy.inferred, + ...(resourcePolicy.profileLocal ? { bare: true } : {}), }; } @@ -305,7 +318,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined; const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined; - const isBare = previousUnified?.bare === true || previousLegacy?.bare === true; + const resourcePolicy = resolveSharedResourcePolicy(previousUnified ?? previousLegacy); try { if (existsUnified) { @@ -315,13 +328,21 @@ router.put('/:name/context', async (req: Request, res: Response): Promise registry.updateProfile(name, metadata); } - await instanceMgr.ensureInstance(name, policy, { bare: isBare }); + await instanceMgr.ensureInstance(name, policy, { bare: resourcePolicy.profileLocal }); } catch (error) { if (existsUnified && previousUnified) { - registry.updateAccountUnified(name, previousUnified); + registry.updateAccountUnified(name, { + ...previousUnified, + shared_resource_mode: previousUnified.shared_resource_mode, + bare: previousUnified.bare, + }); } if (existsLegacy && previousLegacy) { - registry.updateProfile(name, previousLegacy); + registry.updateProfile(name, { + ...previousLegacy, + shared_resource_mode: previousLegacy.shared_resource_mode, + bare: previousLegacy.bare, + }); } throw error; } @@ -342,6 +363,89 @@ router.put('/:name/context', async (req: Request, res: Response): Promise } }); +/** + * PUT /api/accounts/:name/shared-resources - Update account shared resource mode + */ +router.put('/:name/shared-resources', async (req: Request, res: Response): Promise => { + try { + const registry = createProfileRegistry(); + const instanceMgr = createInstanceManager(); + const { name } = req.params; + + if (!name) { + res.status(400).json({ error: 'Missing account name' }); + return; + } + + const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null; + if (cliproxyKey) { + res.status(400).json({ + error: `Shared resource mode is not supported for CLIProxy account: ${name}`, + }); + return; + } + + const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name); + const existsLegacy = registry.hasProfile(name); + if (!existsUnified && !existsLegacy) { + res.status(404).json({ error: `Account not found: ${name}` }); + return; + } + + const mode = req.body?.shared_resource_mode; + if (!isSharedResourceMode(mode)) { + res.status(400).json({ + error: 'Missing or invalid shared_resource_mode: expected shared|profile-local', + }); + return; + } + + const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined; + const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined; + const previousMetadata = previousUnified ?? previousLegacy; + const contextPolicy = resolveAccountContextPolicy(previousMetadata); + const metadata = sharedResourceModeToMetadata(mode); + + try { + if (existsUnified) { + registry.updateAccountUnified(name, metadata); + } + if (existsLegacy) { + registry.updateProfile(name, metadata); + } + + await instanceMgr.ensureInstance(name, contextPolicy, { + bare: mode === 'profile-local', + }); + } catch (error) { + if (existsUnified && previousUnified) { + registry.updateAccountUnified(name, { + ...previousUnified, + shared_resource_mode: previousUnified.shared_resource_mode, + bare: previousUnified.bare, + }); + } + if (existsLegacy && previousLegacy) { + registry.updateProfile(name, { + ...previousLegacy, + shared_resource_mode: previousLegacy.shared_resource_mode, + bare: previousLegacy.bare, + }); + } + throw error; + } + + res.json({ + name, + shared_resource_mode: mode, + shared_resource_inferred: false, + bare: mode === 'profile-local' ? true : undefined, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * DELETE /api/accounts/reset-default - Reset to CCS default */ diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 5faae932..90834888 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 { @@ -66,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; @@ -286,6 +295,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.`; } @@ -1034,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; @@ -1070,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. 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('