mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-30 12:22:19 +00:00
Merge pull request #1212 from kaitranntt/dev
feat: release 7.77.1 — shared resources, OAuth diagnostics, Codex fast tier
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.<name>.context_mode/context_group`: controls isolated vs shared account context
|
||||
- `accounts.<name>.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 <profile>` 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/<profile>/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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<text>` routes through headless delegation consistently with `--prompt <text>`.
|
||||
- **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 |
|
||||
|
||||
@@ -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/<account>/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 <account>` 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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
+52
-13
@@ -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 <profile>', 'command')} Show profile details`);
|
||||
console.log(
|
||||
` ${color('resources <profile>', 'command')} Show or change shared resource mode`
|
||||
);
|
||||
console.log(` ${color('remove <profile>', 'command')} Remove saved profile`);
|
||||
console.log(` ${color('default <profile>', '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 <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 <profile>', '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 <profile>', '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 <profile>', '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 <profile>', '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 <profile> --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<void> {
|
||||
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<void> {
|
||||
return handleResetDefault(this.getContext());
|
||||
async handleResetDefault(args: string[] = []): Promise<void> {
|
||||
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':
|
||||
|
||||
@@ -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<void> {
|
||||
await initUI();
|
||||
const { profileName, json } = parseArgs(args);
|
||||
const parsed = parseArgs(args);
|
||||
const { profileName, json } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth backup <profile|default> [--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');
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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 <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--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 <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--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` +
|
||||
|
||||
@@ -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<void> {
|
||||
await initUI();
|
||||
const { profileName } = parseArgs(args);
|
||||
const parsed = parseArgs(args);
|
||||
const { profileName } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth default <profile>',
|
||||
});
|
||||
|
||||
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<void> {
|
||||
export async function handleResetDefault(ctx: CommandContext, args: string[] = []): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseArgs(args);
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth reset-default',
|
||||
});
|
||||
|
||||
try {
|
||||
// Use unified or legacy based on config mode
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void> {
|
||||
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<v
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +59,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
profiles: profileNames.map((name) => {
|
||||
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<v
|
||||
context_mode: contextPolicy.mode,
|
||||
context_group: contextPolicy.group || null,
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||
shared_resource_mode: resourcePolicy.mode,
|
||||
shared_resource_inferred: resourcePolicy.inferred,
|
||||
...(resourcePolicy.profileLocal ? { bare: true } : {}),
|
||||
instance_path: instancePath,
|
||||
};
|
||||
}),
|
||||
@@ -107,6 +124,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
const profile = profiles[name];
|
||||
const isDefault = name === defaultProfile;
|
||||
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||
const resourcePolicy = resolveSharedResourcePolicy(profile);
|
||||
|
||||
// Status column
|
||||
const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success');
|
||||
@@ -122,6 +140,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
if (verbose) {
|
||||
row.push(lastUsed);
|
||||
row.push(formatAccountContextPolicy(contextPolicy));
|
||||
row.push(resourcePolicy.mode);
|
||||
}
|
||||
|
||||
return row;
|
||||
@@ -129,14 +148,14 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
|
||||
// Headers
|
||||
const headers = verbose
|
||||
? ['Profile', 'Type', 'Status', 'Last Used', 'Context']
|
||||
? ['Profile', 'Type', 'Status', 'Last Used', 'Context', 'Resources']
|
||||
: ['Profile', 'Type', 'Status'];
|
||||
|
||||
// Print table
|
||||
console.log(
|
||||
table(rows, {
|
||||
head: headers,
|
||||
colWidths: verbose ? [15, 12, 15, 12, 34] : [15, 12, 15],
|
||||
colWidths: verbose ? [15, 12, 15, 12, 34, 16] : [15, 12, 15],
|
||||
})
|
||||
);
|
||||
console.log('');
|
||||
|
||||
@@ -11,7 +11,7 @@ import { InteractivePrompt } from '../../utils/prompt';
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -19,7 +19,11 @@ import { isUnifiedMode } from '../../config/config-loader-facade';
|
||||
*/
|
||||
export async function handleRemove(ctx: CommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const { profileName, yes } = parseArgs(args);
|
||||
const parsed = parseArgs(args);
|
||||
const { profileName, yes } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth remove <profile> [--yes]',
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
|
||||
@@ -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<void> {
|
||||
await initUI();
|
||||
const parsed = parseArgs(args, { allowMode: true });
|
||||
const { profileName, mode, json } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth resources <profile> [--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 <profile> [--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('');
|
||||
}
|
||||
@@ -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<typeof summarizeAccountHistory>): 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<void> {
|
||||
await initUI();
|
||||
const { profileName, json } = parseArgs(args);
|
||||
const parsed = parseArgs(args);
|
||||
const { profileName, json } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth show <profile> [--json]',
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
@@ -37,6 +50,12 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
const isDefault = profileName === defaultProfile;
|
||||
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
|
||||
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||
const resourcePolicy = resolveSharedResourcePolicy(profile);
|
||||
const settingsSync = describeSettingsSync(instancePath, { bare: resourcePolicy.profileLocal });
|
||||
const historySummary = summarizeAccountHistory(instancePath, contextPolicy);
|
||||
const plainCcsLane = await resolveConfiguredPlainCcsResumeLane().catch(() => 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<v
|
||||
context_mode: contextPolicy.mode,
|
||||
context_group: contextPolicy.group || null,
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||
shared_resource_mode: resourcePolicy.mode,
|
||||
shared_resource_inferred: resourcePolicy.inferred,
|
||||
instance_path: instancePath,
|
||||
session_count: sessionCount,
|
||||
...(profile.bare ? { bare: true } : {}),
|
||||
settings_sync: {
|
||||
state: settingsSync.state,
|
||||
profile_settings_path: settingsSync.profile_settings_path,
|
||||
shared_settings_path: settingsSync.shared_settings_path,
|
||||
root_settings_path: settingsSync.root_settings_path,
|
||||
},
|
||||
history: historySummary,
|
||||
...(plainCcsLane
|
||||
? {
|
||||
plain_ccs_lane: {
|
||||
kind: plainCcsLane.kind,
|
||||
label: plainCcsLane.label,
|
||||
config_dir: plainCcsLane.configDir,
|
||||
project_count: plainCcsLane.projectCount,
|
||||
uses_this_account: plainCcsUsesThisAccount,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(resourcePolicy.profileLocal ? { bare: true } : {}),
|
||||
};
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
@@ -81,7 +120,21 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
['Created', new Date(profile.created).toLocaleString()],
|
||||
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
|
||||
['Context', formatAccountContextPolicy(contextPolicy)],
|
||||
...(profile.bare ? [['Bare', 'yes (no shared symlinks)']] : []),
|
||||
['Resources', resourcePolicy.mode],
|
||||
['Credentials', 'isolated per account'],
|
||||
['Settings', settingsSync.description],
|
||||
['History', formatHistorySummary(historySummary)],
|
||||
...(plainCcsLane
|
||||
? [
|
||||
[
|
||||
'Plain ccs',
|
||||
plainCcsUsesThisAccount
|
||||
? `uses this account lane (${plainCcsLane.projectCount} project(s))`
|
||||
: `${plainCcsLane.label} (${plainCcsLane.projectCount} project(s))`,
|
||||
],
|
||||
]
|
||||
: []),
|
||||
...(resourcePolicy.profileLocal ? [['Bare', 'yes (no shared symlinks)']] : []),
|
||||
['Sessions', `${sessionCount}`],
|
||||
];
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
import ProfileRegistry from '../profile-registry';
|
||||
import { InstanceManager } from '../../management/instance-manager';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { color, fail } from '../../utils/ui';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { formatRelativeTime } from '../../utils/time';
|
||||
@@ -23,6 +26,7 @@ export interface AuthCommandArgs {
|
||||
contextGroup?: string;
|
||||
deeperContinuity?: boolean;
|
||||
bare?: boolean;
|
||||
mode?: string;
|
||||
unknownFlags?: string[];
|
||||
}
|
||||
|
||||
@@ -38,8 +42,30 @@ export interface ProfileOutput {
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
context_group?: string | null;
|
||||
continuity_mode?: 'standard' | 'deeper' | null;
|
||||
shared_resource_mode?: 'shared' | 'profile-local';
|
||||
shared_resource_inferred?: boolean;
|
||||
instance_path?: string;
|
||||
session_count?: number;
|
||||
settings_sync?: {
|
||||
state: 'shared' | 'profile-local' | 'missing' | 'unknown';
|
||||
profile_settings_path: string;
|
||||
shared_settings_path: string;
|
||||
root_settings_path: string;
|
||||
};
|
||||
history?: {
|
||||
project_count: number;
|
||||
session_count: number;
|
||||
projects_path: string;
|
||||
projects_shared: boolean;
|
||||
deeper_artifacts_shared: boolean;
|
||||
};
|
||||
plain_ccs_lane?: {
|
||||
kind: string;
|
||||
label: string;
|
||||
config_dir: string;
|
||||
project_count: number;
|
||||
uses_this_account: boolean;
|
||||
};
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
@@ -60,12 +86,39 @@ export interface CommandContext {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function rejectUnsupportedAuthOptions(
|
||||
parsed: Pick<AuthCommandArgs, 'mode' | 'unknownFlags'>,
|
||||
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<string>();
|
||||
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],
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<T extends SharedResourceMetadata>(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;
|
||||
}
|
||||
+1
-1
@@ -73,7 +73,7 @@ async function main(): Promise<void> {
|
||||
// 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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<CodexServiceTier, CodexServiceTierRequestValue> = {
|
||||
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<string, unknown> {
|
||||
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<CodexReasoningEffort, number> = { 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}".`
|
||||
|
||||
@@ -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<Record<string, string>> = 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
const inner = (batches[0]['tokens'] as Array<Record<string, unknown>>)[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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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']);
|
||||
});
|
||||
});
|
||||
@@ -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<CLIProxyProvider, { idEnv: string; secretEnv: string; displayName: string }>
|
||||
> = {
|
||||
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(
|
||||
|
||||
@@ -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<AccountInfo | null> {
|
||||
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<Accou
|
||||
// H7: Mutable ref for stdin keepalive interval (set later, needed in cleanup)
|
||||
let stdinKeepalive: ReturnType<typeof setInterval> | 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<Accou
|
||||
cancelManualCallbackPrompt: null,
|
||||
};
|
||||
|
||||
// OAuth trace recorder — opt-in file sink via CCS_OAUTH_LOG_FILE=1
|
||||
const fileSink =
|
||||
process.env['CCS_OAUTH_LOG_FILE'] === '1'
|
||||
? createFileSink({ dir: path.join(getCcsDir(), 'logs') })
|
||||
: undefined;
|
||||
const trace = createOAuthTraceRecorder({
|
||||
sessionId: state.sessionId,
|
||||
provider,
|
||||
verbose,
|
||||
fileSink,
|
||||
});
|
||||
traceRef = trace; // wire late-binding ref for cleanup/handleCancel
|
||||
trace.record(OAuthTracePhase.BinarySpawn, {
|
||||
callbackPort,
|
||||
headless,
|
||||
manualCallback: !!options.manualCallback,
|
||||
flowType,
|
||||
});
|
||||
|
||||
// Register session for cancellation support
|
||||
registerAuthSession(state.sessionId, provider);
|
||||
attachProcessToSession(state.sessionId, authProcess);
|
||||
@@ -732,6 +786,14 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
const handleCancel = (cancelledSessionId: string) => {
|
||||
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<Accou
|
||||
}
|
||||
|
||||
authProcess.stdout?.on('data', async (data: Buffer) => {
|
||||
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<Accou
|
||||
authSessionEvents.removeListener('session:cancelled', handleCancel);
|
||||
unregisterAuthSession(state.sessionId);
|
||||
cancelProjectSelection(state.sessionId);
|
||||
trace.record(OAuthTracePhase.Timeout, { timeoutMs });
|
||||
void trace.flush();
|
||||
killWithEscalation(authProcess);
|
||||
console.log('');
|
||||
console.log(fail(`OAuth timed out after ${timeoutMs / 60000} minutes`));
|
||||
@@ -849,6 +930,10 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
unregisterAuthSession(state.sessionId);
|
||||
cancelProjectSelection(state.sessionId);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
trace.record(OAuthTracePhase.BinaryExit, {
|
||||
code,
|
||||
stderrTail: redactString(state.stderrData.trim().split('\n').pop() ?? ''),
|
||||
});
|
||||
|
||||
if (code === 0) {
|
||||
const exitAnalysis = analyzeSuccessfulAuthExit({
|
||||
@@ -861,6 +946,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
});
|
||||
|
||||
if (exitAnalysis.tokenSnapshot) {
|
||||
trace.record(OAuthTracePhase.TokenFileAppeared, {});
|
||||
await trace.flush();
|
||||
console.log('');
|
||||
console.log(ok(`Authentication successful (${elapsed}s)`));
|
||||
|
||||
@@ -881,6 +968,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
});
|
||||
}
|
||||
|
||||
trace.record(OAuthTracePhase.TokenFileMissing, {});
|
||||
await trace.flush();
|
||||
// Try auto-import for Kiro, show error for others
|
||||
const account = await handleTokenNotFound(
|
||||
provider,
|
||||
@@ -889,7 +978,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
nickname,
|
||||
expectedAccountId,
|
||||
verbose,
|
||||
exitAnalysis.failureReason || undefined
|
||||
exitAnalysis.failureReason || undefined,
|
||||
trace
|
||||
);
|
||||
resolve(account);
|
||||
}
|
||||
@@ -918,6 +1008,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
authSessionEvents.removeListener('session:cancelled', handleCancel);
|
||||
unregisterAuthSession(state.sessionId);
|
||||
cancelProjectSelection(state.sessionId);
|
||||
trace.record(OAuthTracePhase.Error, {}, error);
|
||||
void trace.flush();
|
||||
console.log('');
|
||||
console.log(fail(`Failed to start auth process: ${error.message}`));
|
||||
resolve(null);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { OAuthTraceEvent, OAuthTracePhase } from './trace-events';
|
||||
|
||||
export type FailureBranchId =
|
||||
| 'URL_NOT_DISPLAYED'
|
||||
| 'BROWSER_NOT_OPENED'
|
||||
| 'CALLBACK_NEVER_OBSERVED'
|
||||
| 'BINARY_ERROR_EXIT'
|
||||
| 'TOKEN_FILE_MISSING_POST_EXIT'
|
||||
| 'TIMEOUT'
|
||||
| 'SESSION_CANCELLED'
|
||||
| 'TOKEN_EXCHANGE_REJECTED'
|
||||
| 'PASTE_INVALID'
|
||||
| 'GEMINI_PLUS_MISSING_CRED'
|
||||
| 'AGY_RESPONSIBILITY_DECLINED'
|
||||
| 'UNKNOWN';
|
||||
|
||||
export interface DiagnosisResult {
|
||||
branchId: FailureBranchId;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, unknown>)
|
||||
: item
|
||||
);
|
||||
} else if (value && typeof value === 'object') {
|
||||
out[key] = redactJsonShallow(value as Record<string, unknown>);
|
||||
} 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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]';
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
error?: { code?: string; message: string };
|
||||
}
|
||||
|
||||
/** Sink interface — accept events that have already been redacted. */
|
||||
export interface OAuthTraceSink {
|
||||
write(event: OAuthTraceEvent): void;
|
||||
flush?(): Promise<void>;
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
error?: { code?: string; message: string } | Error
|
||||
): void;
|
||||
snapshot(): OAuthTraceEvent[];
|
||||
summary(): {
|
||||
totalMs: number;
|
||||
phaseCounts: Record<string, number>;
|
||||
lastPhase?: OAuthTracePhase;
|
||||
};
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
|
||||
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<string, number> = {};
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
maxLevel: 'xhigh',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
codexServiceTiers: ['fast'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
@@ -211,6 +214,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
maxLevel: 'xhigh',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
codexServiceTiers: ['fast'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4-mini',
|
||||
|
||||
@@ -100,7 +100,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
||||
},
|
||||
ghcp: {
|
||||
displayName: 'GitHub Copilot (OAuth)',
|
||||
description: 'GitHub Copilot via OAuth',
|
||||
description: 'Deprecated GitHub Copilot compatibility via OAuth',
|
||||
oauthFlow: 'device_code',
|
||||
callbackPort: null,
|
||||
callbackProviderName: 'copilot',
|
||||
@@ -188,6 +188,12 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze(
|
||||
Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[]
|
||||
);
|
||||
|
||||
export const BROWSER_URL_AUTH_PROVIDER_IDS = Object.freeze([
|
||||
'cursor',
|
||||
] as const satisfies readonly CLIProxyProvider[]);
|
||||
|
||||
const BROWSER_URL_AUTH_PROVIDER_SET = new Set<CLIProxyProvider>(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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, PendingOAuthRequest>,
|
||||
byProvider: Map<string, PendingOAuthRequest[]>
|
||||
): 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<string, PendingOAuthRequest>,
|
||||
byProvider: Map<string, PendingOAuthRequest[]>
|
||||
): 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<string, PendingOAuthRequest>();
|
||||
const pendingByProvider = new Map<string, PendingOAuthRequest[]>();
|
||||
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;
|
||||
}
|
||||
@@ -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<T> {
|
||||
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<string, CliproxyUsageApiResponse>();
|
||||
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<CliproxyStats |
|
||||
export async function fetchCliproxyUsageRaw(
|
||||
port?: number
|
||||
): Promise<CliproxyUsageApiResponse | null> {
|
||||
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<CliproxyUsageApiResponse>(
|
||||
'/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<unknown>('/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<ManagementJsonResult<unknown[]> | null> {
|
||||
const records: unknown[] = [];
|
||||
const seenFullBatchSignatures = new Set<string>();
|
||||
let cacheKey = '';
|
||||
let status = 0;
|
||||
const drainStartedAt = Date.now();
|
||||
|
||||
for (let batchCount = 0; batchCount < USAGE_QUEUE_MAX_BATCHES; batchCount++) {
|
||||
const result = await fetchManagementJson<unknown[]>(
|
||||
`/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<T>(
|
||||
endpointPath: string,
|
||||
port?: number,
|
||||
timeoutMs = 5000
|
||||
): Promise<ManagementJsonResult<T> | 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<CliproxyManagementAuthFile[] | null> {
|
||||
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;
|
||||
|
||||
@@ -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<string, ResolvedAuthFile> {
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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<CliproxyRequestDetail['tokens']>;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
interface ApiKeyUsageEntry {
|
||||
success?: number;
|
||||
failed?: number;
|
||||
}
|
||||
|
||||
type ApiKeyUsageResponse = Record<string, Record<string, ApiKeyUsageEntry>>;
|
||||
|
||||
interface MergeMissingDetailsOptions {
|
||||
appendExtraDetails?: boolean;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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<NonNullable<CliproxyUsageApiResponse['usage']>['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<NonNullable<CliproxyUsageApiResponse['usage']>['apis']>[string],
|
||||
model: string
|
||||
): NonNullable<typeof providerBucket.models>[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<string>();
|
||||
|
||||
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<string, number>;
|
||||
byIdentity: Map<string, number[]>;
|
||||
}
|
||||
|
||||
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<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
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<string, number>(),
|
||||
byIdentity: new Map<string, number[]>(),
|
||||
};
|
||||
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<string, number>, 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<string, number>, 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<NonNullable<CliproxyUsageApiResponse['usage']>['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<MergeMissingDetailsOptions>
|
||||
): 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<MergeMissingDetailsOptions> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<ReturnType<typeof getProviderCatalog>>,
|
||||
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<ReturnType<typeof getProviderCatalog>>,
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 <subcommand>');
|
||||
console.log('');
|
||||
|
||||
@@ -54,6 +54,7 @@ async function showProfilesHelp(writeLine: HelpWriter): Promise<void> {
|
||||
'Profile Types',
|
||||
[
|
||||
{ name: 'ccs auth create <name>', summary: 'Concurrent Claude account profile' },
|
||||
{ name: 'ccs auth resources <name>', summary: 'Shared resources for an account profile' },
|
||||
{ name: 'ccs api create', summary: 'API-backed settings profile' },
|
||||
{ name: 'ccs cliproxy create <name>', summary: 'Named CLIProxy variant profile' },
|
||||
{ name: 'ccs env <profile>', 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
|
||||
);
|
||||
|
||||
@@ -45,13 +45,14 @@ export async function handleSyncCommand(): Promise<void> {
|
||||
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)) {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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<MigrationResult> {
|
||||
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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 !!
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void>
|
||||
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)
|
||||
|
||||
@@ -275,7 +275,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
// sessions can still inherit model intent and other profile-scoped runtime flags.
|
||||
const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv });
|
||||
const claudeRuntimeEnvVars: NodeJS.ProcessEnv = {
|
||||
...stripAnthropicRoutingEnv(settingsRuntimeEnv),
|
||||
...stripAnthropicRoutingEnv(settingsRuntimeEnv, settingsEnv),
|
||||
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
||||
...webSearchEnv,
|
||||
...imageAnalysisEnv,
|
||||
|
||||
@@ -9,6 +9,11 @@ function ensureSupportedProtocol(parsed: URL): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenRouterHost(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase();
|
||||
return normalized === 'openrouter.ai' || normalized.endsWith('.openrouter.ai');
|
||||
}
|
||||
|
||||
function buildResolvedUrl(baseUrl: string, suffix: string): string {
|
||||
const parsed = new URL(baseUrl);
|
||||
ensureSupportedProtocol(parsed);
|
||||
@@ -18,6 +23,11 @@ function buildResolvedUrl(baseUrl: string, suffix: string): string {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
if (isOpenRouterHost(parsed.hostname) && pathname.endsWith('/api')) {
|
||||
parsed.pathname = `${pathname}/v1${suffix}`;
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
if (pathname.endsWith('/v1') || pathname.endsWith('/api')) {
|
||||
parsed.pathname = `${pathname}${suffix.startsWith('/') ? suffix : `/${suffix}`}`;
|
||||
return parsed.toString();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { loadSettingsFromFile, type ProfileType } from '../auth/profile-detector
|
||||
import ProfileDetector from '../auth/profile-detector';
|
||||
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
||||
import { resolveAccountContextPolicy, isAccountContextMetadata } from '../auth/account-context';
|
||||
import { isProfileLocalSharedResourceMode } from '../auth/shared-resource-policy';
|
||||
import type { ProfileDetectionResult } from '../auth/profile-detector';
|
||||
import {
|
||||
getEffectiveEnvVars,
|
||||
@@ -158,8 +159,12 @@ async function resolveExtensionEnv(
|
||||
const policy = resolveAccountContextPolicy(
|
||||
isAccountContextMetadata(result.profile) ? result.profile : undefined
|
||||
);
|
||||
const sharedResourceMetadata =
|
||||
typeof result.profile === 'object' && result.profile !== null
|
||||
? (result.profile as { shared_resource_mode?: unknown; bare?: unknown })
|
||||
: undefined;
|
||||
const instancePath = await instanceManager.ensureInstance(result.name, policy, {
|
||||
bare: result.profile?.bare === true,
|
||||
bare: isProfileLocalSharedResourceMode(sharedResourceMetadata),
|
||||
});
|
||||
notes.push('Account profiles authenticate through the isolated Claude config directory.');
|
||||
return {
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface ProviderPresetDefinition {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api';
|
||||
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/**
|
||||
* Legacy aliases mapped to canonical preset IDs.
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface ProfileMetadata {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -55,14 +55,32 @@ const TMUX_SYNC_ENV_KEYS = [
|
||||
* Used for nested settings-profile Claude launches where `--settings` already
|
||||
* defines the provider transport and the parent process should only lend model
|
||||
* defaults or effort hints.
|
||||
*
|
||||
* `preserveFrom`: if provided, routing keys present in this source survive the
|
||||
* strip (with their values from `preserveFrom`). Settings-type profiles use
|
||||
* this to keep routing/auth supplied by their own `settings.env` while
|
||||
* dropping any routing leaked from the parent shell or `global.env`.
|
||||
*/
|
||||
export function stripAnthropicRoutingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
export function stripAnthropicRoutingEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
preserveFrom?: NodeJS.ProcessEnv
|
||||
): NodeJS.ProcessEnv {
|
||||
const result: NodeJS.ProcessEnv = {};
|
||||
for (const key of Object.keys(env)) {
|
||||
if (!ANTHROPIC_ROUTING_ENV_KEY_SET.has(key.toUpperCase())) {
|
||||
result[key] = env[key];
|
||||
}
|
||||
}
|
||||
if (preserveFrom) {
|
||||
for (const key of ANTHROPIC_ROUTING_ENV_KEYS) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(preserveFrom, key) &&
|
||||
preserveFrom[key] !== undefined
|
||||
) {
|
||||
result[key] = preserveFrom[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -240,7 +258,7 @@ export function execClaude(
|
||||
? { ...baseEnv, ...claudeLaunchEnv, ...envVars, ...webSearchEnv }
|
||||
: { ...baseEnv, ...claudeLaunchEnv, ...webSearchEnv };
|
||||
const effectiveMergedEnv = stripInheritedAnthropicRoutingEnv
|
||||
? stripAnthropicRoutingEnv(mergedEnv)
|
||||
? stripAnthropicRoutingEnv(mergedEnv, envVars ?? undefined)
|
||||
: mergedEnv;
|
||||
|
||||
// Strip Claude Code nested session guard env var to allow CCS delegation
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Integration tests for the OAuth credential guard wired into the
|
||||
* /:provider/start-url route (Phase 3 + Phase 4).
|
||||
*
|
||||
* These tests verify the guard functions that are called inline by the route
|
||||
* handler, using the same pattern as oauth-handler-paste-callback.test.ts.
|
||||
* Dynamic imports with cache-busting query strings prevent module-cache
|
||||
* interference between test cases.
|
||||
*
|
||||
* Test isolation: guard functions under test only read process.env and
|
||||
* their CLIProxyProvider/CLIProxyBackend arguments — no disk access,
|
||||
* no real ~/.ccs reads required.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restore any env vars mutated during tests
|
||||
// ---------------------------------------------------------------------------
|
||||
const GEMINI_ID_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_ID';
|
||||
const GEMINI_SECRET_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET';
|
||||
const AGY_ID_ENV = 'CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_ID';
|
||||
const AGY_SECRET_ENV = 'CLIPROXY_ANTIGRAVITY_OAUTH_CLIENT_SECRET';
|
||||
|
||||
function unsetGeminiEnv(): void {
|
||||
delete process.env[GEMINI_ID_ENV];
|
||||
delete process.env[GEMINI_SECRET_ENV];
|
||||
}
|
||||
|
||||
function setGeminiEnv(): void {
|
||||
process.env[GEMINI_ID_ENV] = 'test-client-id';
|
||||
process.env[GEMINI_SECRET_ENV] = 'test-client-secret';
|
||||
}
|
||||
|
||||
function unsetAgyEnv(): void {
|
||||
delete process.env[AGY_ID_ENV];
|
||||
delete process.env[AGY_SECRET_ENV];
|
||||
}
|
||||
|
||||
function setAgyEnv(): void {
|
||||
process.env[AGY_ID_ENV] = 'test-agy-client-id';
|
||||
process.env[AGY_SECRET_ENV] = 'test-agy-client-secret';
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any env vars set in tests
|
||||
delete process.env[GEMINI_ID_ENV];
|
||||
delete process.env[GEMINI_SECRET_ENV];
|
||||
delete process.env[AGY_ID_ENV];
|
||||
delete process.env[AGY_SECRET_ENV];
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3: pre-fetch credential guard (getPlusOAuthCredentialError)
|
||||
// The route calls this before making any fetch to the Plus binary.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('start-url route: Phase 3 pre-fetch credential guard', () => {
|
||||
it('fires for gemini on plus backend when both env vars are missing', async () => {
|
||||
unsetGeminiEnv();
|
||||
|
||||
const { getPlusOAuthCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-guard-gemini-missing-${Date.now()}`
|
||||
);
|
||||
|
||||
const error = getPlusOAuthCredentialError('gemini', 'plus');
|
||||
|
||||
// Guard must return a non-null string (route returns 400 with this as message)
|
||||
expect(error).not.toBeNull();
|
||||
expect(typeof error).toBe('string');
|
||||
expect(error).toContain('Gemini OAuth from CLIProxy Plus is missing');
|
||||
expect(error).toContain(GEMINI_ID_ENV);
|
||||
expect(error).toContain(GEMINI_SECRET_ENV);
|
||||
// Message must tell user how to fix (set env vars or switch backend)
|
||||
expect(error).toContain('original');
|
||||
});
|
||||
|
||||
it('fires for gemini on plus backend when only client ID is missing', async () => {
|
||||
delete process.env[GEMINI_ID_ENV];
|
||||
process.env[GEMINI_SECRET_ENV] = 'has-secret';
|
||||
|
||||
const { getPlusOAuthCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-guard-gemini-id-only-${Date.now()}`
|
||||
);
|
||||
|
||||
const error = getPlusOAuthCredentialError('gemini', 'plus');
|
||||
expect(error).not.toBeNull();
|
||||
// Missing var should be listed
|
||||
expect(error).toContain(GEMINI_ID_ENV);
|
||||
delete process.env[GEMINI_SECRET_ENV];
|
||||
});
|
||||
|
||||
it('fires for agy on plus backend when both env vars are missing', async () => {
|
||||
unsetAgyEnv();
|
||||
|
||||
const { getPlusOAuthCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-guard-agy-missing-${Date.now()}`
|
||||
);
|
||||
|
||||
const error = getPlusOAuthCredentialError('agy', 'plus');
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(typeof error).toBe('string');
|
||||
expect(error).toContain('Antigravity OAuth from CLIProxy Plus is missing');
|
||||
expect(error).toContain(AGY_ID_ENV);
|
||||
expect(error).toContain(AGY_SECRET_ENV);
|
||||
});
|
||||
|
||||
it('returns null for gemini on plus when both env vars are present (guard does not fire)', async () => {
|
||||
setGeminiEnv();
|
||||
|
||||
const { getPlusOAuthCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-guard-gemini-ok-${Date.now()}`
|
||||
);
|
||||
|
||||
expect(getPlusOAuthCredentialError('gemini', 'plus')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for agy on plus when both env vars are present (guard does not fire)', async () => {
|
||||
setAgyEnv();
|
||||
|
||||
const { getPlusOAuthCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-guard-agy-ok-${Date.now()}`
|
||||
);
|
||||
|
||||
expect(getPlusOAuthCredentialError('agy', 'plus')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for ghcp provider on plus backend (not in guard table)', async () => {
|
||||
// ghcp is NOT in PLUS_OAUTH_ENV_BY_PROVIDER — guard must not fire
|
||||
const { getPlusOAuthCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-guard-ghcp-${Date.now()}`
|
||||
);
|
||||
|
||||
expect(getPlusOAuthCredentialError('ghcp', 'plus')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for gemini when backend is original (guard only applies to plus)', async () => {
|
||||
unsetGeminiEnv(); // env vars absent, but backend is original
|
||||
|
||||
const { getPlusOAuthCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-guard-gemini-original-${Date.now()}`
|
||||
);
|
||||
|
||||
// original backend → guard returns null regardless of env
|
||||
expect(getPlusOAuthCredentialError('gemini', 'original', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4: post-fetch auth-URL guard (getPlusAuthUrlCredentialError)
|
||||
// The route calls this after fetching the authUrl from Plus, before responding.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('start-url route: Phase 4 post-fetch auth-URL guard', () => {
|
||||
it('fires for gemini when Plus emits auth URL with empty client_id (502 contract)', async () => {
|
||||
const { getPlusAuthUrlCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-url-guard-gemini-empty-${Date.now()}`
|
||||
);
|
||||
|
||||
const badUrl =
|
||||
'https://accounts.google.com/o/oauth2/v2/auth' +
|
||||
'?client_id=&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback&state=abc';
|
||||
const error = getPlusAuthUrlCredentialError('gemini', badUrl);
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(typeof error).toBe('string');
|
||||
expect(error).toContain('Gemini OAuth from CLIProxy Plus is missing');
|
||||
});
|
||||
|
||||
it('fires for agy when Plus emits auth URL with empty client_id (502 contract)', async () => {
|
||||
const { getPlusAuthUrlCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-url-guard-agy-empty-${Date.now()}`
|
||||
);
|
||||
|
||||
const badUrl = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=&state=abc';
|
||||
const error = getPlusAuthUrlCredentialError('agy', badUrl);
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error).toContain('Antigravity OAuth from CLIProxy Plus is missing');
|
||||
});
|
||||
|
||||
it('returns null for gemini when client_id is present (guard must not fire)', async () => {
|
||||
const { getPlusAuthUrlCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-url-guard-gemini-ok-${Date.now()}`
|
||||
);
|
||||
|
||||
const goodUrl = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=real-id&state=abc';
|
||||
expect(getPlusAuthUrlCredentialError('gemini', goodUrl)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for ghcp (not in guard table) even with empty client_id', async () => {
|
||||
const { getPlusAuthUrlCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-url-guard-ghcp-${Date.now()}`
|
||||
);
|
||||
|
||||
// ghcp is not in PLUS_OAUTH_ENV_BY_PROVIDER — URL guard never fires
|
||||
const anyUrl = 'https://example.com/oauth?client_id=&state=abc';
|
||||
expect(getPlusAuthUrlCredentialError('ghcp', anyUrl)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed authUrl (guard must not throw)', async () => {
|
||||
const { getPlusAuthUrlCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?route-url-guard-malformed-${Date.now()}`
|
||||
);
|
||||
|
||||
// Guard must swallow parse errors — route should not 502 on malformed URLs
|
||||
expect(getPlusAuthUrlCredentialError('gemini', 'not-a-url')).toBeNull();
|
||||
expect(getPlusAuthUrlCredentialError('gemini', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3+4: HTTP response body contract
|
||||
// Verifies the exact JSON shape the route would return so the UI hook can
|
||||
// match on data.error and surface data.message to the user.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('start-url route: response body contract', () => {
|
||||
it('credential-missing 400 body shape: error=plus_oauth_credentials_missing, message=string, provider=string', async () => {
|
||||
unsetGeminiEnv();
|
||||
|
||||
const { getPlusOAuthCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?body-shape-missing-${Date.now()}`
|
||||
);
|
||||
|
||||
const message = getPlusOAuthCredentialError('gemini', 'plus');
|
||||
|
||||
// Replicate what the route handler does when credentialError is non-null
|
||||
const body = {
|
||||
error: 'plus_oauth_credentials_missing' as const,
|
||||
provider: 'gemini' as const,
|
||||
message,
|
||||
};
|
||||
|
||||
expect(body.error).toBe('plus_oauth_credentials_missing');
|
||||
expect(typeof body.message).toBe('string');
|
||||
// Human-readable message must be meaningful
|
||||
expect((body.message ?? '').length).toBeGreaterThan(10);
|
||||
expect(body.provider).toBe('gemini');
|
||||
});
|
||||
|
||||
it('auth-url 502 body shape: error=plus_oauth_url_missing_client_id, message=string, provider=string', async () => {
|
||||
const { getPlusAuthUrlCredentialError } = await import(
|
||||
`../../../cliproxy/auth/oauth-handler?body-shape-url-${Date.now()}`
|
||||
);
|
||||
|
||||
const badUrl = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=&state=abc';
|
||||
const message = getPlusAuthUrlCredentialError('gemini', badUrl);
|
||||
|
||||
// Replicate what the route handler does when authUrlError is non-null
|
||||
const body = {
|
||||
error: 'plus_oauth_url_missing_client_id' as const,
|
||||
provider: 'gemini' as const,
|
||||
message,
|
||||
};
|
||||
|
||||
expect(body.error).toBe('plus_oauth_url_missing_client_id');
|
||||
expect(typeof body.message).toBe('string');
|
||||
expect((body.message ?? '').length).toBeGreaterThan(10);
|
||||
expect(body.provider).toBe('gemini');
|
||||
});
|
||||
|
||||
it('UI hook can distinguish credential errors by data.error code', () => {
|
||||
// The UI hook checks: data.error === 'plus_oauth_credentials_missing'
|
||||
// or data.error === 'plus_oauth_url_missing_client_id' to decide
|
||||
// whether to use data.message instead of data.error as the displayed text.
|
||||
const missingCreds = { error: 'plus_oauth_credentials_missing', message: 'Friendly message' };
|
||||
const missingUrl = {
|
||||
error: 'plus_oauth_url_missing_client_id',
|
||||
message: 'Friendly URL message',
|
||||
};
|
||||
const generic = { error: 'some_other_error' };
|
||||
|
||||
function simulateHookErrorResolution(data: Record<string, unknown>): 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void> => {
|
||||
// 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void>
|
||||
|
||||
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<void>
|
||||
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<void>
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/accounts/:name/shared-resources - Update account shared resource mode
|
||||
*/
|
||||
router.put('/:name/shared-resources', async (req: Request, res: Response): Promise<void> => {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<SharedCollectionType, SharedItemsCacheEntry>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-type item builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getCommandItems(sharedDir: string, allowedRoots: Set<string>): 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<string>,
|
||||
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;
|
||||
}
|
||||
@@ -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<string>
|
||||
): { 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<string>,
|
||||
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 };
|
||||
}
|
||||
@@ -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<string>
|
||||
): MarkdownFileEntry[] {
|
||||
const directoriesToVisit: Array<{ path: string; depth: number }> = [
|
||||
{ path: sharedDir, depth: 0 },
|
||||
];
|
||||
const visitedDirectories = new Set<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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('<!--');
|
||||
}
|
||||
|
||||
function extractFrontmatterDescription(content: string): string | null {
|
||||
const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
|
||||
if (!frontmatterMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown> | null;
|
||||
const description = parsed?.description;
|
||||
if (typeof description !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = description.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripFrontmatter(content: string): string {
|
||||
return content.replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
|
||||
}
|
||||
|
||||
function trimDescription(description: string): string {
|
||||
if (description.length <= MAX_DESCRIPTION_LENGTH) {
|
||||
return description;
|
||||
}
|
||||
return `${description.slice(0, MAX_DESCRIPTION_LENGTH - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
export function extractDescription(content: string): string {
|
||||
const frontmatterDescription = extractFrontmatterDescription(content);
|
||||
if (frontmatterDescription) {
|
||||
return trimDescription(frontmatterDescription);
|
||||
}
|
||||
|
||||
// Fall back to first non-empty, non-heading body line.
|
||||
const lines = stripFrontmatter(content).split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (isDescriptionBodyLine(trimmed)) {
|
||||
return trimDescription(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return 'No description';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File readers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function readMarkdownDescription(
|
||||
markdownPath: string,
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
try {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile() || stats.size > MAX_MARKDOWN_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const content = fs.readFileSync(resolvedMarkdownPath, 'utf8');
|
||||
return extractDescription(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readFirstMarkdownDescription(
|
||||
markdownPaths: string[],
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
for (const markdownPath of markdownPaths) {
|
||||
const description = readMarkdownDescription(markdownPath, allowedRoots);
|
||||
if (description) {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readMarkdownContent(
|
||||
markdownPath: string,
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
try {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
return fs.readFileSync(resolvedMarkdownPath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveReadableMarkdownPath(
|
||||
markdownPaths: string[],
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
for (const markdownPath of markdownPaths) {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
return resolvedMarkdownPath;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Shared Routes — Path safety helpers
|
||||
*
|
||||
* Prevents directory traversal by resolving real paths and checking
|
||||
* containment within allowed roots before any file I/O.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getClaudeConfigDir } from '../utils/claude-config-path';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
import type { SharedCollectionType } from './shared-routes-types';
|
||||
|
||||
export function safeRealPath(targetPath: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeForPathComparison(targetPath: string): string {
|
||||
const normalized = path.resolve(targetPath);
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function isPathWithin(candidatePath: string, basePath: string): boolean {
|
||||
const normalizedCandidate = normalizeForPathComparison(candidatePath);
|
||||
const normalizedBase = normalizeForPathComparison(basePath);
|
||||
const relative = path.relative(normalizedBase, normalizedCandidate);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function isPathWithinAny(candidatePath: string, basePaths: Set<string>): boolean {
|
||||
for (const basePath of basePaths) {
|
||||
if (isPathWithin(candidatePath, basePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveAllowedRoots(
|
||||
type: SharedCollectionType,
|
||||
ccsDir: string,
|
||||
sharedDirRoot: string
|
||||
): Set<string> {
|
||||
if (type === 'commands' || type === 'plugins') {
|
||||
return new Set<string>([sharedDirRoot]);
|
||||
}
|
||||
|
||||
return new Set<string>([
|
||||
sharedDirRoot,
|
||||
...[
|
||||
path.join(getClaudeConfigDir(), type),
|
||||
path.join(ccsDir, '.claude', type),
|
||||
path.join(ccsDir, 'shared', type),
|
||||
]
|
||||
.map((dirPath) => safeRealPath(dirPath))
|
||||
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveSettingsAllowedRoots(ccsDir: string, sharedDirRoot: string): Set<string> {
|
||||
const claudeConfigDir = getClaudeConfigDir();
|
||||
return new Set<string>(
|
||||
[
|
||||
sharedDirRoot,
|
||||
safeRealPath(path.join(claudeConfigDir, 'settings.json')),
|
||||
safeRealPath(path.join(ccsDir, '.claude', 'settings.json')),
|
||||
].filter((dirPath): dirPath is string => typeof dirPath === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export getCcsDir so callers that need both path guards and ccsDir can import from one place
|
||||
export { getCcsDir };
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Shared Routes — Plugin registry content renderer
|
||||
*
|
||||
* Renders the markdown content for an installed plugin entry,
|
||||
* pulling from installed_plugins.json, known_marketplaces.json,
|
||||
* and blocklist.json within the allowed roots.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
|
||||
import { safeRealPath, isPathWithinAny } from './shared-routes-path-guards';
|
||||
import {
|
||||
readJsonObject,
|
||||
readPluginInstalledRegistry,
|
||||
decodePluginRegistryPath,
|
||||
extractMarketplaceFromPluginName,
|
||||
stringifyJson,
|
||||
} from './shared-routes-plugins';
|
||||
|
||||
function findPluginBlocklistEntry(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>,
|
||||
pluginName: string
|
||||
): unknown | null {
|
||||
const blocklist = readJsonObject(path.join(sharedDir, 'blocklist.json'), allowedRoots);
|
||||
const plugins = blocklist?.plugins;
|
||||
if (!Array.isArray(plugins)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
plugins.find(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
'plugin' in entry &&
|
||||
(entry as { plugin?: unknown }).plugin === pluginName
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function getPluginRegistryContent(
|
||||
itemPath: string,
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): { content: string; contentPath: string } | null {
|
||||
const pluginName = decodePluginRegistryPath(itemPath);
|
||||
if (!pluginName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registryPath = path.join(sharedDir, 'installed_plugins.json');
|
||||
const resolvedRegistryPath = safeRealPath(registryPath);
|
||||
if (!resolvedRegistryPath || !isPathWithinAny(resolvedRegistryPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
|
||||
if (!registry || !Object.prototype.hasOwnProperty.call(registry.plugins, pluginName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = registry.plugins[pluginName];
|
||||
const marketplace = extractMarketplaceFromPluginName(pluginName);
|
||||
const marketplaceEntry = marketplace
|
||||
? readJsonObject(path.join(sharedDir, 'known_marketplaces.json'), allowedRoots)?.[marketplace]
|
||||
: null;
|
||||
const blocklistEntry = findPluginBlocklistEntry(sharedDir, allowedRoots, pluginName);
|
||||
|
||||
const lines = [
|
||||
`# ${pluginName}`,
|
||||
'',
|
||||
`Registry: \`${resolvedRegistryPath}\``,
|
||||
'',
|
||||
'## Installed Plugin',
|
||||
'',
|
||||
`- Source: shared \`installed_plugins.json\` registry`,
|
||||
`- Registry version: ${registry.version ?? 'unknown'}`,
|
||||
`- Marketplace: ${marketplace ?? 'not recorded in plugin name'}`,
|
||||
`- Install records: ${Array.isArray(metadata) ? metadata.length : 1}`,
|
||||
];
|
||||
|
||||
if (marketplaceEntry) {
|
||||
lines.push(
|
||||
'',
|
||||
'## Marketplace Registry Entry',
|
||||
'',
|
||||
'```json',
|
||||
stringifyJson(marketplaceEntry),
|
||||
'```'
|
||||
);
|
||||
}
|
||||
|
||||
if (blocklistEntry) {
|
||||
lines.push('', '## Blocklist Notice', '', '```json', stringifyJson(blocklistEntry), '```');
|
||||
}
|
||||
|
||||
lines.push('', '## Plugin Registry Entry', '', '```json', stringifyJson(metadata), '```');
|
||||
|
||||
return {
|
||||
content: lines.join('\n'),
|
||||
contentPath: resolvedRegistryPath,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Shared Routes — Plugin collection and registry helpers
|
||||
*
|
||||
* Handles installed_plugins.json registry reads, legacy plugin directory
|
||||
* scanning, and plugin path encode/decode.
|
||||
*
|
||||
* Registry content rendering is in shared-routes-plugin-registry-content.ts.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { safeRealPath, isPathWithin, isPathWithinAny } from './shared-routes-path-guards';
|
||||
import { readFirstMarkdownDescription, MAX_CONTENT_FILE_BYTES } from './shared-routes-markdown';
|
||||
import type { SharedItem, InstalledPluginRegistry } from './shared-routes-types';
|
||||
|
||||
const PLUGIN_REGISTRY_PATH_PREFIX = 'plugin-registry:';
|
||||
const PLUGIN_INFRASTRUCTURE_DIRECTORIES = new Set(['cache', 'data', 'marketplaces']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path encode / decode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function encodePluginRegistryPath(pluginName: string): string {
|
||||
return `${PLUGIN_REGISTRY_PATH_PREFIX}${encodeURIComponent(pluginName)}`;
|
||||
}
|
||||
|
||||
export function decodePluginRegistryPath(itemPath: string): string | null {
|
||||
if (!itemPath.startsWith(PLUGIN_REGISTRY_PATH_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const encodedName = itemPath.slice(PLUGIN_REGISTRY_PATH_PREFIX.length);
|
||||
const pluginName = decodeURIComponent(encodedName);
|
||||
return pluginName.length > 0 ? pluginName : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function readJsonObject(
|
||||
filePath: string,
|
||||
allowedRoots: Set<string>
|
||||
): Record<string, unknown> | null {
|
||||
const resolvedPath = safeRealPath(filePath);
|
||||
if (!resolvedPath || !isPathWithinAny(resolvedPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readPluginInstalledRegistry(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): InstalledPluginRegistry | null {
|
||||
const parsed = readJsonObject(path.join(sharedDir, 'installed_plugins.json'), allowedRoots);
|
||||
if (
|
||||
!parsed ||
|
||||
!parsed.plugins ||
|
||||
typeof parsed.plugins !== 'object' ||
|
||||
Array.isArray(parsed.plugins)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: typeof parsed.version === 'number' ? parsed.version : undefined,
|
||||
plugins: parsed.plugins as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Description helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function extractMarketplaceFromPluginName(pluginName: string): string | null {
|
||||
const atIndex = pluginName.lastIndexOf('@');
|
||||
if (atIndex <= 0 || atIndex === pluginName.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return pluginName.slice(atIndex + 1);
|
||||
}
|
||||
|
||||
export function describeInstalledPlugin(pluginName: string, metadata: unknown): string {
|
||||
const recordCount = Array.isArray(metadata) ? metadata.length : 1;
|
||||
const marketplace = extractMarketplaceFromPluginName(pluginName);
|
||||
const recordLabel = `${recordCount} ${recordCount === 1 ? 'record' : 'records'}`;
|
||||
return marketplace
|
||||
? `Installed from ${marketplace}; ${recordLabel} in shared registry`
|
||||
: `Installed plugin; ${recordLabel} in shared registry`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Infrastructure path guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isPluginInfrastructurePath(candidatePath: string, sharedDir: string): boolean {
|
||||
for (const directoryName of PLUGIN_INFRASTRUCTURE_DIRECTORIES) {
|
||||
const resolvedInfrastructurePath = safeRealPath(path.join(sharedDir, directoryName));
|
||||
if (resolvedInfrastructurePath && isPathWithin(candidatePath, resolvedInfrastructurePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2) ?? 'null';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item list builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getPluginRegistryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
|
||||
if (!registry) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(registry.plugins).map(([pluginName, metadata]) => ({
|
||||
name: pluginName,
|
||||
description: describeInstalledPlugin(pluginName, metadata),
|
||||
path: encodePluginRegistryPath(pluginName),
|
||||
type: 'plugin' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getLegacyPluginDirectoryItems(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): SharedItem[] {
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: SharedItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || PLUGIN_INFRASTRUCTURE_DIRECTORIES.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = path.join(sharedDir, entry.name);
|
||||
const resolvedEntryPath = safeRealPath(entryPath);
|
||||
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const description = readFirstMarkdownDescription(
|
||||
[
|
||||
path.join(resolvedEntryPath, 'README.md'),
|
||||
path.join(resolvedEntryPath, 'readme.md'),
|
||||
path.join(resolvedEntryPath, 'PLUGIN.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
|
||||
if (!description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({ name: entry.name, description, path: entryPath, type: 'plugin' });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function getPluginItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const registryItems = getPluginRegistryItems(sharedDir, allowedRoots);
|
||||
const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots);
|
||||
const itemsByPath = new Map<string, SharedItem>();
|
||||
|
||||
for (const item of [...registryItems, ...legacyDirectoryItems]) {
|
||||
itemsByPath.set(item.path, item);
|
||||
}
|
||||
|
||||
return [...itemsByPath.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Shared Routes — Symlink / copy status checker
|
||||
*
|
||||
* Reports whether shared resources (commands, skills, agents) under
|
||||
* ~/.ccs/shared/ are configured as symlinks pointing to ~/.claude/,
|
||||
* as directory copies (Windows fallback), or are missing.
|
||||
*
|
||||
* Return shape is a superset of the original { valid, message } so
|
||||
* existing callers remain unaffected while new callers can inspect
|
||||
* mode and per-link details.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getClaudeConfigDir } from '../utils/claude-config-path';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
|
||||
type LinkMode = 'symlink' | 'copy' | 'missing';
|
||||
|
||||
interface LinkDetail {
|
||||
mode: LinkMode;
|
||||
}
|
||||
|
||||
interface SymlinkStatusResult {
|
||||
valid: boolean;
|
||||
message: string;
|
||||
/** Overall configuration mode derived from the three entries. */
|
||||
mode: 'symlink' | 'copy' | 'mixed' | 'none';
|
||||
/** Per-entry classification for diagnostics. */
|
||||
links: {
|
||||
commands: LinkMode;
|
||||
skills: LinkMode;
|
||||
agents: LinkMode;
|
||||
};
|
||||
}
|
||||
|
||||
const SHARED_ENTRY_TYPES = ['commands', 'skills', 'agents'] as const;
|
||||
type SharedEntryType = (typeof SHARED_ENTRY_TYPES)[number];
|
||||
|
||||
function classifyEntry(linkPath: string, expectedTarget: string): LinkMode {
|
||||
try {
|
||||
const lstats = fs.lstatSync(linkPath);
|
||||
|
||||
if (lstats.isSymbolicLink()) {
|
||||
// Validate it points to the expected Claude config sub-directory.
|
||||
const target = fs.readlinkSync(linkPath);
|
||||
const resolvedTarget = path.resolve(path.dirname(linkPath), target);
|
||||
if (resolvedTarget === path.resolve(expectedTarget)) {
|
||||
return 'symlink';
|
||||
}
|
||||
// Symlink exists but points elsewhere — treat as missing for validity.
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
if (lstats.isDirectory()) {
|
||||
// Best-effort non-empty check to distinguish a real copy from an
|
||||
// accidental empty directory.
|
||||
try {
|
||||
const entries = fs.readdirSync(linkPath);
|
||||
if (entries.length > 0) {
|
||||
return 'copy';
|
||||
}
|
||||
} catch {
|
||||
// readdirSync failed — fall through to missing
|
||||
}
|
||||
return 'missing';
|
||||
}
|
||||
} catch {
|
||||
// lstatSync threw — path does not exist or is unreadable
|
||||
}
|
||||
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
export function checkSymlinkStatus(): SymlinkStatusResult {
|
||||
const ccsDir = getCcsDir();
|
||||
const sharedDir = path.join(ccsDir, 'shared');
|
||||
const claudeConfigDir = getClaudeConfigDir();
|
||||
|
||||
if (!fs.existsSync(sharedDir)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Shared directory not found',
|
||||
mode: 'none',
|
||||
links: { commands: 'missing', skills: 'missing', agents: 'missing' },
|
||||
};
|
||||
}
|
||||
|
||||
const details: Record<SharedEntryType, LinkDetail> = {
|
||||
commands: { mode: 'missing' },
|
||||
skills: { mode: 'missing' },
|
||||
agents: { mode: 'missing' },
|
||||
};
|
||||
|
||||
for (const entryType of SHARED_ENTRY_TYPES) {
|
||||
const linkPath = path.join(sharedDir, entryType);
|
||||
const expectedTarget = path.join(claudeConfigDir, entryType);
|
||||
|
||||
if (!fs.existsSync(linkPath)) {
|
||||
details[entryType] = { mode: 'missing' };
|
||||
continue;
|
||||
}
|
||||
|
||||
details[entryType] = { mode: classifyEntry(linkPath, expectedTarget) };
|
||||
}
|
||||
|
||||
const modes = SHARED_ENTRY_TYPES.map((t) => details[t].mode);
|
||||
const links = {
|
||||
commands: details.commands.mode,
|
||||
skills: details.skills.mode,
|
||||
agents: details.agents.mode,
|
||||
};
|
||||
|
||||
const allSymlink = modes.every((m) => m === 'symlink');
|
||||
const allCopy = modes.every((m) => m === 'copy');
|
||||
const allMissing = modes.every((m) => m === 'missing');
|
||||
|
||||
if (allSymlink) {
|
||||
return { valid: true, message: 'Symlinks active', mode: 'symlink', links };
|
||||
}
|
||||
|
||||
if (allCopy) {
|
||||
return {
|
||||
valid: true,
|
||||
message: 'Copies active (Windows fallback)',
|
||||
mode: 'copy',
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
if (allMissing) {
|
||||
return { valid: false, message: 'Shared resources not configured', mode: 'none', links };
|
||||
}
|
||||
|
||||
// Mixed — at least one differs from the others.
|
||||
const details_str = SHARED_ENTRY_TYPES.map((t) => `${t}:${details[t].mode}`).join(', ');
|
||||
return {
|
||||
valid: false,
|
||||
message: `Mixed configuration: ${details_str}`,
|
||||
mode: 'mixed',
|
||||
links,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shared Routes — Type definitions and type guards
|
||||
*/
|
||||
|
||||
export type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins';
|
||||
export type SharedContentType = SharedCollectionType | 'settings';
|
||||
|
||||
export interface SharedItem {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
type: 'command' | 'skill' | 'agent' | 'plugin';
|
||||
}
|
||||
|
||||
export interface SharedItemsCacheEntry {
|
||||
items: SharedItem[];
|
||||
sharedDir: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface InstalledPluginRegistry {
|
||||
version?: number;
|
||||
plugins: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isSharedCollectionType(value: unknown): value is SharedCollectionType {
|
||||
return value === 'commands' || value === 'skills' || value === 'agents' || value === 'plugins';
|
||||
}
|
||||
|
||||
export function isSharedContentType(value: unknown): value is SharedContentType {
|
||||
return isSharedCollectionType(value) || value === 'settings';
|
||||
}
|
||||
+55
-570
@@ -1,17 +1,32 @@
|
||||
/**
|
||||
* Shared Data Routes (Phase 07)
|
||||
*
|
||||
* API routes for commands, skills, agents from ~/.ccs/shared/
|
||||
* Thin Express router — delegates all logic to focused sub-modules.
|
||||
* API routes for commands, skills, agents, and plugins from ~/.ccs/shared/
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
import { getClaudeConfigDir } from '../utils/claude-config-path';
|
||||
import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware';
|
||||
import { isSharedContentType } from './shared-routes-types';
|
||||
import {
|
||||
safeRealPath,
|
||||
resolveAllowedRoots,
|
||||
resolveSettingsAllowedRoots,
|
||||
isPathWithinAny,
|
||||
} from './shared-routes-path-guards';
|
||||
import { getSharedItems } from './shared-routes-collections';
|
||||
import { getSharedItemContent } from './shared-routes-content';
|
||||
import { checkSymlinkStatus } from './shared-routes-symlink-status';
|
||||
|
||||
/** Strips extended fields so the summary endpoint stays wire-compatible. */
|
||||
function symlinkStatusForSummary(): { valid: boolean; message: string } {
|
||||
const { valid, message } = checkSymlinkStatus();
|
||||
return { valid, message };
|
||||
}
|
||||
|
||||
export const sharedRoutes = Router();
|
||||
|
||||
@@ -27,29 +42,6 @@ sharedRoutes.use((req: Request, res: Response, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
|
||||
const MAX_DESCRIPTION_LENGTH = 140;
|
||||
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
|
||||
const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
||||
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
|
||||
|
||||
type SharedCollectionType = 'commands' | 'skills' | 'agents';
|
||||
|
||||
interface SharedItem {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
type: 'command' | 'skill' | 'agent';
|
||||
}
|
||||
|
||||
interface SharedItemsCacheEntry {
|
||||
items: SharedItem[];
|
||||
sharedDir: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
|
||||
|
||||
/**
|
||||
* GET /api/shared/commands
|
||||
*/
|
||||
@@ -75,13 +67,21 @@ sharedRoutes.get('/agents', (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/shared/content?type=commands|skills|agents&path=<item-path>
|
||||
* GET /api/shared/plugins
|
||||
*/
|
||||
sharedRoutes.get('/plugins', (_req: Request, res: Response) => {
|
||||
const items = getSharedItems('plugins');
|
||||
res.json({ items });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/shared/content?type=commands|skills|agents|plugins|settings&path=<item-path>
|
||||
*/
|
||||
sharedRoutes.get('/content', (req: Request, res: Response) => {
|
||||
const typeParam = req.query.type;
|
||||
const itemPathParam = req.query.path;
|
||||
|
||||
if (!isSharedCollectionType(typeParam)) {
|
||||
if (!isSharedContentType(typeParam)) {
|
||||
res.status(400).json({ error: 'Invalid or missing type parameter' });
|
||||
return;
|
||||
}
|
||||
@@ -91,15 +91,21 @@ sharedRoutes.get('/content', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
const sharedDir = path.join(ccsDir, 'shared', typeParam);
|
||||
const sharedDir =
|
||||
typeParam === 'settings' ? path.join(ccsDir, 'shared') : path.join(ccsDir, 'shared', typeParam);
|
||||
|
||||
if (!fs.existsSync(sharedDir)) {
|
||||
res.status(404).json({ error: 'Shared directory not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
|
||||
const allowedRoots = resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
|
||||
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots);
|
||||
const allowedRoots =
|
||||
typeParam === 'settings'
|
||||
? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot)
|
||||
: resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
|
||||
|
||||
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots, sharedDirRoot);
|
||||
|
||||
if (!contentResult) {
|
||||
res.status(404).json({ error: 'Shared content not found' });
|
||||
@@ -116,548 +122,27 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => {
|
||||
const commands = getSharedItems('commands').length;
|
||||
const skills = getSharedItems('skills').length;
|
||||
const agents = getSharedItems('agents').length;
|
||||
const plugins = getSharedItems('plugins').length;
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
||||
const sharedDirRoot = safeRealPath(path.join(ccsDir, 'shared'));
|
||||
const resolvedSettingsPath = safeRealPath(settingsPath);
|
||||
const settingsAllowedRoots = sharedDirRoot
|
||||
? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot)
|
||||
: new Set<string>();
|
||||
const hasSettings =
|
||||
Boolean(sharedDirRoot) &&
|
||||
Boolean(resolvedSettingsPath) &&
|
||||
isPathWithinAny(resolvedSettingsPath as string, settingsAllowedRoots);
|
||||
|
||||
res.json({
|
||||
commands,
|
||||
skills,
|
||||
agents,
|
||||
total: commands + skills + agents,
|
||||
symlinkStatus: checkSymlinkStatus(),
|
||||
plugins,
|
||||
settings: hasSettings,
|
||||
total: commands + skills + agents + plugins + (hasSettings ? 1 : 0),
|
||||
symlinkStatus: symlinkStatusForSummary(),
|
||||
});
|
||||
});
|
||||
|
||||
function isSharedCollectionType(value: unknown): value is SharedCollectionType {
|
||||
return value === 'commands' || value === 'skills' || value === 'agents';
|
||||
}
|
||||
|
||||
function resolveAllowedRoots(
|
||||
type: SharedCollectionType,
|
||||
ccsDir: string,
|
||||
sharedDirRoot: string
|
||||
): Set<string> {
|
||||
if (type === 'commands') {
|
||||
return new Set<string>([sharedDirRoot]);
|
||||
}
|
||||
|
||||
return new Set<string>([
|
||||
sharedDirRoot,
|
||||
...[
|
||||
path.join(getClaudeConfigDir(), type),
|
||||
path.join(ccsDir, '.claude', type),
|
||||
path.join(ccsDir, 'shared', type),
|
||||
]
|
||||
.map((dirPath) => safeRealPath(dirPath))
|
||||
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
|
||||
]);
|
||||
}
|
||||
|
||||
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 items: SharedItem[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getCommandItems(sharedDir: string, allowedRoots: Set<string>): 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));
|
||||
}
|
||||
|
||||
function getSkillOrAgentItem(
|
||||
type: 'skills' | 'agents',
|
||||
entry: fs.Dirent,
|
||||
entryPath: string,
|
||||
resolvedEntryPath: string,
|
||||
allowedRoots: Set<string>,
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
interface MarkdownFileEntry {
|
||||
displayPath: string;
|
||||
resolvedPath: string;
|
||||
}
|
||||
|
||||
function collectMarkdownFiles(sharedDir: string, allowedRoots: Set<string>): MarkdownFileEntry[] {
|
||||
const directoriesToVisit: Array<{ path: string; depth: number }> = [
|
||||
{ path: sharedDir, depth: 0 },
|
||||
];
|
||||
const visitedDirectories = new Set<string>();
|
||||
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 = normalizeForPathComparison(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;
|
||||
}
|
||||
|
||||
function extractDescription(content: string): string {
|
||||
const frontmatterDescription = extractFrontmatterDescription(content);
|
||||
if (frontmatterDescription) {
|
||||
return trimDescription(frontmatterDescription);
|
||||
}
|
||||
|
||||
// Extract first non-empty, non-heading line from the markdown body.
|
||||
const lines = stripFrontmatter(content).split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (isDescriptionBodyLine(trimmed)) {
|
||||
return trimDescription(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return 'No description';
|
||||
}
|
||||
|
||||
function isDescriptionBodyLine(line: string): boolean {
|
||||
if (!line) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (line === '---' || line === '...') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !line.startsWith('#') && !line.startsWith('<!--');
|
||||
}
|
||||
|
||||
function extractFrontmatterDescription(content: string): string | null {
|
||||
const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
|
||||
if (!frontmatterMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown> | null;
|
||||
const description = parsed?.description;
|
||||
if (typeof description !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = description.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripFrontmatter(content: string): string {
|
||||
return content.replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
|
||||
}
|
||||
|
||||
function trimDescription(description: string): string {
|
||||
if (description.length <= MAX_DESCRIPTION_LENGTH) {
|
||||
return description;
|
||||
}
|
||||
|
||||
return `${description.slice(0, MAX_DESCRIPTION_LENGTH - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function readFirstMarkdownDescription(
|
||||
markdownPaths: string[],
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
for (const markdownPath of markdownPaths) {
|
||||
const description = readMarkdownDescription(markdownPath, allowedRoots);
|
||||
if (description) {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readMarkdownDescription(markdownPath: string, allowedRoots: Set<string>): string | null {
|
||||
try {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile()) {
|
||||
return null;
|
||||
}
|
||||
if (stats.size > MAX_MARKDOWN_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const content = fs.readFileSync(resolvedMarkdownPath, 'utf8');
|
||||
return extractDescription(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMarkdownContent(markdownPath: string, allowedRoots: Set<string>): string | null {
|
||||
try {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile()) {
|
||||
return null;
|
||||
}
|
||||
if (stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fs.readFileSync(resolvedMarkdownPath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReadableMarkdownPath(
|
||||
markdownPaths: string[],
|
||||
allowedRoots: Set<string>
|
||||
): string | null {
|
||||
for (const markdownPath of markdownPaths) {
|
||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(resolvedMarkdownPath);
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
return resolvedMarkdownPath;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSharedItemContent(
|
||||
type: SharedCollectionType,
|
||||
itemPath: string,
|
||||
allowedRoots: Set<string>
|
||||
): { content: string; contentPath: string } | null {
|
||||
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 (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,
|
||||
};
|
||||
}
|
||||
|
||||
function safeRealPath(targetPath: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathWithin(candidatePath: string, basePath: string): boolean {
|
||||
const normalizedCandidate = normalizeForPathComparison(candidatePath);
|
||||
const normalizedBase = normalizeForPathComparison(basePath);
|
||||
const relative = path.relative(normalizedBase, normalizedCandidate);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isPathWithinAny(candidatePath: string, basePaths: Set<string>): boolean {
|
||||
for (const basePath of basePaths) {
|
||||
if (isPathWithin(candidatePath, basePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeForPathComparison(targetPath: string): string {
|
||||
const normalized = path.resolve(targetPath);
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function checkSymlinkStatus(): { valid: boolean; message: string } {
|
||||
const ccsDir = getCcsDir();
|
||||
const sharedDir = path.join(ccsDir, 'shared');
|
||||
|
||||
if (!fs.existsSync(sharedDir)) {
|
||||
return { valid: false, message: 'Shared directory not found' };
|
||||
}
|
||||
|
||||
// Check all three symlinks: commands, skills, agents
|
||||
const linkTypes = ['commands', 'skills', 'agents'];
|
||||
let validLinks = 0;
|
||||
|
||||
for (const linkType of linkTypes) {
|
||||
const linkPath = path.join(sharedDir, linkType);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(linkPath)) {
|
||||
const stats = fs.lstatSync(linkPath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const target = fs.readlinkSync(linkPath);
|
||||
// Check if it points to Claude config dir.
|
||||
const expectedTarget = path.join(getClaudeConfigDir(), linkType);
|
||||
if (path.resolve(path.dirname(linkPath), target) === path.resolve(expectedTarget)) {
|
||||
validLinks++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not a symlink or read error
|
||||
}
|
||||
}
|
||||
|
||||
if (validLinks === linkTypes.length) {
|
||||
return { valid: true, message: 'Symlinks active' };
|
||||
} else if (validLinks > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Symlinks partially configured (${validLinks}/${linkTypes.length})`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: false, message: 'Symlinks not configured' };
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('profile-writer Anthropic direct', () => {
|
||||
it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => {
|
||||
const result = createApiProfile(
|
||||
'openrouter-test',
|
||||
'https://openrouter.ai/api',
|
||||
'https://openrouter.ai/api/v1',
|
||||
'sk-or-testkey',
|
||||
{ default: 'anthropic/claude-opus-4.5', opus: 'anthropic/claude-opus-4.5', sonnet: 'anthropic/claude-opus-4.5', haiku: 'anthropic/claude-opus-4.5' }
|
||||
);
|
||||
@@ -145,7 +145,7 @@ describe('profile-writer Anthropic direct', () => {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
|
||||
// OpenRouter: proxy mode with ANTHROPIC_API_KEY explicitly blank
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api');
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api/v1');
|
||||
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-or-testkey');
|
||||
expect(settings.env.ANTHROPIC_API_KEY).toBe('');
|
||||
});
|
||||
|
||||
@@ -92,6 +92,11 @@ describe('provider-presets', () => {
|
||||
expect(isValidPresetId('hf')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses OpenRouter v1 as the OpenAI-compatible API root', () => {
|
||||
const preset = getPresetById('openrouter');
|
||||
expect(preset?.baseUrl).toBe('https://openrouter.ai/api/v1');
|
||||
});
|
||||
|
||||
it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => {
|
||||
const recommendedPresetIds = PROVIDER_PRESETS.filter(
|
||||
(preset) => preset.category === 'recommended'
|
||||
|
||||
@@ -63,6 +63,35 @@ describe('auth command args parsing', () => {
|
||||
expect(parsed.contextGroup).toBe('sprint-a');
|
||||
});
|
||||
|
||||
it('parses shared resource mode value for resources command', () => {
|
||||
const parsed = parseArgs(['work', '--mode', 'profile-local'], { allowMode: true });
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBe('profile-local');
|
||||
});
|
||||
|
||||
it('parses inline shared resource mode value', () => {
|
||||
const parsed = parseArgs(['work', '--mode=shared'], { allowMode: true });
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBe('shared');
|
||||
});
|
||||
|
||||
it('flags missing shared resource mode value as empty string', () => {
|
||||
const parsed = parseArgs(['work', '--mode'], { allowMode: true });
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBe('');
|
||||
});
|
||||
|
||||
it('treats shared resource mode as unknown unless the command opts in', () => {
|
||||
const parsed = parseArgs(['work', '--mode', 'shared']);
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBeUndefined();
|
||||
expect(parsed.unknownFlags).toEqual(['--mode']);
|
||||
});
|
||||
|
||||
it('tracks unknown flags and keeps positional profile intact', () => {
|
||||
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('auth help account route guidance', () => {
|
||||
it('keeps the help copy explicit about account choice and credential isolation', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src', 'auth', 'auth-commands.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(source).toContain('Create two isolated accounts and choose one explicitly at runtime');
|
||||
expect(source).toContain('ccs auth create work');
|
||||
expect(source).toContain('ccs auth create personal');
|
||||
expect(source).toContain('Account logins, tokens, and .anthropic stay isolated');
|
||||
expect(source).toContain('settings.json');
|
||||
expect(source).toContain('ccs auth show <profile>');
|
||||
expect(source).toContain('History sync is opt-in');
|
||||
expect(source).toContain('ccs auth backup default');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import ProfileRegistry from '../../src/auth/profile-registry';
|
||||
import InstanceManager from '../../src/management/instance-manager';
|
||||
import { handleResources } from '../../src/auth/commands/resources-command';
|
||||
import { handleCreate } from '../../src/auth/commands/create-command';
|
||||
import { handleList } from '../../src/auth/commands/list-command';
|
||||
import { handleShow } from '../../src/auth/commands/show-command';
|
||||
import { handleBackup } from '../../src/auth/commands/backup-command';
|
||||
import { handleRemove } from '../../src/auth/commands/remove-command';
|
||||
import { handleDefault, handleResetDefault } from '../../src/auth/commands/default-command';
|
||||
|
||||
async function expectProfileExit(action: () => Promise<void>): Promise<string> {
|
||||
const originalExit = process.exit;
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const lines: string[] = [];
|
||||
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit(${code ?? 0})`);
|
||||
}) as typeof process.exit;
|
||||
console.log = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(action()).rejects.toThrow('process.exit(7)');
|
||||
} finally {
|
||||
process.exit = originalExit;
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
describe('auth resources command', () => {
|
||||
let tempRoot = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalUnifiedMode: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-resources-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalUnifiedMode !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
|
||||
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
if (tempRoot && fs.existsSync(tempRoot)) {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('updates an existing account shared resource mode without changing context metadata', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: shared',
|
||||
' context_group: sprint-a',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||
path.join(ccsDir, 'instances', 'work')
|
||||
);
|
||||
const lines: string[] = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
await handleResources(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode', 'profile-local', '--json']
|
||||
);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
ensureSpy.mockRestore();
|
||||
}
|
||||
|
||||
const output = JSON.parse(lines.join('\n')) as {
|
||||
shared_resource_mode: string;
|
||||
bare?: boolean;
|
||||
};
|
||||
expect(output.shared_resource_mode).toBe('profile-local');
|
||||
expect(output.bare).toBe(true);
|
||||
|
||||
const account = registry.getAllAccountsUnified().work;
|
||||
expect(account.shared_resource_mode).toBe('profile-local');
|
||||
expect(account.bare).toBe(true);
|
||||
expect(account.context_mode).toBe('shared');
|
||||
expect(account.context_group).toBe('sprint-a');
|
||||
});
|
||||
|
||||
it('rolls back inferred shared metadata when instance reconciliation fails', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue(
|
||||
new Error('reconcile failed')
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handleResources(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode', 'profile-local']
|
||||
)
|
||||
).rejects.toThrow('reconcile failed');
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
}
|
||||
|
||||
const account = registry.getAllAccountsUnified().work;
|
||||
expect(account.shared_resource_mode).toBeUndefined();
|
||||
expect(account.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects resources --mode without a value instead of showing current mode', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
|
||||
const output = await expectProfileExit(() =>
|
||||
handleResources(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode']
|
||||
)
|
||||
);
|
||||
|
||||
expect(output).toContain('Missing value for --mode: expected shared|profile-local');
|
||||
});
|
||||
|
||||
it('rejects --mode on auth create instead of silently ignoring it', async () => {
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
|
||||
const output = await expectProfileExit(() =>
|
||||
handleCreate(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode', 'profile-local']
|
||||
)
|
||||
);
|
||||
|
||||
expect(output).toContain('Unknown option(s): "--mode"');
|
||||
});
|
||||
|
||||
it('rejects --mode on non-resources auth commands instead of silently ignoring it', async () => {
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const ctx = { registry, instanceMgr, version: 'test' };
|
||||
const cases: Array<[string, () => Promise<void>]> = [
|
||||
['list', () => handleList(ctx, ['--mode', 'shared'])],
|
||||
['show', () => handleShow(ctx, ['work', '--mode=shared'])],
|
||||
['backup', () => handleBackup(ctx, ['work', '--mode', 'profile-local'])],
|
||||
['remove', () => handleRemove(ctx, ['work', '--mode', 'shared'])],
|
||||
['default', () => handleDefault(ctx, ['work', '--mode', 'shared'])],
|
||||
['reset-default', () => handleResetDefault(ctx, ['--mode', 'shared'])],
|
||||
];
|
||||
|
||||
for (const [commandName, action] of cases) {
|
||||
const output = await expectProfileExit(action);
|
||||
expect(`${commandName}\n${output}`).toContain('Unknown option(s): "--mode"');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
describeSettingsSync,
|
||||
summarizeAccountHistory,
|
||||
} from '../../../src/auth/account-profile-diagnostics';
|
||||
|
||||
describe('account profile diagnostics', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-diagnostics-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function seedSharedSettings(instancePath: string): void {
|
||||
const claudeDir = path.join(tempHome, '.claude');
|
||||
const sharedDir = path.join(tempHome, '.ccs', 'shared');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
fs.mkdirSync(sharedDir, { recursive: true });
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}\n');
|
||||
fs.symlinkSync(path.join(claudeDir, 'settings.json'), path.join(sharedDir, 'settings.json'));
|
||||
fs.symlinkSync(path.join(sharedDir, 'settings.json'), path.join(instancePath, 'settings.json'));
|
||||
}
|
||||
|
||||
it('reports non-bare account settings as shared with root Claude settings', () => {
|
||||
const instancePath = path.join(tempHome, '.ccs', 'instances', 'ck');
|
||||
seedSharedSettings(instancePath);
|
||||
|
||||
const summary = describeSettingsSync(instancePath);
|
||||
|
||||
expect(summary.state).toBe('shared');
|
||||
expect(summary.description).toBe('shared with ~/.claude/settings.json');
|
||||
expect(summary.root_settings_path).toBe(path.join(tempHome, '.claude', 'settings.json'));
|
||||
});
|
||||
|
||||
it('reports bare account settings as profile-local', () => {
|
||||
const instancePath = path.join(tempHome, '.ccs', 'instances', 'bare');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
fs.writeFileSync(path.join(instancePath, 'settings.json'), '{}\n');
|
||||
|
||||
const summary = describeSettingsSync(instancePath, { bare: true });
|
||||
|
||||
expect(summary.state).toBe('profile-local');
|
||||
expect(summary.description).toContain('bare profile');
|
||||
});
|
||||
|
||||
it('reports bare account settings as missing when the local file is absent', () => {
|
||||
const instancePath = path.join(tempHome, '.ccs', 'instances', 'bare');
|
||||
|
||||
const summary = describeSettingsSync(instancePath, { bare: true });
|
||||
|
||||
expect(summary.state).toBe('missing');
|
||||
expect(summary.description).toContain('bare profile');
|
||||
expect(summary.profile_settings_path).toBe(path.join(instancePath, 'settings.json'));
|
||||
});
|
||||
|
||||
it('summarizes shared project and deeper continuity lane state', () => {
|
||||
const instancePath = path.join(tempHome, '.ccs', 'instances', 'ck');
|
||||
const groupRoot = path.join(tempHome, '.ccs', 'shared', 'context-groups', 'default');
|
||||
const projectsPath = path.join(groupRoot, 'projects');
|
||||
const sessionEnvPath = path.join(groupRoot, 'continuity', 'session-env');
|
||||
fs.mkdirSync(projectsPath, { recursive: true });
|
||||
fs.mkdirSync(sessionEnvPath, { recursive: true });
|
||||
fs.mkdirSync(path.join(projectsPath, 'project-a'));
|
||||
fs.mkdirSync(path.join(projectsPath, 'project-b'));
|
||||
fs.writeFileSync(path.join(sessionEnvPath, 'session-a.json'), '{}\n');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
fs.symlinkSync(projectsPath, path.join(instancePath, 'projects'), 'dir');
|
||||
|
||||
for (const artifact of ['session-env', 'file-history', 'shell-snapshots', 'todos']) {
|
||||
const sharedArtifactPath = path.join(groupRoot, 'continuity', artifact);
|
||||
fs.mkdirSync(sharedArtifactPath, { recursive: true });
|
||||
fs.symlinkSync(sharedArtifactPath, path.join(instancePath, artifact), 'dir');
|
||||
}
|
||||
|
||||
const summary = summarizeAccountHistory(instancePath, {
|
||||
mode: 'shared',
|
||||
group: 'default',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
|
||||
expect(summary.project_count).toBe(2);
|
||||
expect(summary.session_count).toBe(1);
|
||||
expect(summary.projects_shared).toBe(true);
|
||||
expect(summary.deeper_artifacts_shared).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,7 @@ describe('profile-registry context normalization', () => {
|
||||
|
||||
const profile = registry.getProfile('work');
|
||||
expect(profile.bare).toBe(true);
|
||||
expect(profile.shared_resource_mode).toBe('profile-local');
|
||||
});
|
||||
|
||||
it('persists bare flag for unified accounts and merged projection', () => {
|
||||
@@ -126,8 +127,52 @@ describe('profile-registry context normalization', () => {
|
||||
|
||||
const accounts = registry.getAllAccountsUnified();
|
||||
expect(accounts.work.bare).toBe(true);
|
||||
expect(accounts.work.shared_resource_mode).toBe('profile-local');
|
||||
|
||||
const merged = registry.getAllProfilesMerged();
|
||||
expect(merged.work.bare).toBe(true);
|
||||
expect(merged.work.shared_resource_mode).toBe('profile-local');
|
||||
});
|
||||
|
||||
it('lets explicit shared resource mode override stale unified bare metadata', () => {
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-03-05T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' shared_resource_mode: shared',
|
||||
' bare: true',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const accounts = registry.getAllAccountsUnified();
|
||||
|
||||
expect(accounts.work.shared_resource_mode).toBe('shared');
|
||||
expect(accounts.work.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists explicit profile-local mode for legacy profiles', () => {
|
||||
const registry = new ProfileRegistry();
|
||||
registry.createProfile('work', {
|
||||
type: 'account',
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
|
||||
const profile = registry.getProfile('work');
|
||||
expect(profile.shared_resource_mode).toBe('profile-local');
|
||||
expect(profile.bare).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
normalizeSharedResourceMetadata,
|
||||
resolveSharedResourcePolicy,
|
||||
sharedResourceModeToMetadata,
|
||||
} from '../../../src/auth/shared-resource-policy';
|
||||
|
||||
describe('shared resource policy', () => {
|
||||
it('defaults missing metadata to shared resources', () => {
|
||||
const policy = resolveSharedResourcePolicy(undefined);
|
||||
|
||||
expect(policy.mode).toBe('shared');
|
||||
expect(policy.inferred).toBe(true);
|
||||
expect(policy.profileLocal).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves legacy bare metadata as profile-local', () => {
|
||||
const policy = resolveSharedResourcePolicy({ bare: true });
|
||||
|
||||
expect(policy.mode).toBe('profile-local');
|
||||
expect(policy.inferred).toBe(true);
|
||||
expect(policy.profileLocal).toBe(true);
|
||||
});
|
||||
|
||||
it('lets explicit shared mode override stale bare metadata', () => {
|
||||
const normalized = normalizeSharedResourceMetadata({
|
||||
shared_resource_mode: 'shared',
|
||||
bare: true,
|
||||
});
|
||||
|
||||
expect(normalized.shared_resource_mode).toBe('shared');
|
||||
expect(normalized.bare).toBeUndefined();
|
||||
expect(resolveSharedResourcePolicy(normalized).profileLocal).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes explicit profile-local mode to legacy-compatible bare metadata', () => {
|
||||
const normalized = normalizeSharedResourceMetadata({
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
|
||||
expect(normalized.shared_resource_mode).toBe('profile-local');
|
||||
expect(normalized.bare).toBe(true);
|
||||
});
|
||||
|
||||
it('builds write metadata for both supported modes', () => {
|
||||
expect(sharedResourceModeToMetadata('profile-local')).toEqual({
|
||||
shared_resource_mode: 'profile-local',
|
||||
bare: true,
|
||||
});
|
||||
expect(sharedResourceModeToMetadata('shared')).toEqual({
|
||||
shared_resource_mode: 'shared',
|
||||
bare: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,18 @@ describe('cliproxy routing command dispatch', () => {
|
||||
handleRoutingSet: async (args: string[]) => {
|
||||
calls.push(`set:${args.join(' ')}`);
|
||||
},
|
||||
// Mock module must expose every named export `cliproxy/index.ts`
|
||||
// statically imports from this module, otherwise Bun reports
|
||||
// `Export named 'X' not found` at module-graph resolution time.
|
||||
handleRoutingAffinityStatus: async () => {
|
||||
calls.push('affinity:status');
|
||||
},
|
||||
handleRoutingAffinityHelp: async () => {
|
||||
calls.push('affinity:help');
|
||||
},
|
||||
handleRoutingAffinitySet: async (args: string[]) => {
|
||||
calls.push(`affinity:set:${args.join(' ')}`);
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('sync command MCP sync behavior', () => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('syncs MCP servers only to non-bare profiles', async () => {
|
||||
it('syncs MCP servers only to shared-resource profiles', async () => {
|
||||
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
|
||||
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
|
||||
success: true,
|
||||
@@ -42,6 +42,8 @@ describe('sync command MCP sync behavior', () => {
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
work: profile(),
|
||||
sandbox: profile({ bare: true }),
|
||||
local: profile({ shared_resource_mode: 'profile-local' }),
|
||||
restored: profile({ bare: true, shared_resource_mode: 'shared' }),
|
||||
personal: profile(),
|
||||
});
|
||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||
@@ -55,8 +57,16 @@ describe('sync command MCP sync behavior', () => {
|
||||
|
||||
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
|
||||
|
||||
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual(['work', 'personal']);
|
||||
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual(['/tmp/work', '/tmp/personal']);
|
||||
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||
'work',
|
||||
'restored',
|
||||
'personal',
|
||||
]);
|
||||
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||
'/tmp/work',
|
||||
'/tmp/restored',
|
||||
'/tmp/personal',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips MCP sync when all profiles are bare', async () => {
|
||||
@@ -69,7 +79,7 @@ describe('sync command MCP sync behavior', () => {
|
||||
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
sandbox: profile({ bare: true }),
|
||||
experiment: profile({ bare: true }),
|
||||
experiment: profile({ shared_resource_mode: 'profile-local' }),
|
||||
});
|
||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||
|
||||
|
||||
@@ -177,6 +177,43 @@ describe('migration-manager legacy kimi compatibility', () => {
|
||||
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates legacy bare accounts to profile-local shared resource mode', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
default: 'sandbox',
|
||||
profiles: {
|
||||
sandbox: {
|
||||
type: 'account',
|
||||
created: '2026-02-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
bare: true,
|
||||
},
|
||||
restored: {
|
||||
type: 'account',
|
||||
created: '2026-02-02T00:00:00.000Z',
|
||||
last_used: null,
|
||||
shared_resource_mode: 'shared',
|
||||
bare: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const result = await migrate(false);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const unified = loadUnifiedConfig();
|
||||
expect(unified?.accounts.sandbox.shared_resource_mode).toBe('profile-local');
|
||||
expect(unified?.accounts.sandbox.bare).toBe(true);
|
||||
expect(unified?.accounts.restored.shared_resource_mode).toBe('shared');
|
||||
expect(unified?.accounts.restored.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies safe browser defaults when migrating legacy config files', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user