Merge pull request #1212 from kaitranntt/dev

feat: release 7.77.1 — shared resources, OAuth diagnostics, Codex fast tier
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-10 22:49:23 -04:00
committed by GitHub
140 changed files with 10599 additions and 964 deletions
+2 -1
View File
@@ -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
+40 -2
View File
@@ -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`
+7 -7
View File
@@ -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
+8 -4
View File
@@ -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 |
+74 -3
View File
@@ -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
+14 -8
View File
@@ -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
View File
@@ -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",
+140
View File
@@ -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
View File
@@ -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':
+8 -3
View File
@@ -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');
}
+29 -20
View File
@@ -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` +
+11 -3
View File
@@ -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
+1
View File
@@ -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';
+23 -4
View File
@@ -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('');
+6 -2
View File
@@ -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'));
+154
View File
@@ -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('');
}
+57 -4
View File
@@ -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}`],
];
+75 -1
View File
@@ -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],
};
+2 -1
View File
@@ -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 {
+1
View File
@@ -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,
},
};
+7 -2
View File
@@ -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,
};
}
+97
View File
@@ -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
View File
@@ -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']);
});
});
+149 -2
View File
@@ -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(
+95 -3
View File
@@ -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;
}
+13
View File
@@ -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';
+119
View File
@@ -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())
);
});
+14 -8
View File
@@ -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}`;
}
}
+4
View File
@@ -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',
+17 -1
View File
@@ -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;
}
+191 -30
View File
@@ -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;
+105 -13
View File
@@ -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;
}
+70 -12
View File
@@ -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),
});
}
}
+3 -2
View File
@@ -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',
+7
View File
@@ -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'));
+7 -1
View File
@@ -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('');
+2 -1
View File
@@ -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
);
+3 -2
View File
@@ -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)) {
+6 -3
View File
@@ -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.');
+11
View File
@@ -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');
+2
View File
@@ -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 -2
View File
@@ -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 !!
+1 -1
View File
@@ -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;
+114 -36
View File
@@ -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) {
+1 -1
View File
@@ -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',
+6 -4
View File
@@ -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)
+1 -1
View File
@@ -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,
+10
View File
@@ -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();
+6 -1
View File
@@ -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 {
+1 -1
View File
@@ -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.
+2
View File
@@ -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;
}
+20 -2
View File
@@ -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;
}
+108 -4
View File
@@ -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
*/
+51 -1
View File
@@ -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.
+192
View File
@@ -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;
}
+157
View File
@@ -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;
}
+163
View File
@@ -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,
};
}
+199
View File
@@ -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,
};
}
+32
View File
@@ -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
View File
@@ -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('');
});
+5
View File
@@ -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'
+29
View File
@@ -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');
});
});
+248
View File
@@ -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(' ')}`);
},
}));
});
+14 -4
View File
@@ -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