Merge pull request #1580 from kaitranntt/dev

feat: promote dev to main (CCS Bar v1.7.0, maintainability epic, provider fixes)
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-21 13:37:22 -04:00
commit f9047d00a8
257 files changed
+17573 -9171

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
name: Bar Release
# Build and publish the macOS CCS Bar app to the floating `ccs-bar-latest`
# release asset (what `ccs bar install` downloads).
#
# Scoped deliberately so it NEVER burdens other PRs or CI:
# - Triggers ONLY on push to `main` that changes `macos-bar/**`, or a manual
# run. Regular PRs, dev pushes, and non-bar changes do not start it.
# - Runs ONLY on the dedicated self-hosted macOS runner (label `ccs-bar` on
# kai-minim4). The Linux CI runners never match this job, and this job never
# competes for them.
#
# Version source: `macos-bar/VERSION` (single line semver). Bump it in the same
# PR when you want a new number; the asset is always "latest" regardless.
on:
push:
branches: [main]
paths:
- 'macos-bar/**'
workflow_dispatch:
# Least privilege: only the release-asset write the publish step needs.
permissions:
contents: write
# One release at a time; never cancel a publish midway.
concurrency:
group: bar-release
cancel-in-progress: false
jobs:
release:
name: Build and publish CCS Bar
runs-on: [self-hosted, macos, ccs-bar]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Read bar version
id: ver
run: |
version="$(tr -d '[:space:]' < macos-bar/VERSION)"
if [ -z "$version" ]; then
echo "::error file=macos-bar/VERSION::macos-bar/VERSION is empty"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "[i] CCS Bar version: $version"
- name: Build and package CCS Bar.app
working-directory: macos-bar
run: ./Scripts/package_app.sh "${{ steps.ver.outputs.version }}"
- name: Publish to ccs-bar-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
# gh lives in Homebrew on the self-hosted runner; ensure it is on PATH.
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
gh release upload ccs-bar-latest macos-bar/dist/CCS-Bar.app.zip --clobber
notes="$(mktemp)"
cat > "$notes" <<NOTES
CCS Bar v${VERSION}
Install or update: ccs bar install
Ad-hoc signed: first launch may need right-click then Open.
Built automatically from main when macos-bar changes.
NOTES
gh release edit ccs-bar-latest --title "CCS Bar v${VERSION}" --notes-file "$notes"
rm -f "$notes"
echo "[OK] Published CCS Bar v${VERSION} to ccs-bar-latest"
+2 -2
View File
@@ -139,7 +139,7 @@ jobs:
COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true)
ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \
perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*+(?:,|and)?\s*+#[0-9]+\b)*))/ig) { print "$1\n"; }' | \
grep -oE '#[0-9]+' || true)
PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \
@@ -155,7 +155,7 @@ jobs:
done
ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \
perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*+(?:,|and)?\s*+#[0-9]+\b)*))/ig) { print "$1\n"; }' | \
grep -oE '#[0-9]+' || true)
ISSUES=$(printf '%s\n%s\n' "$ISSUES_FROM_COMMITS" "$ISSUES_FROM_PRS" | \
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
$PR_BODY
$COMMIT_TEXT"
PR_ISSUES=$(printf '%s\n' "$PR_TEXT" | \
perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \
perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*+(?:,|and)?\s*+#[0-9]+\b)*))/ig) { print "$1\n"; }' | \
grep -oE '#[0-9]+' || true)
if [[ -n "$PR_ISSUES" ]]; then
ALL_REFERENCED_ISSUES=$(printf '%s\n%s\n' "$ALL_REFERENCED_ISSUES" "$PR_ISSUES")
+5 -2
View File
@@ -61,10 +61,13 @@ 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, Kiro, Claude, Qwen, Kimi, and more, with legacy
- OAuth providers like Codex, Kiro, Claude, 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
Novita, Fireworks AI, and Alibaba Coding Plan
Qwen Code account linking is not available in the bundled CLIProxy runtime yet;
use an API-key Qwen profile such as Alibaba Coding Plan for Qwen models.
The goal is simple: stop rewriting config files, stop breaking active sessions,
and move between providers in seconds.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/claude",
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed"
}
}
+4 -4
View File
@@ -2,9 +2,9 @@
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE",
"ANTHROPIC_MODEL": "glm-5",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5"
"ANTHROPIC_MODEL": "glm-5.2",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.2",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.2",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5.2"
}
}
+4 -4
View File
@@ -2,10 +2,10 @@
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/coding/paas/v4/chat/completions",
"ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE",
"ANTHROPIC_MODEL": "glm-5",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5",
"ANTHROPIC_MODEL": "glm-5.2",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.2",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.2",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5.2",
"ANTHROPIC_TEMPERATURE": "0.2",
"ANTHROPIC_MAX_TOKENS": "65536",
"MAX_THINKING_TOKENS": "32768",
+4 -4
View File
@@ -2,9 +2,9 @@
"env": {
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/",
"ANTHROPIC_AUTH_TOKEN": "YOUR_KIMI_API_KEY_HERE",
"ANTHROPIC_MODEL": "kimi-k2-thinking-turbo",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-k2-thinking-turbo",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2-thinking-turbo",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kimi-k2-thinking-turbo"
"ANTHROPIC_MODEL": "kimi-for-coding",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-for-coding",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-for-coding",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kimi-for-coding"
}
}
+4 -4
View File
@@ -2,9 +2,9 @@
"env": {
"ANTHROPIC_BASE_URL": "https://api.minimax.io/anthropic",
"ANTHROPIC_AUTH_TOKEN": "YOUR_MINIMAX_API_KEY_HERE",
"ANTHROPIC_MODEL": "MiniMax-M2.1",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M2.1",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M2.1",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M2.1-lightning"
"ANTHROPIC_MODEL": "MiniMax-M3",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M3",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M3",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M3"
}
}
+29 -8
View File
@@ -35,7 +35,7 @@ If `CCS Bar.app` is already installed, the command shows the current version and
After installation, CCS reads the app version directly from the bundle's `Info.plist` and pins it to `~/.ccs/bar/.version`. It then performs a reachability check against the bar API (`GET /api/bar/summary`). A 404 response means the running CCS server predates CCS Bar support — update CCS to a version that includes CCS Bar, then restart `ccs bar`.
After a successful install, CCS asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely:
After a successful install, CCS leaves the macOS Gatekeeper quarantine marker in place so macOS can perform its normal first-launch verification. CCS then asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely:
```bash
ccs bar install --launch # install and launch immediately
@@ -44,13 +44,9 @@ ccs bar install --no-launch # install, skip launch prompt
### Gatekeeper note
The install command automatically clears the macOS Gatekeeper quarantine attribute (`xattr -dr com.apple.quarantine`) on the downloaded app. If clearing fails for any reason, the command falls back to printing the manual command:
The install command does not automatically clear the macOS Gatekeeper quarantine attribute on the downloaded app. This preserves macOS first-launch verification for the floating release download.
```bash
xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app"
```
Or right-click the app and choose Open.
If macOS blocks the app on first launch, make an explicit trust decision by right-clicking the app and choosing Open.
## Run
@@ -109,7 +105,7 @@ This removes `~/Applications/CCS Bar.app` and the installed version pin. It is a
- Install fails with "server predates CCS Bar" or bar API returns 404: the CCS server running does not yet include CCS Bar. Update CCS (`npm i -g ccs@latest` or equivalent), then restart `ccs bar`.
- Server failed to start: `ccs bar` first checks whether a CCS server is already running on the candidate ports (3000, 3001, 3002, 8000, 8080) and reuses it if found. A true failure here means a non-CCS process is occupying all candidate ports. Free one of those ports and re-run `ccs bar`. Check `~/.ccs/bar/serve.log` for the background server's output.
- App won't open (Gatekeeper): right-click and Open, or clear quarantine with the `xattr` command above.
- App won't open (Gatekeeper): right-click the app and choose Open to make an explicit trust decision.
- Menu shows "CCS is not running": open the menu again to let the app start the server, or run `ccs bar status` to check and `ccs bar` to start it.
- Quota not updating: re-open the menu to force a refresh, or confirm the server is still reachable on loopback.
@@ -121,3 +117,28 @@ The source lives in `macos-bar/`. Contributors can build and run the logic check
swift build # build all targets, including the app
swift run ccs-bar-check # run the logic tests
```
## Releasing
The app ships as a single floating GitHub release asset, `CCS-Bar.app.zip` under the `ccs-bar-latest` tag, which is what `ccs bar install` downloads. The version comes from one file: `macos-bar/VERSION` (a single line of semver). Bump it in the same PR when you want a new number; the asset is always the latest build regardless.
### Automatic (preferred)
The `Bar Release` workflow (`.github/workflows/bar-release.yml`) builds and publishes the asset automatically. It is scoped tightly so it never affects other PRs or CI:
- It runs only on a push to `main` that touches `macos-bar/**`, or a manual run from the Actions tab (`workflow_dispatch`).
- It runs only on the dedicated self-hosted macOS runner (label `ccs-bar`); the Linux CI runners never pick it up and it never competes for them.
So bar changes reach users when they land on `main` (the stable cadence). To cut a release without a code change, or to re-publish, trigger the workflow manually.
### Manual fallback
From a macOS machine with the Swift toolchain and `gh`:
```bash
cd macos-bar
./Scripts/package_app.sh # uses macos-bar/VERSION; pass a version to override
gh release upload ccs-bar-latest dist/CCS-Bar.app.zip --clobber
```
Builds are ad-hoc signed by default. Developer ID notarization (for the no-prompt public install) is available via `CCS_BAR_SIGNING=developer-id` once a paid Apple cert is configured.
+12
View File
@@ -730,6 +730,18 @@ This pattern is used in:
---
## Lint Enforcement Gates
Two ESLint gates (`eslint.config.mjs`) lock in the maintainability epic's gains:
- **`ccs/no-new-throw-error`** (error): flags new `throw new Error(...)`. Use a typed error from `src/errors/error-types.ts` (`AuthError`, `ConfigError`, `ProfileError`, `ProviderError`, `NetworkError`, `ProxyError`, `MigrationError`, `ValidationError`, `RetryableError`) so `handleError` emits a differentiated exit code. Existing ~340 sites are grandfathered in `eslint-rules/throw-error-baseline.json`; only **new** violations error. Regenerate the baseline when intentionally grandfathering a new site, or quarterly to prune converted entries:
```bash
node scripts/generate-throw-error-baseline.js
```
- **`max-lines`** (warn, 400): warns on source files over 400 lines (`skipBlankLines`, `skipComments`). Split via the Monster File Splitting methodology above (barrel `index.ts` preserves the public API).
When the no-throw rule blocks a change, prefer converting to the matching typed error. Only add to the baseline when the throw is genuinely out of scope to convert (and regenerate the baseline so the entry is explicit, not silent).
## Related Documentation
- [Codebase Summary](./codebase-summary.md) - Full directory structure
+69 -2
View File
@@ -1,7 +1,7 @@
# Hardening Debt Burndown Tracker
Last Updated: 2026-02-12
Owner: Stream D (`#542`)
Last Updated: 2026-06-18
Owner: Stream D (`#542`); maintainability epic owner TBD (open Q5)
## Scope
@@ -43,3 +43,70 @@ Baseline captured: `2026-02-12`.
| Date | Area | Change | Safety Notes |
|---|---|---|---|
| 2026-02-12 | `src/web-server/jsonl-parser.ts` | Migrated `parseProjectDirectory()` directory listing from sync `readdirSync` to async `fs.promises.readdir` | Existing behavior kept (same filtering/fallback); covered by `tests/unit/jsonl-parser.test.ts` |
## Maintainability & Traceability Baseline (2026-06-18)
Baseline for the maintainability/traceability epic (`plans/260618-1346-maintainability-traceability-epic`). Sourced from `docs/reports/hardening-inventory.json` -> `maintainability` block after `bun run report:hardening`. Baseline captured: `2026-06-18`.
| Metric | Baseline | Epic target | Owner phase |
|---|---:|---:|---|
| typed-error adoption (typed / total throws) | 0.9% (4 / 431) | >40% in locked subdomains | P4 |
| typed-error adoption (locked: cliproxy/quota, cliproxy/auth, web-server/routes, auth) | 0.0% (0 / 23) | >40% | P4 |
| hotpath `console.error`/`warn` occurrences (non-exempt) | 931 (1091 total, 160 CLI-UX exempt) | < 10 | P3 |
| hotpath `console.error`/`warn` files (non-exempt) | 134 | minimal | P3 |
| files with `createLogger` | 35 / 685 (5.1%) | rise across all subdomains | P2/P3 |
| subdomains with zero `createLogger` | 20 (incl. api, channels, config, delegation, dispatcher, docker, shared) | 0 in the named set | P2 |
| files > 400 LOC | 95 | < 60 after P5+P6 | P5/P6 |
| files > 600 LOC | 45 | drop | P5/P6 |
| ESLint `no-new-throw-error` gate | not enforced | error + allowlist | P7 |
| ESLint `max-lines` gate | not enforced | warn at 400 | P7 |
| hardening report freshness | stale (2026-02-12) | < 30d gate in `validate:ci-parity` | P1 |
### Method
Metrics are grep-based and approximate (not a contract). Comments and string/template/regex literals are stripped before matching (`scripts/hardening-inventory.js#stripComments`). Subdomain granularity is 2-level under `src/cliproxy/` (`cliproxy/quota`, `cliproxy/auth`, ...) and 1-level elsewhere (`auth`, `config`, ...). The hotpath `console.error` count excludes CLI-UX print surfaces (`src/commands/`, `src/management/`, `src/utils/ui/`) which are legitimate user-facing terminal output. The typed-error denominator for the P4 target is LOCKED to the four named subdomains so the >40% goal cannot be gamed by narrowing scope. Re-baseline whenever the schema or method changes.
### Largest hotpath console.error offenders (2026-06-18)
| File | `console.error`/`warn` |
|---|---:|
| `src/utils/error-manager.ts` | 142 |
| `src/cliproxy/accounts/account-safety.ts` | 56 |
| `src/cliproxy/config/model-config.ts` | 32 |
| `src/cliproxy/executor/arg-parser.ts` | 26 |
| `src/dispatcher/flows/settings-flow.ts` | 26 |
## Progress Log
| Date | Phase | Change | Metric movement |
|---|---|---|---|
| 2026-06-18 | P2 | Express `withRequestContext` wrap; `CCS_REQUEST_ID` daemon forwarding + child re-anchor; logger toe-holds in delegation/docker. | zero-createLogger subdomains 20 -> 18 |
| 2026-06-18 | P3 | Redaction gate (token-shape scrubbing in context + `Error.message` + message string). `tool-sanitization-proxy` private log subsystem deleted (13 sites -> existing `createLogger`). ~120 diagnostic `console.error` -> structured `createLogger` across proxy, web-server/routes, glmt, quota-fetchers, executors, delegation. User-facing `console.error` (CLI flows, arg-parser usage, installers, prompts, adapter launch errors, error display) migrated to `process.stderr.write` (preserves stderr output). `error-manager.ts` reclassified CLI-UX-exempt (user-facing display). | hotpath `console.error` 928 -> 267 (71%); createLogger files 35 -> 64 |
| 2026-06-18 | P4 | `throw new Error` -> typed subclasses (ProfileError/AuthError/ConfigError/ProviderError/ValidationError) in cliproxy/auth, web-server/routes, auth. Error taxonomy made erasable (enum -> const, param-props -> fields) so the UI build accepts it. | typed adoption (locked subdomains) 0/23 -> 21/23 (91.3%); overall 0.9% -> 8.6% |
| 2026-06-18 | P5 | Split 4 test-backed god-files into submodule dirs + barrels (shared-manager, cliproxy-stats-routes, persist-command, quota-subcommand). Public API preserved. | files > 400 LOC 95 -> 91 |
| 2026-06-18 | P6 | Split 2 of 6 characterization-first god-files (quota-fetcher, quota-fetcher-gemini-cli; both had strong per-provider test coverage). 4 deferred. | files > 400 LOC 91 -> 89 |
| 2026-06-18 | P7 | ESLint gates: `ccs/no-new-throw-error` (error, baseline-allowlisted) + `max-lines` (warn, 400). code-standards.md + logging-contract.md (error.code table). | gates enforced (0 lint errors on baseline) |
## Epic outcome (2026-06-18)
| Metric | Baseline | Final | Target | Status |
|---|---:|---:|---:|---|
| typed-error adoption (locked subdomains) | 0.0% (0/23) | 91.3% (21/23) | >40% | met |
| typed-error adoption (overall) | 0.9% (5/431) | 8.6% (37/431) | rise | met |
| hotpath `console.error`/`warn` | 928 | 267 | <10 | partial; high-risk diagnostics migrated + redaction gate closed, but metric target unmet (see P3 note) |
| files with `createLogger` | 35 | 64 | rise | met |
| subdomains with zero `createLogger` | 20 | 15 | 0 in named set | partial; logger toe-holds improved coverage but P2 zero-subdomain target unmet |
| files > 400 LOC | 95 | 89 | <60 | partial (P6 deferred 4 targets) |
| hardening report freshness | stale | <30d gate | gate | met (P1) |
| ESLint `no-new-throw-error` | not enforced | error + allowlist | enforced | met (P7) |
| ESLint `max-lines` | not enforced | warn at 400 | enforced | met (P7) |
### Deferred follow-ups
- P2 remaining zero-logger subdomains (`api`, `channels`, `config`, `dispatcher`, `shared`, and others): add lightweight logger toe-holds when those subdomains next receive behavior work. This epic improved coverage but did not satisfy the original `0` target.
- P3 remaining 267 non-exempt `console.error`/`warn` call sites: split into true user-facing terminal output vs diagnostics, then either migrate diagnostics to structured logs or update the metric method to exempt confirmed CLI display helpers.
- P6 remaining 4 targets (`oauth-handler.ts`, `cursor-executor.ts`, `tool-sanitization-proxy.ts`, +1): need dedicated characterization-test work before splitting (the plan's characterization-first hard gate). Each has a clean seam identified in the epic plan.
### P3 residual note (2026-06-18)
The remaining 267 non-exempt `console.error`/`warn` occurrences are not fully resolved by this epic. Many are user-facing terminal display paths (interactive flows, arg-parser usage errors, installers, prompts, adapter launch failures, error-display helpers), but the generated metric still counts them because its exemption list is intentionally conservative. Treat P3 as partial until the residual list is classified file-by-file and either migrated to structured logs or explicitly moved into the CLI-UX exemption method. The redaction gate now covers structured message, context, `Error.message`, and `StageOptions.error` metadata so further diagnostic migration is safe.
+19
View File
@@ -146,6 +146,25 @@ Use `stage()` whenever the entry corresponds to one of the canonical lifecycle s
Default level is `info`. Configure via `logging.level` in `~/.ccs/config.yaml`. Streaming providers MUST gate per-chunk metrics behind `debug`.
## `error.code` values (exit codes)
Typed errors (`src/errors/error-types.ts`) carry an `ExitCode` that `handleError` propagates to `process.exit`. Log readers can branch on `error.code` for differentiated handling. The full mapping lives in `src/errors/exit-codes.ts`; the per-class assignment:
| Typed class | ExitCode | Value |
|---|---|---:|
| `ConfigError` | `CONFIG_ERROR` | 2 |
| `NetworkError` | `NETWORK_ERROR` | 3 (recoverable) |
| `AuthError` | `AUTH_ERROR` | 4 |
| `BinaryError` | `BINARY_ERROR` | 5 |
| `ProviderError` | `PROVIDER_ERROR` | 6 (recoverable) |
| `ProfileError` | `PROFILE_ERROR` | 7 |
| `ProxyError` | `PROXY_ERROR` | 8 |
| `MigrationError` | `MIGRATION_ERROR` | 9 |
| `UserAbortError` | `USER_ABORT` | 130 |
| `ValidationError`, `RetryableError` | `GENERAL_ERROR` | 1 |
New throws must use a typed class (enforced by `ccs/no-new-throw-error`, see `docs/code-standards.md`). Redaction scrubs credential token shapes in both context values and message strings, so routing errors into the logger is safe — but keep messages clean prose and put sensitive data in context under a sensitive key (auto-redacted).
## Backward Compatibility
- All new `LogEntry` fields (`requestId`, `stage`, `latencyMs`, `error`) are optional. Old readers ignore them.
+8
View File
@@ -327,6 +327,14 @@ That flag is respected by both:
- CCS now preserves upstream rate-limit errors and retry headers
- Empty or malformed provider JSON is returned as Anthropic-style `api_error`
### Slow upstreams: `socket connection was closed unexpectedly`
- Long-running upstreams (self-hosted LLMs with queue/prefill phases) can stay
silent for minutes before the first or next token. The proxy already allows up
to 10 minutes per request; set `CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS` in the
profile settings to raise or lower that ceiling.
- Restart the proxy after changing the setting.
### Requests route to the wrong model/profile
- Use an explicit selector such as `profile:model`
File diff suppressed because it is too large. Load diff
+85 -26
View File
@@ -6,43 +6,102 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
| Metric | Value |
|---|---:|
| Sync fs occurrences (all) | 835 |
| Sync fs files affected (all) | 100 |
| Sync fs occurrences (runtime hotpaths) | 724 |
| Sync fs files affected (runtime hotpaths) | 89 |
| Legacy shim markers | 131 |
| Legacy shim files affected | 56 |
| Sync fs occurrences (all) | 2301 |
| Sync fs files affected (all) | 247 |
| Sync fs occurrences (runtime hotpaths) | 1946 |
| Sync fs files affected (runtime hotpaths) | 197 |
| Legacy shim markers | 427 |
| Legacy shim files affected | 164 |
## Top Runtime Hotpath Sync fs Files
| File | Sync Calls | API Names |
|---|---:|---|
| `src/management/shared-manager.ts` | 60 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync |
| `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync |
| `src/utils/shell-completion.ts` | 23 | appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync |
| `src/web-server/routes/settings-routes.ts` | 23 | copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync |
| `src/utils/claude-dir-installer.ts` | 21 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync |
| `src/cliproxy/binary/version-cache.ts` | 20 | existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync |
| `src/management/recovery-manager.ts` | 20 | copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync |
| `src/web-server/routes/cliproxy-stats-routes.ts` | 20 | closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync |
| `src/web-server/routes/misc-routes.ts` | 20 | copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync |
| `src/web-server/routes/persist-routes.ts` | 17 | closeSync, copyFileSync, existsSync, lstatSync, openSync, readdirSync, readSync, renameSync, unlinkSync, writeFileSync |
| `src/cliproxy/__tests__/pool-routing-phase3.test.ts` | 96 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
| `src/cliproxy/config/__tests__/config-generator.test.js` | 88 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
| `src/cliproxy/config/__tests__/claude-model-neutral.test.ts` | 63 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync |
| `src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts` | 48 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
| `src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts` | 45 | existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync |
| `src/cliproxy/executor/__tests__/variant-port-integration.test.js` | 36 | existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync |
| `src/cliproxy/executor/__tests__/composite-variant-service.test.ts` | 33 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
| `src/cliproxy/executor/__tests__/variant-port-edge-cases.test.js` | 33 | existsSync, mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync |
| `src/utils/browser/mcp-installer.ts` | 32 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync |
| `src/cliproxy/__tests__/session-tracker-port.test.js` | 31 | existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync |
## Top Legacy Shim Marker Files
| File | Marker Count |
|---|---:|
| `src/auth/profile-detector.ts` | 18 |
| `src/utils/config-manager.ts` | 13 |
| `src/auth/profile-detector.ts` | 11 |
| `src/config/unified-config-loader.ts` | 9 |
| `src/commands/setup-command.ts` | 7 |
| `src/management/checks/config-check.ts` | 6 |
| `src/web-server/routes/account-routes.ts` | 6 |
| `src/config/migration-manager.ts` | 5 |
| `src/api/services/profile-writer.ts` | 4 |
| `src/cliproxy/quota-fetcher-gemini-cli.ts` | 4 |
| `src/auth/profile-registry.ts` | 3 |
| `src/cliproxy/__tests__/pool-onboarding-phase5.test.ts` | 12 |
| `src/cliproxy/executor/__tests__/variant-port-allocation.test.js` | 12 |
| `src/config/schemas/websearch.ts` | 10 |
| `src/commands/cursor-command-display.ts` | 9 |
| `src/config/migration-manager.ts` | 9 |
| `src/cliproxy/config/__tests__/env-builder-provider-url.test.ts` | 8 |
| `src/cliproxy/executor/__tests__/variant-port-edge-cases.test.js` | 8 |
| `src/cliproxy/config/__tests__/config-generator.test.js` | 7 |
## Explicit Shim/Re-export Files
- `src/cliproxy/openai-compat-manager.ts`
- `src/cliproxy/__tests__/model-catalog-compat.test.ts`
- `src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts`
- `src/cliproxy/ai-providers/__tests__/openai-compat-manager.test.js`
- `src/cliproxy/ai-providers/openai-compat-manager.ts`
- `src/cliproxy/types/__tests__/types-backward-compat.test.ts`
- `src/utils/profile-compat.ts`
- `src/web-server/services/compatible-cli-docs-registry.ts`
## Maintainability Metrics
| Metric | Value |
|---|---:|
| typed-error adoption (typed/total throws) | 8.6% (37/431) |
| typed-error adoption (P4 locked subdomains) | 91.3% (21/23), target 40% |
| hotpath console.error/warn occurrences | 267 (569 total, 302 CLI-UX exempt) |
| hotpath console.error/warn files | 82 |
| files with createLogger | 64/745 |
| subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) |
| files > 400 LOC | 89 |
| files > 600 LOC | 39 |
### Top Hotpath console.error/warn Files
| File | console.error/warn |
|---|---:|
| `src/errors/error-handler.ts` | 11 |
| `src/utils/prompt.ts` | 11 |
| `src/utils/websearch/profile-hook-injector.ts` | 10 |
| `src/cliproxy/accounts/account-safety-cross-lane.ts` | 9 |
| `src/utils/hooks/image-analyzer-profile-hook-injector.ts` | 9 |
| `src/utils/websearch/hook-installer.ts` | 8 |
| `src/cliproxy/auth/token-manager.ts` | 7 |
| `src/cliproxy/binary/downloader.ts` | 7 |
| `src/cliproxy/executor/account-resolution.ts` | 7 |
| `src/config/unified-config-loader.ts` | 7 |
| `src/targets/claude-adapter.ts` | 7 |
| `src/utils/shell-executor.ts` | 7 |
| `src/utils/websearch/hook-config.ts` | 7 |
| `src/targets/droid-detector.ts` | 6 |
| `src/utils/hooks/image-analyzer-hook-installer.ts` | 6 |
### Files > 400 LOC (top 15)
| File | LOC |
|---|---:|
| `src/web-server/routes/cliproxy-auth-routes.ts` | 1515 |
| `src/cliproxy/auth/oauth-handler.ts` | 1455 |
| `src/cursor/cursor-executor.ts` | 1234 |
| `src/web-server/model-pricing.ts` | 1070 |
| `src/web-server/routes/settings-routes.ts` | 1041 |
| `src/cliproxy/config/env-builder.ts` | 1037 |
| `src/cliproxy/proxy/tool-sanitization-proxy.ts` | 1020 |
| `src/cliproxy/auth/oauth-process.ts` | 1018 |
| `src/cliproxy/config/generator.ts` | 1012 |
| `src/commands/cliproxy/variant-subcommand.ts` | 978 |
| `src/cliproxy/quota/quota-manager.ts` | 954 |
| `src/web-server/services/codex-dashboard-service.ts` | 940 |
| `src/glmt/glmt-proxy.ts` | 939 |
| `src/cliproxy/accounts/registry.ts` | 871 |
| `src/channels/official-channels-runtime.ts` | 867 |
@@ -0,0 +1,45 @@
# Typed-Error Exit-Code Compat Audit (P4)
Date: 2026-06-18. Phase 4 of the maintainability/traceability epic.
## Question (open Q1)
Are CCS CLI exit codes a documented public contract that users or CI scripts depend on? This determines whether migrating `throw new Error(...)` to typed errors (which changes the exit code) is safe.
## Finding
Typed exit codes are **already wired end-to-end**. `handleError` -> `getExitCode` extracts `error.code` from any `CCSError` and passes it to `process.exit` (`src/errors/error-handler.ts`, `src/ccs.ts:145`). The `ExitCode` enum and the class-to-code mapping in `src/errors/error-types.ts` are complete and pre-date this epic.
**Only documented public contract:** `ccs doctor` documents exit codes 0 (healthy) / 1 (unhealthy) in `src/commands/doctor-command.ts:55-58`. `ccs doctor` is OUTSIDE the P4 priority subdomains and is NOT touched by P4. Its 0/1 contract is preserved.
**No CI/scripts assert on ccs exit codes.** `scripts/ci-parity-gate.sh` uses `set -euo pipefail` but performs no `ccs` exit-code branching. `.github/workflows/*` perform no ccs exit-code assertions. Prior exit-code changes in CHANGELOG are treated as bugfixes; no documented breaking changes.
## Decision
**Migrate freely** in the P4 priority subdomains (`cliproxy/quota`, `cliproxy/auth`, `web-server/routes`, `auth`). Preserve `GENERAL_ERROR(1)` only where no clear subclass applies. Behavior-lock tests assert the new typed codes.
## Exit-code mapping (the contract this audit locks)
| Typed class | ExitCode | Value | Used for (P4 sites) |
|---|---|---:|---|
| `ProfileError` | `PROFILE_ERROR` | 7 | profile/account/variant not found, already exists |
| `AuthError` | `AUTH_ERROR` | 4 | OAuth/token/Kiro/GitLab auth flow failures, refresh ownership |
| `ConfigError` | `CONFIG_ERROR` | 2 | settings/config structure, path, not-initialized, read/write profiles |
| `ValidationError` | `GENERAL_ERROR` | 1 | input format validation (no exit-code shift) |
| `ProviderError` | `PROVIDER_ERROR` | 6 | unsupported provider backend |
| `NetworkError` | `NETWORK_ERROR` | 3 | (recoverable) |
| `ProxyError` | `PROXY_ERROR` | 8 | |
| `MigrationError` | `MIGRATION_ERROR` | 9 | |
## Per-site decisions
See the P4 commit for the full site list. Summary by subclass chosen:
- `ProfileError`: profile/account not-found + already-exists (`src/auth/profile-registry.ts`, `src/cliproxy/auth/auth-token-manager.ts`, `src/cliproxy/auth/auth-types.ts`).
- `AuthError`: OAuth start failed, paste-callback unavailable, Kiro auth method unsupported, token refresh ownership (`src/cliproxy/auth/oauth-handler.ts`, `provider-refreshers/index.ts`).
- `ConfigError`: invalid settings path, settings not found, CLIProxy config not initialized, GitLab URL format, failed read/write profiles, copilot sync failure (`src/web-server/routes/*`, `src/cliproxy/auth/oauth-handler.ts`, `src/auth/profile-registry.ts`).
- `ValidationError`: invalid profile name, invalid target, invalid host, Kiro IDC start-url (`src/web-server/routes/*`, `src/cliproxy/auth/auth-types.ts`).
- `ProviderError`: unsupported provider backend (`src/web-server/routes/image-analysis-routes.ts`).
## Outcome
Typed-error adoption in the locked subdomains: 0/23 -> 21/23 (91.3%), well above the 40% target. Exit-code changes are intentional and documented here; release notes for the epic PR should mention the differentiated exit codes.
+82
View File
@@ -0,0 +1,82 @@
/**
* Custom ESLint rule: disallow `throw new Error(...)` outside a baseline allowlist.
*
* P7 enforcement gate. Forces new error sites to use the typed-error taxonomy
* (src/errors/error-types.ts: AuthError, ConfigError, ProfileError, ProviderError,
* ...) so handleError emits differentiated exit codes. Existing ~400 sites are
* grandfathered via a generated baseline (scripts/generate-throw-error-baseline.js
* -> eslint-rules/throw-error-baseline.json); only NEW violations are reported.
*
* Detects `throw new Error(...)` (NewExpression with callee name 'Error').
* Typed subclasses (throw new ConfigError(...)) and re-throws are allowed.
*
* Option: { allowlist: string[] } — entries are `${relativePath}:${line}` keys.
* The relative path matches ESLint's context filename (relative to the repo root
* when eslint is invoked from the root). Line drift after edits above an
* allowlisted site causes a false positive until the baseline is regenerated;
* quarterly pruning keeps it accurate.
*/
'use strict';
const path = require('path');
function isNewErrorExpression(node) {
return (
node !== null &&
node !== undefined &&
node.type === 'NewExpression' &&
node.callee !== null &&
node.callee !== undefined &&
node.callee.type === 'Identifier' &&
node.callee.name === 'Error'
);
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Disallow throw new Error(...) outside the baseline; use a typed error from src/errors/error-types.ts.',
},
schema: [
{
type: 'object',
properties: {
allowlist: { type: 'array', items: { type: 'string' } },
},
additionalProperties: false,
},
],
messages: {
unexpected:
"Unexpected throw new Error(...). Use a typed error from src/errors/error-types.ts (AuthError, ConfigError, ProfileError, ProviderError, ...) so handleError emits a differentiated exit code, or regenerate the baseline via 'node scripts/generate-throw-error-baseline.js'.",
},
},
create(context) {
const options = context.options[0] || {};
const allowlist = new Set(options.allowlist || []);
return {
ThrowStatement(node) {
if (!isNewErrorExpression(node.argument)) {
return;
}
const filename = context.getFilename();
// Normalize to a repo-root-relative path so the baseline keys (which are
// relative, e.g. 'src/auth/profile-registry.ts') match regardless of
// whether ESLint reports an absolute or relative filename.
const normalized = path.isAbsolute(filename)
? path.relative(process.cwd(), filename)
: filename;
const line = node.loc && node.loc.start ? node.loc.start.line : -1;
const key = `${normalized}:${line}`;
if (allowlist.has(key)) {
return;
}
context.report({ node, messageId: 'unexpected' });
},
};
},
};
+340
View File
@@ -0,0 +1,340 @@
[
"src/api/services/local-runtime-readiness.ts:68",
"src/api/services/openrouter-catalog.ts:72",
"src/api/services/profile-lifecycle-service.ts:127",
"src/api/services/profile-lifecycle-service.ts:73",
"src/api/services/profile-lifecycle-service.ts:88",
"src/api/services/profile-writer.ts:364",
"src/api/services/profile-writer.ts:415",
"src/bin/ccsxp-runtime.ts:90",
"src/channels/official-channels-store.ts:291",
"src/channels/official-channels-store.ts:296",
"src/channels/official-channels-store.ts:299",
"src/cliproxy/accounts/drain-order.ts:131",
"src/cliproxy/accounts/drain-order.ts:139",
"src/cliproxy/accounts/drain-order.ts:246",
"src/cliproxy/accounts/drain-order.ts:253",
"src/cliproxy/accounts/drain-order.ts:279",
"src/cliproxy/accounts/drain-order.ts:342",
"src/cliproxy/accounts/registry.ts:521",
"src/cliproxy/accounts/registry.ts:536",
"src/cliproxy/accounts/registry.ts:544",
"src/cliproxy/accounts/registry.ts:589",
"src/cliproxy/accounts/registry.ts:597",
"src/cliproxy/accounts/registry.ts:755",
"src/cliproxy/accounts/registry.ts:770",
"src/cliproxy/ai-providers/managed-model-prefixes.ts:54",
"src/cliproxy/ai-providers/managed-model-prefixes.ts:71",
"src/cliproxy/ai-providers/managed-model-prefixes.ts:81",
"src/cliproxy/ai-providers/openai-compat-manager.ts:139",
"src/cliproxy/ai-providers/openai-compat-manager.ts:167",
"src/cliproxy/ai-providers/openai-compat-manager.ts:172",
"src/cliproxy/ai-providers/service.ts:242",
"src/cliproxy/ai-providers/service.ts:247",
"src/cliproxy/ai-providers/service.ts:254",
"src/cliproxy/ai-providers/service.ts:257",
"src/cliproxy/ai-providers/service.ts:260",
"src/cliproxy/ai-providers/service.ts:266",
"src/cliproxy/binary/installer.ts:127",
"src/cliproxy/binary/installer.ts:55",
"src/cliproxy/binary/installer.ts:74",
"src/cliproxy/binary/installer.ts:88",
"src/cliproxy/binary/lifecycle.ts:139",
"src/cliproxy/binary/platform-detector.ts:153",
"src/cliproxy/binary/platform-detector.ts:160",
"src/cliproxy/binary/verifier.ts:55",
"src/cliproxy/binary/version-checker.ts:108",
"src/cliproxy/config/base-config-loader.ts:54",
"src/cliproxy/config/base-config-loader.ts:66",
"src/cliproxy/config/base-config-loader.ts:81",
"src/cliproxy/config/base-config-loader.ts:91",
"src/cliproxy/config/env-builder.ts:1005",
"src/cliproxy/config/env-builder.ts:700",
"src/cliproxy/executor/auth-coordinator.ts:176",
"src/cliproxy/executor/auth-coordinator.ts:317",
"src/cliproxy/executor/browser-launch-setup.ts:120",
"src/cliproxy/executor/index.ts:391",
"src/cliproxy/executor/lifecycle-manager.ts:129",
"src/cliproxy/executor/lifecycle-manager.ts:162",
"src/cliproxy/executor/lifecycle-manager.ts:59",
"src/cliproxy/executor/proxy-resolver.ts:142",
"src/cliproxy/executor/proxy-resolver.ts:157",
"src/cliproxy/executor/proxy-resolver.ts:163",
"src/cliproxy/executor/proxy-resolver.ts:177",
"src/cliproxy/executor/session-bridge.ts:147",
"src/cliproxy/provider-capabilities.ts:257",
"src/cliproxy/proxy/https-tunnel-proxy.ts:61",
"src/cliproxy/routing/routing-strategy.ts:480",
"src/cliproxy/routing/routing-strategy.ts:488",
"src/cliproxy/routing/routing-strategy.ts:708",
"src/cliproxy/services/remote-auth-fetcher.ts:139",
"src/cliproxy/services/remote-auth-fetcher.ts:150",
"src/cliproxy/services/remote-auth-fetcher.ts:152",
"src/cliproxy/services/remote-auth-fetcher.ts:159",
"src/cliproxy/services/remote-auth-fetcher.ts:165",
"src/cliproxy/services/remote-auth-fetcher.ts:168",
"src/cliproxy/services/startup-lock.ts:208",
"src/cliproxy/services/variant-config-adapter.ts:72",
"src/cliproxy/services/variant-service.ts:371",
"src/cliproxy/services/variant-service.ts:446",
"src/codex-auth/codex-auth-dashboard-service.ts:108",
"src/codex-auth/codex-auth-dashboard-service.ts:84",
"src/codex-auth/codex-profile-paths.ts:17",
"src/codex-auth/codex-profile-paths.ts:26",
"src/codex-auth/codex-profile-registry.ts:204",
"src/codex-auth/codex-profile-registry.ts:232",
"src/codex-auth/codex-profile-registry.ts:277",
"src/codex-auth/codex-profile-registry.ts:29",
"src/codex-auth/codex-profile-registry.ts:295",
"src/codex-auth/codex-profile-registry.ts:305",
"src/codex-auth/codex-profile-registry.ts:317",
"src/codex-auth/codex-profile-registry.ts:320",
"src/codex-auth/codex-profile-registry.ts:34",
"src/codex-auth/codex-profile-registry.ts:351",
"src/codex-auth/codex-profile-registry.ts:37",
"src/codex-auth/codex-profile-registry.ts:52",
"src/codex-auth/codex-profile-registry.ts:65",
"src/codex-auth/codex-profile-registry.ts:71",
"src/codex-auth/codex-profile-registry.ts:75",
"src/codex-auth/codex-profile-registry.ts:78",
"src/codex-auth/codex-profile-registry.ts:81",
"src/codex-auth/codex-profile-registry.ts:86",
"src/codex-auth/codex-profile-registry.ts:93",
"src/codex-auth/codex-profile-registry.ts:96",
"src/codex-auth/commands/import-default-command.ts:134",
"src/codex-auth/commands/import-default-command.ts:146",
"src/codex-auth/commands/import-default-command.ts:167",
"src/commands/bar/install-subcommand.ts:137",
"src/commands/bar/install-subcommand.ts:141",
"src/commands/bar/install-subcommand.ts:149",
"src/commands/bar/install-subcommand.ts:172",
"src/commands/bar/install-subcommand.ts:181",
"src/commands/bar/install-subcommand.ts:187",
"src/commands/bar/install-subcommand.ts:228",
"src/commands/bar/install-subcommand.ts:249",
"src/commands/bar/install-subcommand.ts:259",
"src/commands/bar/install-subcommand.ts:271",
"src/commands/bar/install-subcommand.ts:289",
"src/commands/bar/install-subcommand.ts:316",
"src/commands/bar/launch-subcommand.ts:212",
"src/commands/config-channels-command.ts:431",
"src/commands/config-channels-command.ts:436",
"src/commands/config-channels-command.ts:447",
"src/commands/persist-command/arg-parsing.ts:31",
"src/commands/persist-command/backup-rotation.ts:226",
"src/commands/persist-command/backup-rotation.ts:244",
"src/commands/persist-command/backup-rotation.ts:249",
"src/commands/persist-command/backup-rotation.ts:86",
"src/commands/persist-command/handler.ts:166",
"src/commands/persist-command/handler.ts:38",
"src/commands/persist-command/secure-file.ts:104",
"src/commands/persist-command/secure-file.ts:120",
"src/commands/persist-command/secure-file.ts:142",
"src/commands/persist-command/secure-file.ts:172",
"src/commands/persist-command/secure-file.ts:174",
"src/commands/persist-command/secure-file.ts:182",
"src/commands/persist-command/secure-file.ts:98",
"src/commands/proxy-command.ts:89",
"src/commands/setup-command.ts:218",
"src/config/loader/io-locks.ts:205",
"src/config/loader/io-locks.ts:241",
"src/config/loader/io-locks.ts:295",
"src/config/loader/io-locks.ts:297",
"src/config/reserved-names.ts:92",
"src/config/unified-config-loader.ts:149",
"src/copilot/copilot-package-manager.ts:444",
"src/copilot/copilot-package-manager.ts:467",
"src/cursor/cursor-anthropic-translator.ts:113",
"src/cursor/cursor-anthropic-translator.ts:119",
"src/cursor/cursor-anthropic-translator.ts:129",
"src/cursor/cursor-anthropic-translator.ts:149",
"src/cursor/cursor-anthropic-translator.ts:169",
"src/cursor/cursor-anthropic-translator.ts:174",
"src/cursor/cursor-anthropic-translator.ts:197",
"src/cursor/cursor-anthropic-translator.ts:21",
"src/cursor/cursor-anthropic-translator.ts:44",
"src/cursor/cursor-anthropic-translator.ts:51",
"src/cursor/cursor-anthropic-translator.ts:94",
"src/cursor/cursor-client-policy.ts:33",
"src/cursor/cursor-client-policy.ts:81",
"src/cursor/cursor-client-policy.ts:85",
"src/cursor/cursor-daemon-entry.ts:183",
"src/cursor/cursor-daemon-entry.ts:188",
"src/cursor/cursor-daemon-entry.ts:193",
"src/cursor/cursor-daemon-entry.ts:203",
"src/cursor/cursor-executor.ts:206",
"src/cursor/cursor-protobuf.ts:50",
"src/cursor/cursor-translator.ts:189",
"src/delegation/headless-executor.ts:126",
"src/delegation/headless-executor.ts:141",
"src/delegation/headless-executor.ts:270",
"src/delegation/headless-executor.ts:679",
"src/dispatcher/cli-argument-parser.ts:310",
"src/dispatcher/cli-argument-parser.ts:314",
"src/dispatcher/cli-argument-parser.ts:324",
"src/dispatcher/cli-argument-parser.ts:328",
"src/dispatcher/flows/default-flow.ts:76",
"src/dispatcher/flows/settings-flow.ts:135",
"src/docker/docker-assets.ts:35",
"src/docker/docker-executor.ts:154",
"src/docker/docker-executor.ts:435",
"src/glmt/delta-accumulator.ts:154",
"src/glmt/delta-accumulator.ts:196",
"src/glmt/delta-accumulator.ts:216",
"src/glmt/delta-accumulator.ts:304",
"src/glmt/glmt-transformer.ts:92",
"src/glmt/sse-parser.ts:130",
"src/glmt/sse-parser.ts:73",
"src/management/instance-manager.ts:133",
"src/management/profile-context-sync-lock.ts:162",
"src/management/profile-context-sync-lock.ts:232",
"src/proxy/proxy-daemon-entry.ts:67",
"src/proxy/proxy-daemon-entry.ts:78",
"src/proxy/proxy-daemon-entry.ts:88",
"src/proxy/proxy-daemon.ts:98",
"src/proxy/transformers/request-transformer.ts:141",
"src/proxy/transformers/request-transformer.ts:182",
"src/proxy/transformers/request-transformer.ts:189",
"src/proxy/transformers/request-transformer.ts:242",
"src/proxy/transformers/request-transformer.ts:259",
"src/proxy/transformers/request-transformer.ts:278",
"src/proxy/transformers/request-transformer.ts:344",
"src/proxy/transformers/request-transformer.ts:360",
"src/proxy/transformers/request-transformer.ts:370",
"src/proxy/transformers/request-transformer.ts:390",
"src/proxy/transformers/request-transformer.ts:428",
"src/proxy/transformers/request-transformer.ts:439",
"src/proxy/transformers/request-transformer.ts:443",
"src/proxy/transformers/request-transformer.ts:459",
"src/proxy/transformers/request-transformer.ts:468",
"src/proxy/transformers/request-transformer.ts:487",
"src/proxy/transformers/request-transformer.ts:493",
"src/proxy/transformers/request-transformer.ts:528",
"src/proxy/transformers/request-transformer.ts:533",
"src/proxy/transformers/request-transformer.ts:538",
"src/proxy/transformers/request-transformer.ts:543",
"src/proxy/transformers/request-transformer.ts:561",
"src/proxy/transformers/request-transformer.ts:566",
"src/proxy/transformers/request-transformer.ts:573",
"src/proxy/transformers/request-transformer.ts:582",
"src/proxy/transformers/request-transformer.ts:633",
"src/proxy/transformers/request-transformer.ts:639",
"src/proxy/transformers/request-transformer.ts:644",
"src/proxy/transformers/request-transformer.ts:665",
"src/proxy/upstream-url.ts:28",
"src/shared/claude-extension-setup.ts:240",
"src/shared/claude-extension-setup.ts:247",
"src/shared/claude-extension-setup.ts:257",
"src/shared/claude-extension-setup.ts:317",
"src/shared/provider-preset-catalog.ts:308",
"src/shared/provider-preset-catalog.ts:311",
"src/shared/provider-preset-catalog.ts:320",
"src/shared/provider-preset-catalog.ts:323",
"src/shared/provider-preset-catalog.ts:326",
"src/shared/provider-preset-catalog.ts:331",
"src/shared/provider-preset-catalog.ts:334",
"src/shared/toml-object.ts:23",
"src/targets/codex-adapter.ts:103",
"src/targets/codex-adapter.ts:275",
"src/targets/codex-adapter.ts:319",
"src/targets/codex-adapter.ts:326",
"src/targets/codex-adapter.ts:363",
"src/targets/codex-cliproxy-provider-config.ts:138",
"src/targets/codex-cliproxy-provider-config.ts:141",
"src/targets/codex-cliproxy-provider-config.ts:151",
"src/targets/codex-cliproxy-provider-config.ts:177",
"src/targets/droid-adapter.ts:32",
"src/targets/droid-adapter.ts:35",
"src/targets/droid-adapter.ts:68",
"src/targets/droid-adapter.ts:74",
"src/targets/droid-config-manager.ts:335",
"src/targets/droid-config-manager.ts:34",
"src/targets/droid-config-manager.ts:354",
"src/targets/droid-config-manager.ts:357",
"src/targets/droid-config-manager.ts:390",
"src/targets/droid-config-manager.ts:421",
"src/targets/droid-config-manager.ts:429",
"src/targets/droid-config-manager.ts:449",
"src/targets/target-registry.ts:27",
"src/targets/target-resolver.ts:137",
"src/targets/target-resolver.ts:161",
"src/targets/target-resolver.ts:171",
"src/utils/browser/browser-policy.ts:35",
"src/utils/browser/browser-policy.ts:43",
"src/utils/browser/chrome-reuse.ts:114",
"src/utils/browser/chrome-reuse.ts:119",
"src/utils/browser/chrome-reuse.ts:142",
"src/utils/browser/chrome-reuse.ts:155",
"src/utils/browser/chrome-reuse.ts:160",
"src/utils/browser/chrome-reuse.ts:165",
"src/utils/browser/chrome-reuse.ts:41",
"src/utils/browser/chrome-reuse.ts:64",
"src/utils/browser/chrome-reuse.ts:80",
"src/utils/browser/chrome-reuse.ts:84",
"src/utils/browser/chrome-reuse.ts:95",
"src/utils/browser/mcp-installer.ts:342",
"src/utils/claude-spawner.ts:46",
"src/utils/config-manager.ts:298",
"src/utils/config-manager.ts:302",
"src/utils/prompt.ts:112",
"src/utils/prompt.ts:157",
"src/utils/prompt.ts:54",
"src/utils/proxy-env.ts:36",
"src/utils/proxy-env.ts:40",
"src/utils/retry-strategy.ts:96",
"src/utils/retry-strategy.ts:99",
"src/utils/shell-completion.ts:101",
"src/utils/shell-completion.ts:143",
"src/utils/shell-completion.ts:191",
"src/utils/shell-completion.ts:224",
"src/utils/shell-completion.ts:269",
"src/utils/shell-completion.ts:288",
"src/utils/shell-completion.ts:74",
"src/utils/websearch/mcp-installer.ts:438",
"src/utils/websearch/profile-hook-injector.ts:206",
"src/web-server/index.ts:266",
"src/web-server/services/claude-extension-binding-service.ts:144",
"src/web-server/services/claude-extension-binding-service.ts:185",
"src/web-server/services/claude-extension-binding-service.ts:195",
"src/web-server/services/claude-extension-binding-service.ts:207",
"src/web-server/services/claude-extension-binding-service.ts:255",
"src/web-server/services/claude-extension-binding-service.ts:273",
"src/web-server/services/claude-extension-binding-service.ts:82",
"src/web-server/services/claude-extension-binding-service.ts:83",
"src/web-server/services/claude-extension-binding-service.ts:84",
"src/web-server/services/claude-extension-binding-service.ts:89",
"src/web-server/services/claude-extension-settings-service.ts:186",
"src/web-server/services/claude-extension-settings-service.ts:190",
"src/web-server/services/claude-extension-settings-service.ts:202",
"src/web-server/services/claude-extension-settings-service.ts:226",
"src/web-server/services/compatible-cli-docs-registry.ts:171",
"src/web-server/services/compatible-cli-json-file-service.ts:171",
"src/web-server/services/compatible-cli-json-file-service.ts:174",
"src/web-server/services/compatible-cli-json-file-service.ts:191",
"src/web-server/services/compatible-cli-json-file-service.ts:194",
"src/web-server/services/compatible-cli-json-file-service.ts:203",
"src/web-server/services/compatible-cli-json-file-service.ts:206",
"src/web-server/services/compatible-cli-toml-file-service.ts:124",
"src/web-server/services/compatible-cli-toml-file-service.ts:127",
"src/web-server/services/compatible-cli-toml-file-service.ts:292",
"src/web-server/services/compatible-cli-toml-file-service.ts:295",
"src/web-server/services/compatible-cli-toml-file-service.ts:89",
"src/web-server/services/compatible-cli-toml-file-service.ts:92",
"src/web-server/usage/cliproxy-usage-syncer.ts:302",
"src/web-server/usage/handlers.ts:101",
"src/web-server/usage/handlers.ts:66",
"src/web-server/usage/handlers.ts:73",
"src/web-server/usage/handlers.ts:74",
"src/web-server/usage/handlers.ts:75",
"src/web-server/usage/handlers.ts:84",
"src/web-server/usage/handlers.ts:93",
"src/web-server/usage/profile-filter.ts:15",
"src/web-server/usage/profile-filter.ts:21",
"src/web-server/usage/sqlite-cli.ts:101",
"src/web-server/usage/sqlite-cli.ts:138",
"src/web-server/usage/sqlite-cli.ts:159",
"src/web-server/usage/sqlite-cli.ts:175",
"src/web-server/usage/sqlite-cli.ts:179",
"src/web-server/usage/sqlite-cli.ts:77",
"src/web-server/usage/sqlite-cli.ts:90"
]
+19
View File
@@ -1,6 +1,14 @@
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';
import prettier from 'eslint-config-prettier';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import noNewThrowError from './eslint-rules/no-new-throw-error.js';
const __configDir = path.dirname(fileURLToPath(import.meta.url));
const baselinePath = path.join(__configDir, 'eslint-rules', 'throw-error-baseline.json');
const throwErrorBaseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
export default [
{
@@ -16,6 +24,8 @@ export default [
},
plugins: {
'@typescript-eslint': tseslint,
// Local enforcement rules (P7 gates).
ccs: { rules: { 'no-new-throw-error': noNewThrowError } },
},
rules: {
// TypeScript rules - upgraded to errors for stricter type safety
@@ -32,6 +42,15 @@ export default [
'prefer-const': 'error',
'no-var': 'error',
'eqeqeq': ['error', 'always'],
// P7 enforcement gates:
// - no-new-throw-error: error on NEW throw new Error(...) outside the
// generated baseline (eslint-rules/throw-error-baseline.json). Forces
// the typed-error taxonomy (src/errors/error-types.ts). Regenerate the
// baseline with: node scripts/generate-throw-error-baseline.js
// - max-lines: warn on files over 400 LOC (goal of P5/P6 god-file splits).
'ccs/no-new-throw-error': ['error', { allowlist: throwErrorBaseline }],
'max-lines': ['warn', { max: 400, skipBlankLines: true, skipComments: true }],
},
},
{
+8 -1
View File
@@ -13,12 +13,19 @@
# CCS_BAR_SIGNING=developer-id CCS_BAR_SIGN_IDENTITY="Developer ID Application: ..." ./Scripts/package_app.sh 0.1.0
set -euo pipefail
VERSION="${1:-0.0.0}"
SIGNING="${CCS_BAR_SIGNING:-adhoc}"
APP_NAME="CCS Bar"
EXEC_NAME="CCSBar"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# Version precedence: explicit arg, else the committed `VERSION` file (the single
# source of truth shared with the Bar Release CI workflow), else 0.0.0.
VERSION="${1:-}"
if [[ -z "$VERSION" && -f "$ROOT/VERSION" ]]; then
VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"
fi
VERSION="${VERSION:-0.0.0}"
DIST="$ROOT/dist"
APP="$DIST/$APP_NAME.app"
@@ -26,6 +26,11 @@ struct BarAnalyticsView: View {
/// Spend header (using the otherwise-blank space) so the user switches
/// bars/line in place rather than digging into Settings. nil for `.breakdown`.
var onToggleSpendStyle: (() -> Void)? = nil
/// Selected time window for the spend chart. Default .last7d so the rolling
/// 7-day view is the startup default, matching SpendPeriodStore's default.
var spendPeriod: SpendPeriod = .last7d
/// Callback when the user taps a period selector button. nil for .breakdown.
var onSelectPeriod: ((SpendPeriod) -> Void)? = nil
private var lastActive: String? {
BarFormatting.lastActiveLabel(
@@ -71,14 +76,20 @@ struct BarAnalyticsView: View {
!analytics.bySurface.isEmpty || !analytics.topModels.isEmpty
}
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
/// line, and a thin inline 30-day sparkline when there is real spend.
/// The informational spend strip: a "SPEND" label, period selector,
/// bars/line toggle, a muted caption, a taller sparkline, and axis labels.
private var spendStrip: some View {
VStack(alignment: .leading, spacing: 5) {
// Header row: section label | period selector | bars/line toggle.
HStack(spacing: 6) {
SectionLabel("Spend")
Spacer()
// Inline bars/line toggle in the header's blank space — no Settings trip.
// Compact 3-segment period selector — only when there is data to chart,
// so the idle state shows no controls that would have no visible effect.
if let select = onSelectPeriod, analytics.hasRecentData {
periodSelector(onSelect: select)
}
// Inline bars/line toggle — only when there is data to render.
if let toggle = onToggleSpendStyle, analytics.hasRecentData, !sparklineIsEmpty {
Button(action: toggle) {
Image(
@@ -92,16 +103,16 @@ struct BarAnalyticsView: View {
.help("Spend graph: switch to \(spendChartStyle == .bars ? "line" : "bars")")
}
}
if analytics.hasRecentData {
Text(spendCaption)
.font(.caption2)
.foregroundStyle(.secondary)
if !sparklineIsEmpty {
// height: 30 (up from 18) so daily spend gradations are clearly readable.
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent,
style: spendChartStyle)
.frame(height: 30)
}
// height: 56 — more room so per-hour or per-day gradations are readable.
Sparkline(values: activeSeries, accent: theme.accent, style: spendChartStyle)
.frame(height: 56)
// Axis labels below the chart, aligned to the same width.
axisLabelRow
} else {
Text(idleCaption)
.font(.caption2)
@@ -110,11 +121,129 @@ struct BarAnalyticsView: View {
}
}
/// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk".
/// Small 3-button period selector: "Today / 7d / 30d".
private func periodSelector(onSelect: @escaping (SpendPeriod) -> Void) -> some View {
HStack(spacing: 4) {
periodButton("Today", period: .today, onSelect: onSelect)
periodButton("7d", period: .last7d, onSelect: onSelect)
periodButton("30d", period: .last30d, onSelect: onSelect)
}
}
private func periodButton(
_ label: String, period: SpendPeriod, onSelect: @escaping (SpendPeriod) -> Void
) -> some View {
Button(label) { onSelect(period) }
.buttonStyle(.borderless)
.font(
spendPeriod == period
? .system(size: 10, weight: .semibold)
: .system(size: 10))
.foregroundStyle(spendPeriod == period ? theme.accent : Color.secondary)
}
/// The value series for the currently-selected period.
private var activeSeries: [Double] {
switch spendPeriod {
case .today:
return analytics.byHour.map(\.cost)
case .last7d:
return Array(analytics.byDay.suffix(7)).map(\.cost)
case .last30d:
return analytics.byDay.map(\.cost)
}
}
/// Caption showing the cost for the active period. Each period sums the SAME
/// series it charts so the caption total always matches the visible bars.
private var spendCaption: String {
"today \(BarFormatting.money(analytics.today.cost))"
+ " · 7d \(BarFormatting.money(analytics.last7d.cost))"
+ " · 30d \(BarFormatting.money(analytics.last30d.cost))"
switch spendPeriod {
case .today:
// Sum byHour (the charted series) so caption == bars. Fall back to the
// daily today total only when there is no hourly series to draw.
let cost =
analytics.byHour.isEmpty
? analytics.today.cost
: analytics.byHour.reduce(0) { $0 + $1.cost }
return "today \(BarFormatting.money(cost))"
case .last7d:
let cost = Array(analytics.byDay.suffix(7)).reduce(0) { $0 + $1.cost }
return "7d \(BarFormatting.money(cost))"
case .last30d:
return "30d \(BarFormatting.money(analytics.last30d.cost))"
}
}
/// Axis labels rendered below the sparkline. Each tick is placed at its data
/// point's horizontal fraction (bar-center) so the label sits under the hour /
/// day it names — not merely evenly distributed, which drifts on the Today
/// view where the tick indices aren't at even fractions of the width. Edge
/// labels are clamped inward by their estimated half-width so they don't clip.
@ViewBuilder private var axisLabelRow: some View {
let ticks = axisTicks(for: spendPeriod)
if !ticks.isEmpty {
GeometryReader { geo in
let width = Double(geo.size.width)
ForEach(Array(ticks.enumerated()), id: \.offset) { _, tick in
// ~2.75pt per char at 9pt monospaced is half a glyph; clamp keeps the
// first/last labels fully on-screen.
let halfW = max(8.0, Double(tick.label.count) * 2.75)
let x = min(max(tick.fraction * width, halfW), max(halfW, width - halfW))
Text(tick.label)
.font(.system(size: 9, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.fixedSize()
.position(x: x, y: 6)
}
}
.frame(height: 12)
}
}
/// Tick (label, horizontal fraction 0...1) pairs for the current period.
/// Fraction is the bar CENTER ((i + 0.5) / count) so labels align under the
/// default bar chart; for the line style the end ticks differ by half a bar,
/// which is visually negligible.
private func axisTicks(for period: SpendPeriod) -> [(label: String, fraction: Double)] {
func center(_ i: Int, _ n: Int) -> Double { n > 0 ? (Double(i) + 0.5) / Double(n) : 0 }
switch period {
case .today:
// Hours at 0, 6, 12, 18, 23 — only those that exist.
let hours = analytics.byHour
guard !hours.isEmpty else { return [] }
let n = hours.count
return [0, 6, 12, 18, 23].compactMap { idx -> (String, Double)? in
guard idx < n, let label = BarCardFormatting.hourShort(fromHourKey: hours[idx].hour)
else { return nil }
return (label, center(idx, n))
}
case .last7d:
// All 7 days (or whatever suffix(7) yields): short weekday "Mon".
let days = Array(analytics.byDay.suffix(7))
guard !days.isEmpty else { return [] }
let n = days.count
return days.enumerated().compactMap { (i, d) -> (String, Double)? in
guard let label = BarCardFormatting.weekdayShort(fromDayKey: d.date) else { return nil }
return (label, center(i, n))
}
case .last30d:
// 5 evenly-spaced "MMM d" labels: first, ~1/4, mid, ~3/4, last.
let days = analytics.byDay
guard days.count >= 2 else { return [] }
let n = days.count
let last = n - 1
let indices = [0, last / 4, last / 2, last * 3 / 4, last].reduce(into: [Int]()) { acc, i in
if acc.last != i { acc.append(i) }
}
return indices.compactMap { i -> (String, Double)? in
guard i < n, let label = BarCardFormatting.monthDayShort(fromDayKey: days[i].date)
else { return nil }
return (label, center(i, n))
}
}
}
/// Honest idle caption when there's no recent spend, folding in last-active.
@@ -125,8 +254,10 @@ struct BarAnalyticsView: View {
return headline
}
/// True when the active period's series is all zero. Still shows the chart
/// frame + axis (do not hide them), but the bars/line toggle is suppressed.
private var sparklineIsEmpty: Bool {
analytics.byDay.allSatisfy { $0.cost <= 0 }
activeSeries.allSatisfy { $0 <= 0 }
}
}
@@ -51,4 +51,57 @@ enum BarCardFormatting {
fmt.dateFormat = "HH:mm"
return fmt.string(from: date)
}
// MARK: - Axis label formatters (used by BarAnalyticsView spend chart)
/// Short hour label from a byHour key "YYYY-MM-DD HH:00", e.g. "12a", "6p".
/// Extracts the HH part (characters at index 11-12) and converts to 12-hour
/// format with lowercase "a"/"p" suffix. Returns nil for unparseable keys.
static func hourShort(fromHourKey key: String) -> String? {
// key format: "YYYY-MM-DD HH:00" — HH is at offset 11, length 2.
guard key.count >= 13 else { return nil }
let start = key.index(key.startIndex, offsetBy: 11)
let end = key.index(start, offsetBy: 2)
guard let hour = Int(key[start..<end]) else { return nil }
switch hour {
case 0: return "12a"
case 1..<12: return "\(hour)a"
case 12: return "12p"
default: return "\(hour - 12)p"
}
}
/// Short weekday label from a byDay key "YYYY-MM-DD", e.g. "Mon".
/// Returns nil for unparseable keys. Formats in UTC to match how `dayDate`
/// parses the key, so the weekday names the calendar day the key represents
/// (formatting in local time would shift it a day for users west of UTC).
static func weekdayShort(fromDayKey key: String) -> String? {
guard let date = dayDate(fromKey: key) else { return nil }
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.dateFormat = "EEE"
return fmt.string(from: date)
}
/// Short month+day label from a byDay key "YYYY-MM-DD", e.g. "Jun 5".
/// Returns nil for unparseable keys. Formats in UTC to match `dayDate`'s
/// parse zone so the label names the same calendar day as the key.
static func monthDayShort(fromDayKey key: String) -> String? {
guard let date = dayDate(fromKey: key) else { return nil }
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.dateFormat = "MMM d"
return fmt.string(from: date)
}
/// Parse a "YYYY-MM-DD" key into a Date at midnight UTC.
private static func dayDate(fromKey key: String) -> Date? {
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.dateFormat = "yyyy-MM-dd"
return fmt.date(from: key)
}
}
@@ -92,8 +92,8 @@ struct BarMenuView: View {
accountsSection
// (3) SPEND — demoted to a thin informational strip below the cockpit.
// spendChartStyle is threaded from the viewModel and toggled inline
// from the Spend header, so a change updates the chart immediately.
// spendChartStyle and spendPeriod are threaded from the viewModel and
// toggled/selected inline from the Spend header so changes are live.
if let analytics = viewModel.analytics {
Divider()
BarAnalyticsView(
@@ -102,7 +102,9 @@ struct BarMenuView: View {
onToggleSpendStyle: {
viewModel.spendChartStyle =
viewModel.spendChartStyle == .bars ? .line : .bars
})
},
spendPeriod: viewModel.spendPeriod,
onSelectPeriod: { viewModel.spendPeriod = $0 })
}
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
@@ -185,7 +187,7 @@ struct BarMenuView: View {
} else {
subscriptionsHeader(parts.subscriptions)
ForEach(orderedSubscriptions(parts.subscriptions)) { row in
BarSubscriptionCard(row: row)
BarSubscriptionCard(row: row, onRefresh: { viewModel.forceRefresh() })
}
}
}
@@ -341,7 +343,7 @@ struct BarMenuView: View {
.help("Settings — appearance/theme, menu-bar glance, and alerts")
Spacer()
Button {
viewModel.onOpen()
viewModel.forceRefresh()
} label: {
Image(systemName: "arrow.clockwise")
}
@@ -401,7 +403,7 @@ struct BarRowView: View {
/// A native first-party subscription (Claude Code / Codex) — drives the
/// distinct "subscription" badge + indigo provider chip.
private var isNativeSubscription: Bool {
BarFormatting.isNativeSubscription(provider: row.provider)
BarFormatting.isNativeSubscription(row)
}
var body: some View {
@@ -1,4 +1,9 @@
import Foundation
#if os(Linux)
import Glibc
#else
import Darwin
#endif
import CCSBarCore
/// Reads `~/.ccs/bar/launch.json` and spawns the CCS bar server detached,
@@ -36,10 +41,80 @@ struct BarServerLauncher: Sendable {
private func loadDescriptor() -> BarLaunchDescriptor? {
let path = BarLaunchDescriptor.defaultPath(home: home)
guard FileManager.default.fileExists(atPath: path),
let data = FileManager.default.contents(atPath: path)
guard isSafeDescriptorFile(path),
let data = FileManager.default.contents(atPath: path),
let descriptor = try? JSONDecoder().decode(BarLaunchDescriptor.self, from: data),
isSafeDescriptor(descriptor)
else { return nil }
return try? JSONDecoder().decode(BarLaunchDescriptor.self, from: data)
return descriptor
}
/// Treat launch.json as untrusted because it lives under a user-writable CCS
/// directory. Refuse symlinks, files not owned by the current user, and files
/// writable by group/other before decoding executable details.
private func isSafeDescriptorFile(_ path: String) -> Bool {
let fm = FileManager.default
guard fm.fileExists(atPath: path) else { return false }
guard let linkValues = try? URL(fileURLWithPath: path).resourceValues(forKeys: [.isSymbolicLinkKey]),
linkValues.isSymbolicLink != true,
let attrs = try? fm.attributesOfItem(atPath: path),
(attrs[.type] as? FileAttributeType) == .typeRegular,
let owner = attrs[.ownerAccountID] as? NSNumber,
owner.uint32Value == getuid(),
let permissions = attrs[.posixPermissions] as? NSNumber
else { return false }
// Disallow group/other write bits. User-writable is expected so CCS can refresh it.
return (permissions.uint16Value & 0o022) == 0
}
/// Validate the descriptor schema and constrain it to the expected CCS bar
/// server command shape: runtime absolute path + absolute entry point +
/// exactly "bar serve". This blocks shell descriptors such as
/// /bin/sh -c attacker-command while preserving the installed launch path.
private func isSafeDescriptor(_ descriptor: BarLaunchDescriptor) -> Bool {
guard descriptor.schema == 1, descriptor.args.count == 3 else { return false }
guard descriptor.args[1] == "bar", descriptor.args[2] == "serve" else { return false }
guard isAbsolutePath(descriptor.runtime), isAbsolutePath(descriptor.args[0]) else { return false }
guard descriptor.home == home else { return false }
if let ccsHome = descriptor.ccsHome, !ccsHome.isEmpty, !isAbsolutePath(ccsHome) {
return false
}
let runtimeName = URL(fileURLWithPath: descriptor.runtime).lastPathComponent.lowercased()
let allowedRuntimes: Set<String> = ["node", "nodejs", "bun"]
guard allowedRuntimes.contains(runtimeName) else { return false }
guard FileManager.default.isExecutableFile(atPath: descriptor.runtime) else { return false }
let entry = descriptor.args[0]
let entryName = URL(fileURLWithPath: entry).lastPathComponent.lowercased()
guard entryName == "ccs.js" || entryName == "ccs.ts" else { return false }
guard isSafeEntrypointFile(entry), !isUnderCcsDir(entry) else { return false }
return true
}
private func isSafeEntrypointFile(_ path: String) -> Bool {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
(attrs[.type] as? FileAttributeType) == .typeRegular,
let permissions = attrs[.posixPermissions] as? NSNumber
else { return false }
return (permissions.uint16Value & 0o022) == 0
}
private func isUnderCcsDir(_ path: String) -> Bool {
let ccsPath = URL(fileURLWithPath: home)
.appendingPathComponent(".ccs")
.standardizedFileURL
.path
let targetPath = URL(fileURLWithPath: path).standardizedFileURL.path
return targetPath == ccsPath || targetPath.hasPrefix(ccsPath + "/")
}
private func isAbsolutePath(_ path: String) -> Bool {
path.hasPrefix("/")
}
// MARK: - Spawn from descriptor
@@ -16,6 +16,9 @@ struct BarSubscriptionCard: View {
/// Injected clock — defaults to live Date() in production, pinned in previews
/// and tests so countdown math is deterministic.
var now: Date = Date()
/// Optional force-refresh action. When provided, the stale footnote appends a
/// compact inline refresh button so the user can reload without reopening menus.
var onRefresh: (() -> Void)? = nil
private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] }
@@ -219,6 +222,8 @@ struct BarSubscriptionCard: View {
/// "as of HH:mm (older session)" caption when the Codex reading came from an
/// older session. The bar still renders — the data is real, just not live.
/// When `onRefresh` is provided, a compact inline refresh button trails the
/// caption so the user can force-reload without extra navigation.
@ViewBuilder private var staleFootnote: some View {
if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) {
HStack(spacing: 4) {
@@ -228,6 +233,15 @@ struct BarSubscriptionCard: View {
Text("as of \(clock), older session")
.font(.caption2)
.foregroundStyle(.tertiary)
if let refresh = onRefresh {
Button(action: refresh) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 9))
}
.buttonStyle(.borderless)
.foregroundStyle(.tertiary)
.help("Force refresh to get the latest data")
}
}
}
}
@@ -33,6 +33,11 @@ final class BarViewModel: ObservableObject {
@Published var spendChartStyle: SpendChartStyle {
didSet { SpendChartStyleStore.save(spendChartStyle) }
}
/// Active time window for the spend sparkline (today/7d/30d). Persisted via
/// SpendPeriodStore; didSet mirrors the spendChartStyle pattern.
@Published var spendPeriod: SpendPeriod {
didSet { SpendPeriodStore.save(spendPeriod) }
}
/// The alerts the most recent evaluation wanted delivered, surfaced in the
/// dropdown so users who deny notifications still see the conditions.
@Published var activeAlerts: [BarNotification] = []
@@ -71,6 +76,7 @@ final class BarViewModel: ObservableObject {
self.appearance = BarAppearanceStore.load()
self.glanceMode = prefs.load().glanceMode
self.spendChartStyle = SpendChartStyleStore.load()
self.spendPeriod = SpendPeriodStore.load()
reconnect()
startBackgroundPolling()
}
@@ -219,6 +225,16 @@ final class BarViewModel: ObservableObject {
reconnectAndLoad(force: force)
}
// MARK: - Force refresh (bypasses the 15s debounce)
/// Unconditional force-refresh: skips the debouncer and calls
/// `reconnectAndLoad(force: true)` directly. Used by the footer Refresh button
/// and by the Codex stale-footnote inline action so those never silently no-op
/// inside the debounce window.
func forceRefresh() {
reconnectAndLoad(force: true)
}
// MARK: - Start CCS (called from offline UI button)
/// Trigger a full connect sequence (probe → launch → poll). Used by the
@@ -48,12 +48,16 @@ enum DashboardLauncher {
return
}
// Server isn't up. Start it via `ccs config`, fully detached (nohup) so it
// survives this app quitting; it opens the dashboard on its own.
// Server isn't up. Start it via `ccs config`; it opens the dashboard on its
// own. Avoid a shell here: candidate paths can include user-controlled
// components, and Process can pass the executable and arguments directly.
if let bin = ccsBinary() {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
proc.arguments = ["-c", "nohup \"\(bin)\" config >/dev/null 2>&1 &"]
proc.executableURL = URL(fileURLWithPath: bin)
proc.arguments = ["config"]
let null = FileHandle(forWritingAtPath: "/dev/null")
proc.standardOutput = null
proc.standardError = null
try? proc.run()
return
}
+43 -6
View File
@@ -758,6 +758,35 @@ do {
check(t1.firedKeys.isEmpty, "prune collapses stale-bucket + absent-account keys (bounded)")
}
// (H) Duplicate account ids from a malformed summary must not crash pruning.
do {
let rows = [
BarSummaryRow(
accountId: "dup@example.com", provider: "agy", quotaPercentage: 5, quotaStatus: "ok",
nextReset: "2026-07-01T00:00:00Z"),
BarSummaryRow(
accountId: "dup@example.com", provider: "agy", quotaPercentage: 4, quotaStatus: "ok",
nextReset: "2026-08-01T00:00:00Z"),
]
let prior: Set<String> = [
"quotaRemainingBelow|agy:dup@example.com|2026-07-01T00:00:00Z|L10",
"quotaRemainingBelow|agy:dup@example.com|2026-08-01T00:00:00Z|L10",
"quotaRemainingBelow|agy:dup@example.com|2026-09-01T00:00:00Z|L10",
]
let ev = BarAlertEngine.evaluate(
rows: rows, analytics: nil, prefs: BarAlertPrefs(quotaLevels: [10]), priorFiredKeys: prior,
now: engineNow, calendar: utc)
check(
ev.firedKeys.contains("quotaRemainingBelow|agy:dup@example.com|2026-07-01T00:00:00Z|L10"),
"duplicate ids: prune keeps first present reset bucket")
check(
ev.firedKeys.contains("quotaRemainingBelow|agy:dup@example.com|2026-08-01T00:00:00Z|L10"),
"duplicate ids: prune keeps second present reset bucket")
check(
!ev.firedKeys.contains("quotaRemainingBelow|agy:dup@example.com|2026-09-01T00:00:00Z|L10"),
"duplicate ids: prune drops absent reset bucket")
}
// (H) Deterministic order: shuffled rows produce notifs in stable id order.
do {
let rows = [
@@ -906,13 +935,19 @@ check(
BarFormatting.providerLabel("codex") == "Codex", "native: providerLabel maps codex -> 'Codex'")
check(BarFormatting.providerLabel("agy") == "agy", "native: providerLabel passes CLIProxy keys through")
check(
BarFormatting.isNativeSubscription(provider: "claude-code"),
BarFormatting.isNativeSubscription(
BarSummaryRow(accountId: "claude-code", provider: "claude-code")),
"native: claude-code is a native subscription")
check(
BarFormatting.isNativeSubscription(provider: "codex"), "native: codex is a native subscription")
BarFormatting.isNativeSubscription(BarSummaryRow(accountId: "codex", provider: "codex")),
"native: codex is a native subscription")
check(
!BarFormatting.isNativeSubscription(provider: "agy"),
!BarFormatting.isNativeSubscription(BarSummaryRow(accountId: "agy-a", provider: "agy")),
"native: agy (CLIProxy pool) is NOT a native subscription")
check(
!BarFormatting.isNativeSubscription(
BarSummaryRow(accountId: "pool-codex-oauth-1", provider: "codex")),
"native: codex CLIProxy pool row is NOT a native subscription")
// (N6) Grouping: a mixed list splits into native subscriptions (top) and pool
// accounts, preserving backend order within each group.
@@ -922,17 +957,19 @@ do {
BarSummaryRow(
accountId: "claude-code", provider: "claude-code", quotaPercentage: 40, quotaStatus: "ok"),
BarSummaryRow(accountId: "pool-b", provider: "ghcp", quotaStatus: "unsupported"),
BarSummaryRow(
accountId: "pool-codex-oauth-1", provider: "codex", quotaPercentage: 65, quotaStatus: "ok"),
BarSummaryRow(accountId: "codex", provider: "codex", quotaPercentage: 52, quotaStatus: "ok"),
]
let parts = BarFormatting.partitionSubscriptions(mixed)
check(parts.subscriptions.count == 2, "native: partition pulls 2 subscriptions")
check(parts.pool.count == 2, "native: partition leaves 2 pool accounts")
check(parts.pool.count == 3, "native: partition leaves 3 pool accounts")
check(
parts.subscriptions.map { $0.provider } == ["claude-code", "codex"],
"native: subscriptions keep backend order (claude-code, codex)")
check(
parts.pool.map { $0.provider } == ["agy", "ghcp"],
"native: pool keeps backend order (agy, ghcp)")
parts.pool.map { $0.id } == ["agy:pool-a", "ghcp:pool-b", "codex:pool-codex-oauth-1"],
"native: pool keeps backend order and retains CLIProxy codex row")
}
// (N7) Pool-only list does NOT get split (single "Accounts" header path): both
@@ -251,8 +251,9 @@ public enum BarAlertEngine {
// PRUNE — keep the fired set bounded so it can't grow without limit across
// day/month/reset rollovers or account churn.
let presentIds = Set(rows.map { $0.id })
let presentResetBuckets: [String: String] = Dictionary(
uniqueKeysWithValues: rows.map { ($0.id, $0.nextReset ?? "noreset") })
let presentResetBuckets: [String: Set<String>] = rows.reduce(into: [:]) { buckets, row in
buckets[row.id, default: []].insert(row.nextReset ?? "noreset")
}
fired = fired.filter { key in
let parts = key.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
guard let kind = parts.first else { return false }
@@ -266,7 +267,7 @@ public enum BarAlertEngine {
// parts: [kind, accountId, bucket, "L<level>"]; bucket must equal the
// account's CURRENT nextReset and the account must still be present.
guard parts.count >= 3, presentIds.contains(parts[1]) else { return false }
return presentResetBuckets[parts[1]] == parts[2]
return presentResetBuckets[parts[1]]?.contains(parts[2]) == true
case BarAlertKind.reauthNeeded.rawValue, BarAlertKind.accountCooldownOrPaused.rawValue:
// parts: [kind, accountId, "on"]; keep only for still-present accounts.
return parts.count >= 2 && presentIds.contains(parts[1])
@@ -57,6 +57,20 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
}
}
/// Spend and request count for one hour of today. 24 entries, zero-filled,
/// oldest (00:00) to newest (23:00). The `hour` key is "YYYY-MM-DD HH:00".
public struct Hour: Codable, Sendable, Equatable, Identifiable {
public let hour: String
public let cost: Double
public let requests: Int
public var id: String { hour }
public init(hour: String, cost: Double, requests: Int) {
self.hour = hour
self.cost = cost
self.requests = requests
}
}
public let today: Window
public let last7d: Window
public let last30d: Window
@@ -67,6 +81,9 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
public let allTime: Window
/// Oldest → newest, exactly 30 zero-filled entries, for the sparkline.
public let byDay: [Day]
/// Today's hourly breakdown: 24 entries, oldest (00:00) → newest (23:00),
/// zero-filled. Absent from older payloads; defaults to [].
public let byHour: [Hour]
public let topModels: [Model]
/// "30d" when recent data exists, else "all".
public let topModelsWindow: String
@@ -90,6 +107,7 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
monthToDate: Window = Window(cost: 0, requests: 0),
allTime: Window,
byDay: [Day],
byHour: [Hour] = [],
topModels: [Model],
topModelsWindow: String,
lastActivityAt: String? = nil,
@@ -104,6 +122,7 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
self.monthToDate = monthToDate
self.allTime = allTime
self.byDay = byDay
self.byHour = byHour
self.topModels = topModels
self.topModelsWindow = topModelsWindow
self.lastActivityAt = lastActivityAt
@@ -127,6 +146,9 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
(try c.decodeIfPresent(Window.self, forKey: .monthToDate)) ?? Window(cost: 0, requests: 0)
allTime = try c.decode(Window.self, forKey: .allTime)
byDay = try c.decode([Day].self, forKey: .byDay)
// `byHour` is a new field; older payloads omit it. Default to [] so cached
// or older-backend payloads decode cleanly without throwing.
byHour = (try c.decodeIfPresent([Hour].self, forKey: .byHour)) ?? []
topModels = try c.decode([Model].self, forKey: .topModels)
topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow)
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
@@ -33,3 +33,32 @@ public enum SpendChartStyleStore {
UserDefaults.standard.set(style.rawValue, forKey: defaultsKey)
}
}
// MARK: - SpendPeriod
/// Time window for the spend sparkline selector: today (hourly), last 7 days,
/// or last 30 days. Mirrors the SpendChartStyle pattern: CaseIterable + Sendable.
public enum SpendPeriod: String, CaseIterable, Sendable {
case today
case last7d
case last30d
}
// MARK: - SpendPeriodStore
/// Persists the chosen spend period. Mirrors SpendChartStyleStore exactly:
/// a UserDefaults key, a static load, and a static save. The `?? .last7d`
/// fallback is the sole source of the default.
public enum SpendPeriodStore {
public static let defaultsKey = "ccsbar.spendPeriod"
public static func load() -> SpendPeriod {
let raw = UserDefaults.standard.string(forKey: defaultsKey)
?? SpendPeriod.last7d.rawValue
return SpendPeriod(rawValue: raw) ?? .last7d
}
public static func save(_ period: SpendPeriod) {
UserDefaults.standard.set(period.rawValue, forKey: defaultsKey)
}
}
@@ -165,8 +165,9 @@ public enum BarFormatting {
/// Code or Codex plan) rather than a CLIProxy-managed OAuth pool account. Drives
/// the "Subscriptions" grouping + badge so a user reads "this is MY plan quota",
/// not one of the rotating pool credentials.
public static func isNativeSubscription(provider: String) -> Bool {
provider == "claude-code" || provider == "codex"
public static func isNativeSubscription(_ row: BarSummaryRow) -> Bool {
(row.provider == "claude-code" && row.accountId == "claude-code")
|| (row.provider == "codex" && row.accountId == "codex")
}
/// Friendly product label for a provider key. Native subscription keys read as
@@ -189,7 +190,7 @@ public enum BarFormatting {
var subs: [BarSummaryRow] = []
var pool: [BarSummaryRow] = []
for row in rows {
if isNativeSubscription(provider: row.provider) {
if isNativeSubscription(row) {
subs.append(row)
} else {
pool.append(row)
+1
View File
@@ -0,0 +1 @@
1.7.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "8.4.0",
"version": "8.4.0-dev.17",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
+31
View File
@@ -57,6 +57,37 @@ if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
fi
fi
# Hardening inventory freshness: the maintainability metrics artifact must be
# regenerated within 30 days so the burndown stays current. Runs only after the
# skip conditions above (CCS_SKIP_PREPUSH_GATE, detached HEAD, behind origin).
HARDENING_JSON="docs/reports/hardening-inventory.json"
if [[ ! -f "$HARDENING_JSON" ]]; then
echo "[X] Missing $HARDENING_JSON."
echo " Regenerate with: bun run report:hardening"
exit 1
fi
HARDENING_TS=""
# If the working-tree copy differs from HEAD (contributor regenerated but not
# yet committed), use filesystem mtime; otherwise use the last commit time,
# which is stable across CI clones (checkout resets mtimes) and so correctly
# flags a stale committed artifact.
if git diff --quiet -- "$HARDENING_JSON" 2>/dev/null && git diff --cached --quiet -- "$HARDENING_JSON" 2>/dev/null; then
HARDENING_TS=$(git log -1 --format=%ct -- "$HARDENING_JSON" 2>/dev/null)
else
HARDENING_TS=$(stat -f %m "$HARDENING_JSON" 2>/dev/null || stat -c %Y "$HARDENING_JSON" 2>/dev/null)
fi
if [[ -n "$HARDENING_TS" ]]; then
NOW_TS=$(date +%s)
AGE_DAYS=$(( (NOW_TS - HARDENING_TS) / 86400 ))
if (( AGE_DAYS > 30 )); then
echo "[X] Hardening inventory is stale (${AGE_DAYS}d old; max 30d)."
echo " Regenerate with: bun run report:hardening"
echo " Then commit docs/reports/hardening-inventory.{json,md}."
exit 1
fi
echo "[i] Hardening inventory fresh (${AGE_DAYS}d old; max 30d)."
fi
echo "[i] Running CI-parity local checks..."
# `set -euo pipefail` above makes every step fail fast. Keep these commands
# explicit so parity drift is visible when CI changes.
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* Generate the baseline allowlist for eslint-rules/no-new-throw-error.js.
*
* Walks src/ (non-test), finds every `throw new Error(...)` site using the same
* comment-stripping as scripts/maintainability-metrics.js (so the baseline
* matches what the ESLint AST sees — comments are not ThrowStatement nodes),
* and writes eslint-rules/throw-error-baseline.json as a sorted array of
* `${relativePath}:${line}` keys.
*
* Run after intentionally adding a new grandfathered throw, or quarterly to
* prune entries that have since been converted to typed errors.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT_DIR = path.resolve(__dirname, '..');
const SRC_DIR = path.join(ROOT_DIR, 'src');
const OUTPUT_PATH = path.join(ROOT_DIR, 'eslint-rules', 'throw-error-baseline.json');
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
const THROW_NEW_ERROR_REGEX = /\bthrow\s+new\s+Error\s*\(/g;
function isTestPath(relPath) {
return (
/(?:^|\/)(?:__tests__|tests?)\//.test(relPath) ||
/\.test\./.test(relPath) ||
/\.spec\./.test(relPath)
);
}
function toPosix(p) {
return p.split(path.sep).join('/');
}
function relPath(fullPath) {
return toPosix(path.relative(ROOT_DIR, fullPath));
}
// Lazy require: hardening-inventory.js requires this module's sibling maintainability-metrics.js
// at top level; requiring it back at top level here would capture a partial module.exports.
// A function-scope require resolves against the fully-loaded module at call time.
function stripCommentsOnce(sourceText) {
return require('./hardening-inventory.js').stripComments(sourceText);
}
function walkFiles(dirPath) {
const out = [];
let entries;
try {
entries = fs.readdirSync(dirPath, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
const full = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
out.push.apply(out, walkFiles(full));
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(full))) {
out.push(full);
}
}
return out;
}
function collectSites() {
const sites = [];
for (const full of walkFiles(SRC_DIR)) {
const rel = relPath(full);
if (isTestPath(rel)) continue;
// Match on the RAW source (not comment-stripped). ESLint lints the raw
// file, so the baseline must reflect real throw lines as the AST sees them.
// stripComments can undercount on files whose regex/template literals confuse
// its state machine; raw matching is a superset (may include comment/string
// mentions, which are harmless unused allowlist entries) and never undercounts.
const sourceText = fs.readFileSync(full, 'utf8');
const re = new RegExp(THROW_NEW_ERROR_REGEX.source, 'g');
let match;
while ((match = re.exec(sourceText)) !== null) {
const line = sourceText.slice(0, match.index).split(/\r?\n/).length;
sites.push(`${rel}:${line}`);
}
}
return sites.sort();
}
function main() {
const sites = collectSites();
fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(sites, null, 2) + '\n', 'utf8');
console.log(`[throw-error-baseline] ${sites.length} sites -> ${relPath(OUTPUT_PATH)}`);
}
if (require.main === module) {
main();
}
module.exports = { collectSites, OUTPUT_PATH };
@@ -1,32 +1,72 @@
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
const ISSUE_REF_PATTERN = /#([0-9]+)/g;
const ACTION_VERB_PATTERN =
/\b(?:fixes|closes|resolves|refs?)\b\s+(#\d+\b(?:\s*(?:,|and)?\s*#\d+\b)*)/gi;
const RESOLVE_VERB_PATTERN =
/\b(?:fixes|closes|resolves)\b\s+(#\d+\b(?:\s*(?:,|and)?\s*#\d+\b)*)/gi;
const ACTION_VERB_PATTERN = /\b(?:fixes|closes|resolves|refs?)\b\s+/gi;
const RESOLVE_VERB_PATTERN = /\b(?:fixes|closes|resolves)\b\s+/gi;
const PR_REF_PATTERN = /(?:Merge pull request #|\(#)([0-9]+)/g;
const STABLE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+$/;
export function extractIssueNumbers(text, { includeRefs = true } = {}) {
const pattern = includeRefs ? ACTION_VERB_PATTERN : RESOLVE_VERB_PATTERN;
const source = text || '';
const issues = new Set();
let actionMatch;
pattern.lastIndex = 0;
while ((actionMatch = pattern.exec(text || '')) !== null) {
const tail = actionMatch[1] || '';
let issueMatch;
ISSUE_REF_PATTERN.lastIndex = 0;
while ((issueMatch = ISSUE_REF_PATTERN.exec(tail)) !== null) {
issues.add(Number(issueMatch[1]));
while ((actionMatch = pattern.exec(source)) !== null) {
let cursor = actionMatch.index + actionMatch[0].length;
while (source[cursor] === '#') {
const numberStart = cursor + 1;
let numberEnd = numberStart;
while (numberEnd < source.length && isAsciiDigit(source[numberEnd])) {
numberEnd += 1;
}
if (numberEnd === numberStart || isAsciiWord(source[numberEnd] || '')) break;
issues.add(Number(source.slice(numberStart, numberEnd)));
cursor = numberEnd;
cursor = skipWhitespace(source, cursor);
if (source[cursor] === ',') {
cursor = skipWhitespace(source, cursor + 1);
} else if (isAndSeparator(source, cursor)) {
cursor = skipWhitespace(source, cursor + 3);
}
}
}
return [...issues].sort((a, b) => a - b);
}
function skipWhitespace(text, cursor) {
while (cursor < text.length && /\s/.test(text[cursor])) {
cursor += 1;
}
return cursor;
}
function isAndSeparator(text, cursor) {
return (
text.slice(cursor, cursor + 3).toLowerCase() === 'and' &&
!isAsciiWord(text[cursor - 1] || '') &&
!isAsciiWord(text[cursor + 3] || '')
);
}
function isAsciiDigit(value) {
return value >= '0' && value <= '9';
}
function isAsciiWord(value) {
return (
(value >= '0' && value <= '9') ||
(value >= 'A' && value <= 'Z') ||
(value >= 'a' && value <= 'z') ||
value === '_'
);
}
export function extractPrNumbers(text) {
const prs = new Set();
let match;
+62 -4
View File
@@ -3,6 +3,8 @@
const fs = require('fs');
const path = require('path');
const { collectMaintainabilityMetrics } = require('./maintainability-metrics.js');
const ROOT_DIR = path.resolve(__dirname, '..');
const SRC_DIR = path.join(ROOT_DIR, 'src');
const REPORT_DIR = path.join(ROOT_DIR, 'docs', 'reports');
@@ -430,6 +432,7 @@ function buildReport() {
.filter((file) => /shim|re-export|compat/i.test(path.basename(file)))
),
},
maintainability: collectMaintainabilityMetrics(ROOT_DIR),
};
}
@@ -489,6 +492,55 @@ function renderMarkdown(report) {
lines.push('- _none_');
}
const m = report.maintainability;
if (m) {
lines.push('## Maintainability Metrics');
lines.push('');
lines.push('| Metric | Value |');
lines.push('|---|---:|');
lines.push(
`| typed-error adoption (typed/total throws) | ${(m.typedErrors.adoptionRatio * 100).toFixed(1)}% (${m.typedErrors.typedThrows}/${m.typedErrors.totalThrows}) |`
);
lines.push(
`| typed-error adoption (P4 locked subdomains) | ${(m.typedErrorAdoption.ratio * 100).toFixed(1)}% (${m.typedErrorAdoption.numerator}/${m.typedErrorAdoption.denominator}), target 40% |`
);
lines.push(
`| hotpath console.error/warn occurrences | ${m.hotpathConsoleErrors.hotpathOccurrences} (${m.hotpathConsoleErrors.totalOccurrences} total, ${m.hotpathConsoleErrors.exemptOccurrences} CLI-UX exempt) |`
);
lines.push(`| hotpath console.error/warn files | ${m.hotpathConsoleErrors.filesAffected} |`);
lines.push(
`| files with createLogger | ${m.loggerCoverage.filesWithCreateLogger}/${m.loggerCoverage.totalSourceFiles} |`
);
lines.push(
`| subdomains with zero createLogger | ${m.loggerCoverage.subdomainsWithZeroCreateLogger.length} (${m.loggerCoverage.subdomainsWithZeroCreateLogger.join(', ') || 'none'}) |`
);
lines.push(`| files > 400 LOC | ${m.largeFiles.countOver400} |`);
lines.push(`| files > 600 LOC | ${m.largeFiles.countOver600} |`);
lines.push('');
lines.push('### Top Hotpath console.error/warn Files');
lines.push('');
lines.push('| File | console.error/warn |');
lines.push('|---|---:|');
for (const item of m.hotpathConsoleErrors.topFiles) {
lines.push(`| \`${item.file}\` | ${item.count} |`);
}
if (m.hotpathConsoleErrors.topFiles.length === 0) {
lines.push('| _none_ | 0 |');
}
lines.push('');
lines.push('### Files > 400 LOC (top 15)');
lines.push('');
lines.push('| File | LOC |');
lines.push('|---|---:|');
for (const item of m.largeFiles.topOver400) {
lines.push(`| \`${item.file}\` | ${item.loc} |`);
}
if (m.largeFiles.topOver400.length === 0) {
lines.push('| _none_ | 0 |');
}
lines.push('');
}
lines.push('');
return lines.join('\n');
}
@@ -510,17 +562,23 @@ function main() {
console.log(
`[hardening-inventory] legacy markers total=${report.legacyShim.totalMarkers}, files=${report.legacyShim.filesAffected}`
);
if (report.maintainability) {
const mt = report.maintainability;
console.log(
`[hardening-inventory] maintainability: typed-adoption=${(mt.typedErrors.adoptionRatio * 100).toFixed(1)}% (${mt.typedErrors.typedThrows}/${mt.typedErrors.totalThrows}), locked=${(mt.typedErrorAdoption.ratio * 100).toFixed(1)}% (${mt.typedErrorAdoption.numerator}/${mt.typedErrorAdoption.denominator}), console.error=${mt.hotpathConsoleErrors.hotpathOccurrences}, zero-logger-subdomains=${mt.loggerCoverage.subdomainsWithZeroCreateLogger.length}, >400LOC=${mt.largeFiles.countOver400}`
);
}
console.log(`[hardening-inventory] wrote ${relJson}`);
console.log(`[hardening-inventory] wrote ${relMd}`);
}
if (require.main === module) {
main();
}
module.exports = {
buildReport,
collectSyncCallSites,
renderMarkdown,
stripComments,
};
if (require.main === module) {
main();
}
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env node
/**
* Maintainability metrics collector for the CCS CLI maintainability epic.
*
* Computes the metrics the epic tracks across phases:
* - typed-error adoption (throw new <TypedError> vs throw new Error vs other)
* - createLogger coverage by subdomain (which subdomains have zero)
* - hotpath console.error / console.warn call-site count (CLI-UX exempt)
* - files > 400 / 600 LOC
* - typed-error adoption in the P4 LOCKED denominator subdomains
*
* Accuracy relies on stripping comments/strings before regex matching. We
* reuse hardening-inventory.js#stripComments for that. The require is lazy
* (inside sanitize()) because hardening-inventory.js requires THIS module at
* its top level; a top-level require back would capture hardening-inventory's
* partial module.exports during the load cycle (it reassigns module.exports at
* the bottom). A function-scope require resolves against the fully-loaded
* module at call time, so the cycle is harmless.
*
* Approximate by design (grep-based). Method documented in
* docs/hardening-debt-burndown.md. Re-baseline when the schema changes.
*/
const fs = require('fs');
const path = require('path');
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
// CCSError subclasses (src/errors/error-types.ts). A `throw new <OneOfThese>`
// counts as TYPED. `throw new Error(...)` is plain. Any other `throw new X(`
// is "other" (error subclass outside the canonical taxonomy).
const TYPED_ERROR_CLASSES = new Set([
'CCSError',
'ConfigError',
'NetworkError',
'AuthError',
'BinaryError',
'ProviderError',
'ProfileError',
'ProxyError',
'MigrationError',
'UserAbortError',
'ValidationError',
'RetryableError',
]);
// P4 LOCKED denominator: typed-error adoption is measured ONLY over these
// subdomains so the >40% goal cannot be gamed by narrowing scope. Emits both
// numerator and denominator counts alongside the ratio.
const TYPED_ADOPTION_SUBDOMAINS = ['cliproxy/quota', 'cliproxy/auth', 'web-server/routes', 'auth'];
// CLI-UX print surfaces exempt from the hotpath console.error sweep (P3).
// Diagnostics here are legitimate user-facing terminal output, not loggable
// errors, and stay on stdout/stderr via utils/ui. src/utils/error-manager.ts is
// the user-facing error display module (ErrorManager.show*), a sibling to
// utils/ui, so its console.error calls are display output, not diagnostics.
const CLI_UX_EXEMPT_PREFIXES = [
'src/commands/',
'src/management/',
'src/utils/ui/',
'src/utils/error-manager.ts',
];
const THROW_NEW_REGEX = /\bthrow\s+new\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
const CONSOLE_ERR_WARN_REGEX = /\bconsole\s*\.\s*(?:error|warn)\s*\(/g;
const CREATE_LOGGER_REGEX = /\bcreateLogger\s*\(/;
function sanitize(sourceText) {
// Lazy require; see module header for the circular-dependency rationale.
return require('./hardening-inventory.js').stripComments(sourceText);
}
function toPosixPath(filePath) {
return filePath.split(path.sep).join('/');
}
function isSourceFile(filePath) {
return SOURCE_EXTENSIONS.has(path.extname(filePath));
}
function isTestFile(relPath) {
return (
/(?:^|\/)(?:__tests__|tests?)\//.test(relPath) ||
/\.test\./.test(relPath) ||
/\.spec\./.test(relPath)
);
}
function isCliUxExempt(relPath) {
return CLI_UX_EXEMPT_PREFIXES.some(function (prefix) {
return relPath.startsWith(prefix);
});
}
/**
* Subdomain key for a src-relative path.
* src/cliproxy/quota/x.ts -> "cliproxy/quota"
* src/auth/x.ts -> "auth"
* src/x.ts -> "<root>"
*/
function subdomainOf(relPath) {
const rest = relPath.replace(/^src\//, '');
const parts = rest.split('/');
if (parts[0] === 'cliproxy' && parts.length > 2) {
return parts[0] + '/' + parts[1];
}
return parts[0] || '<root>';
}
function round4(n) {
return Math.round(n * 10000) / 10000;
}
// Items may be file-shaped ({file,count}) or subdomain-shaped ({subdomain,count});
// the tiebreaker key is whichever label field is present.
function labelOf(item) {
return item.file || item.subdomain || '';
}
function topByCount(items, limit) {
return items
.slice()
.sort(function (a, b) {
return b.count - a.count || labelOf(a).localeCompare(labelOf(b));
})
.slice(0, limit);
}
/** Classify `throw new X(` occurrences in sanitized source. */
function classifyThrows(sourceText) {
const sanitized = sanitize(sourceText);
const counts = { typed: 0, plain: 0, other: 0, total: 0 };
const re = new RegExp(THROW_NEW_REGEX.source, 'g');
let match;
while ((match = re.exec(sanitized)) !== null) {
const identifier = match[1];
counts.total += 1;
if (identifier === 'Error') {
counts.plain += 1;
} else if (TYPED_ERROR_CLASSES.has(identifier)) {
counts.typed += 1;
} else {
counts.other += 1;
}
}
return counts;
}
/** Count console.error / console.warn call sites in sanitized source. */
function countConsoleErrors(sourceText) {
const sanitized = sanitize(sourceText);
const re = new RegExp(CONSOLE_ERR_WARN_REGEX.source, 'g');
return (sanitized.match(re) || []).length;
}
/** True if the file directly creates a logger via createLogger(...). */
function hasCreateLogger(sourceText) {
return CREATE_LOGGER_REGEX.test(sanitize(sourceText));
}
/** Raw line count of a source file (matches `wc -l` content semantics). */
function countLoc(sourceText) {
if (!sourceText) return 0;
const parts = sourceText.split(/\r?\n/);
return sourceText.endsWith('\n') ? parts.length - 1 : parts.length;
}
function walkFiles(dirPath) {
const output = [];
let entries;
try {
entries = fs.readdirSync(dirPath, { withFileTypes: true });
} catch (_err) {
return output;
}
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
output.push.apply(output, walkFiles(fullPath));
continue;
}
if (entry.isFile() && isSourceFile(fullPath)) output.push(fullPath);
}
return output;
}
function emptyAggregates() {
return {
typedBySubdomain: {},
loggerBySubdomain: {},
hotpathByFile: [],
hotpathTotal: 0,
hotpathExempt: 0,
hotpathNonExempt: 0,
hotpathFilesAffected: 0,
filesWithLogger: 0,
totalSourceFiles: 0,
typedTotal: 0,
typedTyped: 0,
typedPlain: 0,
typedOther: 0,
over400: [],
over600: [],
};
}
function ensureBucket(obj, key, factory) {
if (!obj[key]) obj[key] = factory();
return obj[key];
}
/**
* Walk <rootDir>/src and aggregate maintainability metrics.
* Returns the `maintainability` block merged into the hardening inventory.
*/
function collectMaintainabilityMetrics(rootDir) {
const srcDir = path.join(rootDir, 'src');
const files = walkFiles(srcDir);
const agg = emptyAggregates();
for (const fullPath of files) {
const relPath = toPosixPath(path.relative(rootDir, fullPath));
if (isTestFile(relPath)) continue;
const sourceText = fs.readFileSync(fullPath, 'utf8');
const sub = subdomainOf(relPath);
const throws = classifyThrows(sourceText);
agg.typedTotal += throws.total;
agg.typedTyped += throws.typed;
agg.typedPlain += throws.plain;
agg.typedOther += throws.other;
if (throws.total > 0) {
const bucket = ensureBucket(agg.typedBySubdomain, sub, function () {
return { typed: 0, plain: 0, other: 0, total: 0 };
});
bucket.typed += throws.typed;
bucket.plain += throws.plain;
bucket.other += throws.other;
bucket.total += throws.total;
}
agg.totalSourceFiles += 1;
const hasLogger = hasCreateLogger(sourceText);
if (hasLogger) agg.filesWithLogger += 1;
const lc = ensureBucket(agg.loggerBySubdomain, sub, function () {
return { files: 0, withLogger: 0 };
});
lc.files += 1;
if (hasLogger) lc.withLogger += 1;
const ce = countConsoleErrors(sourceText);
if (ce > 0) {
agg.hotpathTotal += ce;
if (isCliUxExempt(relPath)) {
agg.hotpathExempt += ce;
} else {
agg.hotpathNonExempt += ce;
agg.hotpathFilesAffected += 1;
agg.hotpathByFile.push({ file: relPath, count: ce });
}
}
const loc = countLoc(sourceText);
if (loc > 600) agg.over600.push({ file: relPath, loc: loc });
if (loc > 400) agg.over400.push({ file: relPath, loc: loc });
}
const adoptionRatio = agg.typedTotal > 0 ? agg.typedTyped / agg.typedTotal : 0;
let numerator = 0;
let denominator = 0;
for (const sub of TYPED_ADOPTION_SUBDOMAINS) {
const bucket = agg.typedBySubdomain[sub];
if (bucket) {
numerator += bucket.typed;
denominator += bucket.total;
}
}
const typedAdoptionRatio = denominator > 0 ? numerator / denominator : 0;
const subdomainsWithZeroCreateLogger = Object.keys(agg.loggerBySubdomain)
.filter(function (k) {
return agg.loggerBySubdomain[k].files > 0 && agg.loggerBySubdomain[k].withLogger === 0;
})
.sort();
const topOver400 = agg.over400
.slice()
.sort(function (a, b) {
return b.loc - a.loc || a.file.localeCompare(b.file);
})
.slice(0, 15);
return {
typedErrors: {
totalThrows: agg.typedTotal,
typedThrows: agg.typedTyped,
plainThrows: agg.typedPlain,
otherThrows: agg.typedOther,
adoptionRatio: round4(adoptionRatio),
topSubdomainsByThrows: topByCount(
Object.keys(agg.typedBySubdomain).map(function (k) {
const v = agg.typedBySubdomain[k];
return { subdomain: k, count: v.total, typed: v.typed, plain: v.plain };
}),
10
),
},
typedErrorAdoption: {
subdomains: TYPED_ADOPTION_SUBDOMAINS.slice(),
numerator: numerator,
denominator: denominator,
ratio: round4(typedAdoptionRatio),
targetRatio: 0.4,
},
loggerCoverage: {
filesWithCreateLogger: agg.filesWithLogger,
totalSourceFiles: agg.totalSourceFiles,
coverageRatio: round4(
agg.totalSourceFiles > 0 ? agg.filesWithLogger / agg.totalSourceFiles : 0
),
subdomainsWithZeroCreateLogger: subdomainsWithZeroCreateLogger,
topSubdomainsByFiles: topByCount(
Object.keys(agg.loggerBySubdomain).map(function (k) {
const v = agg.loggerBySubdomain[k];
return { subdomain: k, count: v.files, withLogger: v.withLogger };
}),
10
),
},
hotpathConsoleErrors: {
totalOccurrences: agg.hotpathTotal,
exemptOccurrences: agg.hotpathExempt,
hotpathOccurrences: agg.hotpathNonExempt,
filesAffected: agg.hotpathFilesAffected,
topFiles: topByCount(agg.hotpathByFile, 15),
},
largeFiles: {
countOver400: agg.over400.length,
countOver600: agg.over600.length,
topOver400: topOver400,
},
};
}
module.exports = {
collectMaintainabilityMetrics: collectMaintainabilityMetrics,
classifyThrows: classifyThrows,
countConsoleErrors: countConsoleErrors,
hasCreateLogger: hasCreateLogger,
countLoc: countLoc,
TYPED_ERROR_CLASSES: TYPED_ERROR_CLASSES,
TYPED_ADOPTION_SUBDOMAINS: TYPED_ADOPTION_SUBDOMAINS,
};
@@ -0,0 +1,131 @@
/**
* Regression tests for the `ccs api create --cliproxy-provider claude` bridge path.
*
* PR #1554 fixed the main CLIProxy env-builder to use the root URL for the built-in
* claude provider. This file locks the same rule on the parallel api-create bridge path
* (resolveCliproxyBridgeProfile / listCliproxyBridgeProviders) so both paths stay
* consistent.
*
* Background: CLIProxyAPI registers /v1/messages at the ROOT. The /api/provider/<x>
* prefix is a Plus-only route for non-Claude providers. Using /api/provider/claude
* returns 404 on the base CLIProxyAPI installation.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import {
resolveCliproxyBridgeProfile,
listCliproxyBridgeProviders,
} from '../cliproxy-profile-bridge';
import { resolveCliproxyBridgeMetadata } from '../cliproxy-profile-bridge';
import { invalidateConfigCache } from '../../../config/config-loader-facade';
import { clearConfigCache } from '../../../cliproxy/config/base-config-loader';
describe('cliproxy-profile-bridge: claude provider uses root URL', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
// Empty temp dir → loadOrCreateUnifiedConfig defaults to local target (127.0.0.1:8317).
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bridge-test-'));
process.env.CCS_HOME = tempHome;
invalidateConfigCache();
clearConfigCache();
});
afterEach(() => {
process.env.CCS_HOME = originalCcsHome;
invalidateConfigCache();
clearConfigCache();
fs.rmSync(tempHome, { recursive: true, force: true });
});
// ── resolveCliproxyBridgeProfile ──────────────────────────────────────────
it('resolveCliproxyBridgeProfile(claude) produces root base URL', () => {
const profile = resolveCliproxyBridgeProfile('claude');
// Must be the root URL — NOT /api/provider/claude.
expect(profile.baseUrl).toBe('http://127.0.0.1:8317/');
expect(profile.baseUrl).not.toContain('/api/provider/claude');
});
it('resolveCliproxyBridgeProfile(claude) reports root routePath', () => {
const profile = resolveCliproxyBridgeProfile('claude');
expect(profile.routePath).toBe('/');
});
it('resolveCliproxyBridgeProfile(claude) does not leak model pins (model-neutral)', () => {
const profile = resolveCliproxyBridgeProfile('claude');
expect(profile.models.default).toBe('');
expect(profile.models.opus).toBe('');
expect(profile.models.sonnet).toBe('');
expect(profile.models.haiku).toBe('');
});
it('resolveCliproxyBridgeProfile(gemini) still uses scoped /api/provider path', () => {
const profile = resolveCliproxyBridgeProfile('gemini');
expect(profile.baseUrl).toContain('/api/provider/gemini');
expect(profile.routePath).toBe('/api/provider/gemini');
});
it('resolveCliproxyBridgeProfile(codex) still uses scoped /api/provider path', () => {
const profile = resolveCliproxyBridgeProfile('codex');
expect(profile.baseUrl).toContain('/api/provider/codex');
expect(profile.routePath).toBe('/api/provider/codex');
});
// ── listCliproxyBridgeProviders ───────────────────────────────────────────
it('listCliproxyBridgeProviders shows root routePath for claude', () => {
const providers = listCliproxyBridgeProviders();
const claudeInfo = providers.find((p) => p.provider === 'claude');
expect(claudeInfo).toBeDefined();
expect(claudeInfo?.routePath).toBe('/');
expect(claudeInfo?.routePath).not.toContain('/api/provider/claude');
});
it('listCliproxyBridgeProviders keeps scoped routePaths for non-claude providers', () => {
const providers = listCliproxyBridgeProviders();
for (const info of providers) {
if (info.provider === 'claude') continue;
expect(info.routePath).toBe(`/api/provider/${info.provider}`);
}
});
// ── resolveCliproxyBridgeMetadata fallback behaviour under root URL ───────
//
// When a claude profile stores the fixed root URL (http://127.0.0.1:8317/),
// extractProviderFromPathname('/') returns null (no /api/provider/ segment),
// so resolveCliproxyBridgeMetadata returns null for that settings object.
// Dashboard routes fall back to mapExternalProviderName(profile.name) or the
// profile's cliproxyProvider field — both benign paths that still identify the
// provider correctly. This test locks the null-return so a future change to
// extractProviderFromPathname cannot silently introduce a regression.
it('resolveCliproxyBridgeMetadata returns null for a root-URL claude settings object (benign fallback locked)', () => {
const settings = {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
};
// extractProviderFromPathname cannot identify the provider from a root path.
// The caller falls back to profile.name / cliproxyBridge from other sources.
expect(resolveCliproxyBridgeMetadata(settings)).toBeNull();
});
it('resolveCliproxyBridgeMetadata still resolves non-claude providers from scoped URL', () => {
const settings = {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
};
const meta = resolveCliproxyBridgeMetadata(settings);
expect(meta).not.toBeNull();
expect(meta?.provider).toBe('gemini');
});
});
+20 -9
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver';
import { buildCliproxyProviderPath } from '../../cliproxy/config/env-builder';
import { getEffectiveApiKey } from '../../cliproxy/auth/auth-token-manager';
import { getModelMappingFromConfig } from '../../cliproxy/config/base-config-loader';
import {
@@ -107,13 +108,18 @@ function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping {
}
export function listCliproxyBridgeProviders(): CliproxyBridgeProviderInfo[] {
return CLIPROXY_PROVIDER_IDS.map((provider) => ({
provider,
displayName: getProviderDisplayName(provider),
description: getProviderDescription(provider),
defaultProfileName: getDefaultCliproxyBridgeName(provider),
routePath: `/api/provider/${provider}`,
}));
return CLIPROXY_PROVIDER_IDS.map((provider) => {
const providerPath = buildCliproxyProviderPath(provider);
return {
provider,
displayName: getProviderDisplayName(provider),
description: getProviderDescription(provider),
defaultProfileName: getDefaultCliproxyBridgeName(provider),
// claude uses root path (CLIProxyAPI registers /v1/messages at root);
// all other providers use their scoped /api/provider/<x> route.
routePath: providerPath === '' ? '/' : providerPath,
};
});
}
export function resolveCliproxyBridgeProfile(
@@ -125,8 +131,13 @@ export function resolveCliproxyBridgeProfile(
): ResolvedCliproxyBridgeProfile {
const target = getProxyTarget();
const profileName = options.name?.trim() || suggestCliproxyBridgeName(provider);
const baseUrl = buildProxyUrl(target, `/api/provider/${provider}`);
// Use the shared path helper so the claude provider always resolves to the
// CLIProxy root URL (same rule as buildLocalProviderBaseUrl in env-builder).
const providerPath = buildCliproxyProviderPath(provider);
const baseUrl = buildProxyUrl(target, providerPath);
const apiKey = target.authToken ?? getEffectiveApiKey();
// Expose the canonical route path: root for claude, scoped path for others.
const routePath = providerPath === '' ? '/' : providerPath;
return {
name: profileName,
@@ -136,7 +147,7 @@ export function resolveCliproxyBridgeProfile(
apiKey,
models: resolveBridgeModelMapping(provider),
target: options.target || 'claude',
routePath: `/api/provider/${provider}`,
routePath,
source: target.isRemote ? 'remote' : 'local',
};
}
+13 -12
View File
@@ -13,6 +13,7 @@ import {
mutateConfig,
} from '../config/config-loader-facade';
import { normalizeSharedResourceMetadata, type SharedResourceMode } from './shared-resource-policy';
import { ConfigError, ProfileError } from '../errors/error-types';
const logger = createLogger('auth:profile-registry');
@@ -132,7 +133,7 @@ export class ProfileRegistry {
return JSON.parse(data) as ProfileData;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to read profiles: ${message}`);
throw new ConfigError(`Failed to read profiles: ${message}`, this.profilesPath);
}
}
@@ -159,7 +160,7 @@ export class ProfileRegistry {
fs.unlinkSync(tempPath);
}
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to write profiles: ${message}`);
throw new ConfigError(`Failed to write profiles: ${message}`, this.profilesPath);
}
}
@@ -170,7 +171,7 @@ export class ProfileRegistry {
const data = this._read();
if (data.profiles[name]) {
throw new Error(`Profile already exists: ${name}`);
throw new ProfileError(`Profile already exists: ${name}`, name);
}
// v3.0 minimal schema: only essential fields
@@ -203,7 +204,7 @@ export class ProfileRegistry {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
throw new ProfileError(`Profile not found: ${name}`, name);
}
return this.normalizeLegacyProfileMetadata(data.profiles[name]);
@@ -216,7 +217,7 @@ export class ProfileRegistry {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
throw new ProfileError(`Profile not found: ${name}`, name);
}
data.profiles[name] = this.normalizeLegacyProfileMetadata({
@@ -234,7 +235,7 @@ export class ProfileRegistry {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
throw new ProfileError(`Profile not found: ${name}`, name);
}
delete data.profiles[name];
@@ -287,7 +288,7 @@ export class ProfileRegistry {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
throw new ProfileError(`Profile not found: ${name}`, name);
}
data.default = name;
@@ -330,7 +331,7 @@ export class ProfileRegistry {
createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
mutateConfig((config) => {
if (config.accounts[name]) {
throw new Error(`Account already exists: ${name}`);
throw new ProfileError(`Account already exists: ${name}`, name);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
created: new Date().toISOString(),
@@ -350,7 +351,7 @@ export class ProfileRegistry {
updateAccountUnified(name: string, updates: Partial<AccountConfig>): void {
mutateConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
throw new ProfileError(`Account not found: ${name}`, name);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
...config.accounts[name],
@@ -365,7 +366,7 @@ export class ProfileRegistry {
removeAccountUnified(name: string): void {
mutateConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
throw new ProfileError(`Account not found: ${name}`, name);
}
delete config.accounts[name];
if (config.default === name) {
@@ -382,7 +383,7 @@ export class ProfileRegistry {
const exists =
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
if (!exists) {
throw new Error(`Profile not found: ${name}`);
throw new ProfileError(`Profile not found: ${name}`, name);
}
config.default = name;
});
@@ -434,7 +435,7 @@ export class ProfileRegistry {
touchAccountUnified(name: string): void {
mutateConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
throw new ProfileError(`Account not found: ${name}`, name);
}
config.accounts[name].last_used = new Date().toISOString();
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
@@ -5,6 +5,7 @@ import {
getDeviceCodeVerificationProviders,
getOAuthCallbackPort,
getOAuthFlowType,
getUnsupportedAuthStartReason,
PROVIDER_CAPABILITIES,
getProviderDisplayName,
getProvidersByOAuthFlow,
@@ -134,10 +135,20 @@ describe('provider-capabilities', () => {
expect(getOAuthCallbackPort('gitlab')).toBe(17171);
expect(getOAuthCallbackPort('gemini')).toBe(8085);
expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy');
expect(PROVIDER_CAPABILITIES.qwen.refreshOwnership).toBe('unsupported');
expect(getProviderDisplayName('agy')).toBe('Antigravity');
expect(getProviderDisplayName('kilo')).toBe('Kilo AI');
});
it('exposes auth start support separately from OAuth flow type', () => {
expect(getOAuthFlowType('qwen')).toBe('device_code');
expect(getUnsupportedAuthStartReason('qwen')).toContain(
'Qwen account linking is not supported'
);
expect(getUnsupportedAuthStartReason('kiro')).toBeNull();
expect(getUnsupportedAuthStartReason('qoder')).toBeNull();
});
it('throws when provider aliases collide across providers', () => {
const capabilitiesWithCollision = {
...PROVIDER_CAPABILITIES,
+92 -76
View File
@@ -252,32 +252,36 @@ export function warnCrossProviderDuplicates(provider: CLIProxyProvider): boolean
const duplicates = detectCrossProviderDuplicates();
if (duplicates.size === 0) return false;
console.error('');
console.error(warn('Account safety: cross-provider duplicate detected'));
console.error(
' Same Google account across "ccs gemini" + "ccs agy" is a known suspension/ban risk (ref: #509).'
process.stderr.write('\n');
process.stderr.write(String(warn('Account safety: cross-provider duplicate detected')) + '\n');
process.stderr.write(
' Same Google account across "ccs gemini" + "ccs agy" is a known suspension/ban risk (ref: #509).\n'
);
console.error(' This risk applies to both CLI sessions and accounts added from "ccs config".');
console.error(
' If provider requests start returning 403/Forbidden, treat it as a possible account disable/ban.'
process.stderr.write(
' This risk applies to both CLI sessions and accounts added from "ccs config".\n'
);
console.error(
' If you want to keep Google AI access on this account, do not continue this shared-account setup.'
process.stderr.write(
' If provider requests start returning 403/Forbidden, treat it as a possible account disable/ban.\n'
);
console.error(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
process.stderr.write(
' If you want to keep Google AI access on this account, do not continue this shared-account setup.\n'
);
console.error(` Details: ${ISSUE_509_URL}`);
console.error('');
process.stderr.write(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
);
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
process.stderr.write('\n');
for (const [email, providers] of duplicates) {
console.error(` ${maskEmail(email)} -> ${providers.join(', ')}`);
process.stderr.write(` ${maskEmail(email)} -> ${providers.join(', ')}\n`);
}
console.error('');
console.error(' Immediate action: pause duplicate account and use separate Google accounts.');
console.error(' Fix command: "ccs cliproxy pause <account> --provider <provider>"');
console.error('');
process.stderr.write('\n');
process.stderr.write(
' Immediate action: pause duplicate account and use separate Google accounts.\n'
);
process.stderr.write(' Fix command: "ccs cliproxy pause <account> --provider <provider>"\n');
process.stderr.write('\n');
return true;
}
@@ -289,27 +293,31 @@ export function warnNewAccountConflict(
email: string,
conflictingProviders: CLIProxyProvider[]
): void {
console.error('');
console.error(warn('Account safety: this email is used by another provider'));
console.error(
` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}`
process.stderr.write('\n');
process.stderr.write(
String(warn('Account safety: this email is used by another provider')) + '\n'
);
console.error(
' Reusing one Google account between "ccs gemini" and "ccs agy" can trigger bans.'
process.stderr.write(
` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}\n`
);
console.error(
' This applies to both CLI auth and "ccs config" dashboard auth for these providers.'
process.stderr.write(
' Reusing one Google account between "ccs gemini" and "ccs agy" can trigger bans.\n'
);
console.error(' 403/Forbidden responses can be an early sign of account disablement.');
console.error(
' If you want to keep Google AI access, do not continue with this shared-account setup.'
process.stderr.write(
' This applies to both CLI auth and "ccs config" dashboard auth for these providers.\n'
);
console.error(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
process.stderr.write(
' 403/Forbidden responses can be an early sign of account disablement.\n'
);
console.error(' Consider pausing the duplicate or using a different account.');
console.error(` Details: ${ISSUE_509_URL}`);
console.error('');
process.stderr.write(
' If you want to keep Google AI access, do not continue with this shared-account setup.\n'
);
process.stderr.write(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
);
process.stderr.write(' Consider pausing the duplicate or using a different account.\n');
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
process.stderr.write('\n');
}
function isBanWarningProvider(provider: CLIProxyProvider): boolean {
@@ -324,27 +332,29 @@ export function warnOAuthBanRisk(provider: CLIProxyProvider): void {
shownBanWarnings.add(provider);
const isAgy = provider === 'agy';
console.error('');
console.error(warn('Account safety warning (#509 - read before continuing)'));
console.error(
' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.'
process.stderr.write('\n');
process.stderr.write(
String(warn('Account safety warning (#509 - read before continuing)')) + '\n'
);
process.stderr.write(
' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.\n'
);
if (isAgy) {
console.error(
' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.'
process.stderr.write(
' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.\n'
);
}
console.error(
' This risk applies whether auth was done from CLI or from "ccs config" dashboard.'
process.stderr.write(
' This risk applies whether auth was done from CLI or from "ccs config" dashboard.\n'
);
console.error(
' If you want to keep Google AI access, do not continue with this shared-account setup.'
process.stderr.write(
' If you want to keep Google AI access, do not continue with this shared-account setup.\n'
);
console.error(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
process.stderr.write(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
);
console.error(` Details: ${ISSUE_509_URL}`);
console.error('');
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
process.stderr.write('\n');
}
/**
@@ -364,20 +374,22 @@ export function warnPossible403Ban(provider: CLIProxyProvider, errorMessage: str
return false;
}
console.error('');
console.error(warn(`Account safety: ${provider} returned 403/Forbidden (possible disable/ban)`));
console.error(
' For gemini/agy flows this often means Google blocked or disabled the account.'
process.stderr.write('\n');
process.stderr.write(
String(warn(`Account safety: ${provider} returned 403/Forbidden (possible disable/ban)`)) + '\n'
);
console.error(
' If you want to keep Google AI access, stop using this account/provider pairing immediately.'
process.stderr.write(
' For gemini/agy flows this often means Google blocked or disabled the account.\n'
);
console.error(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
process.stderr.write(
' If you want to keep Google AI access, stop using this account/provider pairing immediately.\n'
);
console.error(` Details: ${ISSUE_509_URL}`);
console.error(` Error: "${truncate(errorMessage, 160)}"`);
console.error('');
process.stderr.write(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
);
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
process.stderr.write(` Error: "${truncate(errorMessage, 160)}"\n`);
process.stderr.write('\n');
return true;
}
@@ -402,10 +414,12 @@ export function cleanupStaleAutoPauses(): void {
for (const { provider, accountId } of session.accounts) {
resumeAccount(provider, accountId);
}
console.error(
info(
`Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session`
)
process.stderr.write(
String(
info(
`Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session`
)
) + '\n'
);
}
@@ -561,15 +575,17 @@ export function enforceProviderIsolation(provider: CLIProxyProvider): number {
});
saveAutoPaused(freshData);
console.error('');
console.error(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`));
process.stderr.write('\n');
process.stderr.write(
String(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`)) + '\n'
);
for (const { provider: p, accountId } of toPause) {
const acct = registry.providers[p]?.accounts[accountId];
const display = acct?.email ? maskEmail(acct.email) : accountId;
console.error(` ${display} (${p})`);
process.stderr.write(` ${display} (${p})\n`);
}
console.error(' Will restore on session exit.');
console.error('');
process.stderr.write(' Will restore on session exit.\n');
process.stderr.write('\n');
return toPause.length;
}
@@ -646,14 +662,14 @@ export function handleBanDetection(
if (!isBanResponse(errorMessage, provider)) return false;
const actor = banActor(provider);
console.error('');
console.error(warn(`Account safety: account appears disabled by ${actor}`));
console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`);
console.error(` "${truncate(errorMessage, 120)}"`);
console.error('');
console.error(info('Auto-pausing this account to prevent further issues.'));
console.error(` Resume later: ccs ${provider} --resume ${accountId}`);
console.error('');
process.stderr.write('\n');
process.stderr.write(String(warn(`Account safety: account appears disabled by ${actor}`)) + '\n');
process.stderr.write(` Account "${maskEmail(accountId)}" (${provider}) returned:\n`);
process.stderr.write(` "${truncate(errorMessage, 120)}"\n`);
process.stderr.write('\n');
process.stderr.write(String(info('Auto-pausing this account to prevent further issues.')) + '\n');
process.stderr.write(` Resume later: ccs ${provider} --resume ${accountId}\n`);
process.stderr.write('\n');
return pauseAccount(provider, accountId);
}
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
import { triggerOAuth } from '../oauth-handler';
describe('triggerOAuth unsupported providers', () => {
const previousDisableBanWarnings = process.env.CCS_DISABLE_BAN_WARNINGS;
afterEach(() => {
if (previousDisableBanWarnings === undefined) {
delete process.env.CCS_DISABLE_BAN_WARNINGS;
} else {
process.env.CCS_DISABLE_BAN_WARNINGS = previousDisableBanWarnings;
}
});
it('fails Qwen account linking before preparing CLIProxy auth args', async () => {
process.env.CCS_DISABLE_BAN_WARNINGS = '1';
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const account = await triggerOAuth('qwen');
expect(account).toBeNull();
expect(logSpy.mock.calls.some(([message]) => String(message).includes('--qwen-login'))).toBe(
false
);
expect(
logSpy.mock.calls.some(([message]) =>
String(message).includes('Qwen account linking is not supported')
)
).toBe(true);
} finally {
logSpy.mockRestore();
}
});
});
+27 -15
View File
@@ -93,7 +93,7 @@ async function askYesNoStep(rl: Interface, step: string, message: string): Promi
const normalized = answer.toUpperCase();
if (normalized === 'YES') return true;
if (normalized === 'NO' || normalized === 'N' || normalized === '') return false;
console.error(warn('Please type YES or NO.'));
process.stderr.write(String(warn('Please type YES or NO.')) + '\n');
}
}
@@ -108,7 +108,7 @@ async function askResponsibilityPhrase(rl: Interface): Promise<boolean> {
if (normalizePhrase(answer) === ANTIGRAVITY_ACK_PHRASE) {
return true;
}
console.error(warn('Phrase mismatch. Try again.'));
process.stderr.write(String(warn('Phrase mismatch. Try again.')) + '\n');
}
return false;
}
@@ -119,14 +119,22 @@ function printResponsibilityHeader(context: AgyRiskContext): void {
? 'You are starting Antigravity OAuth account authorization.'
: 'You are starting a live Antigravity CLI session (ccs agy).';
console.error('');
console.error('╔══════════════════════════════════════════════════════════════════════╗');
console.error('║ Antigravity Responsibility Confirmation (Mandatory) ║');
console.error('╚══════════════════════════════════════════════════════════════════════╝');
console.error(` ${contextLine}`);
console.error(' Antigravity has active ban/suspension patterns for risky OAuth usage.');
console.error(` Policy issue: ${ANTIGRAVITY_RISK_ISSUE_URL}`);
console.error('');
process.stderr.write('' + '\n');
process.stderr.write(
'╔══════════════════════════════════════════════════════════════════════╗' + '\n'
);
process.stderr.write(
'║ Antigravity Responsibility Confirmation (Mandatory) ║' + '\n'
);
process.stderr.write(
'╚══════════════════════════════════════════════════════════════════════╝' + '\n'
);
process.stderr.write(` ${contextLine}` + '\n');
process.stderr.write(
' Antigravity has active ban/suspension patterns for risky OAuth usage.' + '\n'
);
process.stderr.write(` Policy issue: ${ANTIGRAVITY_RISK_ISSUE_URL}` + '\n');
process.stderr.write('' + '\n');
}
export function hasAntigravityRiskAcceptanceFlag(args: string[]): boolean {
@@ -178,9 +186,11 @@ export async function ensureCliAntigravityResponsibility(
}
if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error(fail('Antigravity responsibility acknowledgement required.'));
console.error(' Re-run interactively and complete the 4-step confirmation.');
console.error(' Non-interactive override: --accept-agr-risk');
process.stderr.write(
String(fail('Antigravity responsibility acknowledgement required.')) + '\n'
);
process.stderr.write(' Re-run interactively and complete the 4-step confirmation.' + '\n');
process.stderr.write(' Non-interactive override: --accept-agr-risk' + '\n');
return false;
}
@@ -216,8 +226,10 @@ export async function ensureCliAntigravityResponsibility(
const step4 = await askResponsibilityPhrase(rl);
if (!step4) return false;
console.error(ok('Antigravity responsibility acknowledgement accepted for this command.'));
console.error(info('Proceeding with Antigravity flow...'));
process.stderr.write(
String(ok('Antigravity responsibility acknowledgement accepted for this command.')) + '\n'
);
process.stderr.write(String(info('Proceeding with Antigravity flow...')) + '\n');
return true;
} finally {
rl.close();
+2 -1
View File
@@ -11,6 +11,7 @@ import { randomBytes } from 'crypto';
import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from '../config/generator';
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
import { ProfileError } from '../../errors/error-types';
/**
* Generate a cryptographically secure token.
@@ -133,7 +134,7 @@ export function setVariantApiKey(variantName: string, apiKey: string | undefined
const variant = config.cliproxy.variants[variantName];
if (!variant) {
throw new Error(`Variant '${variantName}' not found`);
throw new ProfileError(`Variant '${variantName}' not found`, variantName);
}
if (!variant.auth) {
+3 -2
View File
@@ -5,6 +5,7 @@
*/
import { CLIProxyProvider } from '../types';
import { ProfileError, ValidationError } from '../../errors/error-types';
import type { AccountInfo } from '../accounts/account-manager';
import {
buildProviderMap,
@@ -109,7 +110,7 @@ export function getKiroCLIAuthArgs(
const startUrl = options?.idcStartUrl?.trim();
if (!startUrl) {
throw new Error('Kiro IDC login requires --kiro-idc-start-url');
throw new ValidationError('Kiro IDC login requires --kiro-idc-start-url', 'kiroIDCStartUrl');
}
const args = [getKiroCLIAuthFlag('idc'), '--kiro-idc-start-url', startUrl];
@@ -382,7 +383,7 @@ export function getManagementOAuthCallbackPath(): string {
export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig {
const config = OAUTH_CONFIGS[provider];
if (!config) {
throw new Error(`Unknown provider: ${provider}`);
throw new ProfileError(`Unknown provider: ${provider}`, provider);
}
return config;
}
+20 -6
View File
@@ -16,6 +16,7 @@ import { fail, info, warn, color, ok } from '../../utils/ui';
import { createLogger } from '../../services/logging';
import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager';
import { generateConfig } from '../config/config-generator';
import { AuthError, ConfigError } from '../../errors/error-types';
import { CLIProxyBackend, CLIProxyProvider } from '../types';
import {
AccountInfo,
@@ -78,6 +79,7 @@ import {
import { maybeOfferPoolRouting } from '../routing/pool-opt-in-prompt';
import { checkCrossLaneEmailOverlap } from '../accounts/account-safety-cross-lane';
import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility';
import { getUnsupportedAuthStartReason } from '../provider-capabilities';
import { InteractivePrompt } from '../../utils/prompt';
import { getCcsDir } from '../../utils/config-manager';
import { generateSessionId } from './project-selection-handler';
@@ -247,8 +249,9 @@ export async function requestPasteCallbackStart(
kiroMethod: options?.kiroMethod,
});
if (!startPath) {
throw new Error(
`Paste-callback start is not available for ${provider} with the selected method`
throw new AuthError(
`Paste-callback start is not available for ${provider} with the selected method`,
provider
);
}
const normalizedGitLabBaseUrl =
@@ -261,7 +264,7 @@ export async function requestPasteCallbackStart(
});
if (!response.ok) {
throw new Error(`OAuth start failed with status ${response.status}`);
throw new AuthError(`OAuth start failed with status ${response.status}`, provider);
}
return (await response.json()) as PasteCallbackStartData;
@@ -315,11 +318,11 @@ export function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | un
try {
parsed = new URL(normalized);
} catch {
throw new Error('GitLab URL must be a valid http:// or https:// URL');
throw new ConfigError('GitLab URL must be a valid http:// or https:// URL');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('GitLab URL must use http:// or https://');
throw new ConfigError('GitLab URL must use http:// or https://');
}
parsed.hash = '';
@@ -614,12 +617,17 @@ function buildOAuthArgs(
kiroIDCFlow?: OAuthOptions['kiroIDCFlow'];
} = {}
): string[] {
const unsupportedReason = getUnsupportedAuthStartReason(provider);
if (unsupportedReason) {
throw new AuthError(unsupportedReason, provider);
}
const args = ['--config', configPath];
if (provider === 'kiro') {
const method = normalizeKiroAuthMethod(options.kiroMethod);
if (!isKiroCLIAuthMethod(method)) {
throw new Error(`Kiro auth method '${method}' is not supported by CLI flow.`);
throw new AuthError(`Kiro auth method '${method}' is not supported by CLI flow.`, 'kiro');
}
args.push(
...getKiroCLIAuthArgs(method, {
@@ -1089,6 +1097,12 @@ export async function triggerOAuth(
options: OAuthOptions = {}
): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider);
const unsupportedReason = getUnsupportedAuthStartReason(provider);
if (unsupportedReason) {
console.log(fail(unsupportedReason));
return null;
}
warnOAuthBanRisk(provider);
const oauthStartedAt = Date.now();
logger.stage('auth', 'cliproxy.oauth.start', 'Triggering OAuth flow', {
@@ -4,8 +4,9 @@
* Exports refresh functions for each OAuth provider.
*
* Refresh responsibility:
* - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, qwen, iflow, kimi
* - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, iflow, kimi
* (CLIProxyAPIPlus handles refresh automatically in background)
* - Unsupported account linking: qwen
* - Not implemented: claude
*/
@@ -15,6 +16,7 @@ import {
getTokenRefreshOwnership,
isRefreshDelegatedToCLIProxy,
} from '../../provider-capabilities';
import { AuthError } from '../../../errors/error-types';
/** Token refresh result */
export interface ProviderRefreshResult {
@@ -26,7 +28,7 @@ export interface ProviderRefreshResult {
}
function assertNever(value: never): never {
throw new Error(`Unhandled token refresh ownership: ${String(value)}`);
throw new AuthError(`Unhandled token refresh ownership: ${String(value)}`);
}
/**
+2 -1
View File
@@ -497,9 +497,10 @@ export function displayAuthStatus(): void {
*
* Refresh responsibility:
* - gemini: CCS refreshes directly via Google OAuth
* - codex, agy, kiro, ghcp, qwen, iflow: CLIProxyAPIPlus handles refresh
* - codex, agy, kiro, ghcp, iflow: CLIProxyAPIPlus handles refresh
* automatically in background (e.g. kiro refreshes every 1 min).
* CCS only checks if token file exists (authentication state).
* - qwen: account linking is unsupported by the bundled CLIProxy runtime
* - claude: not yet implemented
*
* @param provider The CLIProxy provider
@@ -10,7 +10,12 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { getClaudeEnvVars, ensureProviderSettings, getRemoteEnvVars } from '../env-builder';
import {
getClaudeEnvVars,
ensureProviderSettings,
getEffectiveEnvVars,
getRemoteEnvVars,
} from '../env-builder';
import { clearConfigCache } from '../base-config-loader';
const MODEL_KEYS = [
@@ -45,10 +50,10 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
}
});
it('still sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => {
it('sets root ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => {
const env = getClaudeEnvVars('claude');
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
});
@@ -83,10 +88,57 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
}
// Transport keys must be present
expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
expect(written.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
});
it('normalizes stale claude provider-scoped base URL to CLIProxy root at read level', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
}),
'utf-8'
);
const env = getEffectiveEnvVars('claude', 8317, settingsPath);
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
});
it('repairs stale claude provider-scoped base URL in stored default settings', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
});
// ── Upgrade-path: existing claude.settings.json with stale default model pins ──
it('strips stale default model pins from existing claude.settings.json on ensureProviderSettings', () => {
@@ -107,7 +159,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
...stalePins,
},
@@ -128,7 +180,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
}
// Transport keys must still be present
expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
expect(repaired.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
});
@@ -143,7 +195,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-7', // customised — not the stale sonnet default
},
@@ -183,7 +235,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
...stalePins,
},
@@ -217,7 +269,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
const originalContent = JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
},
@@ -252,7 +304,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
@@ -285,7 +337,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6',
@@ -318,7 +370,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-8',
},
@@ -400,7 +452,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
@@ -464,7 +516,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
const originalContent =
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
@@ -502,7 +554,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-8', // custom — not a historical default
},
@@ -533,7 +585,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
customPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but explicit
},
@@ -568,7 +620,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but post-migration = explicit
},
+70 -9
View File
@@ -31,7 +31,11 @@ import {
normalizeIFlowLegacyModelAliases,
normalizeModelIdForProvider,
} from '../ai-providers/model-id-normalizer';
import { getGlobalEnvConfig, getCcsDir } from '../../config/config-loader-facade';
import {
getGlobalEnvConfig,
getOutputLimitsEnv,
getCcsDir,
} from '../../config/config-loader-facade';
/** Settings file structure for user overrides */
interface ProviderSettings {
@@ -201,8 +205,14 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin
/**
* Get environment variables for Claude CLI (bundled defaults)
* Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing.
* This enables concurrent gemini/codex usage - each session routes to its provider via URL path.
* Uses provider-specific endpoints (e.g., /api/provider/gemini) for explicit routing
* except for the built-in claude provider.
*
* Root-URL exception: the claude provider always uses the CLIProxy ROOT endpoint
* (http://127.0.0.1:<port>) instead of /api/provider/claude. CLIProxyAPI's Claude Code
* contract registers /v1/messages at the root; the /api/provider/ prefix is a Plus-only
* feature for non-Claude providers. buildCliproxyProviderPath() encodes this rule and is
* used here and by the api-create bridge path so both remain consistent.
*
* For the claude built-in provider the model env vars are intentionally omitted so that
* the user's own Claude Code /model selection is honored end-to-end (model-neutral passthrough).
@@ -227,7 +237,7 @@ export function getClaudeEnvVars(
// Core transport env vars set dynamically for all providers
const coreEnvVars: NodeJS.ProcessEnv = {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
ANTHROPIC_BASE_URL: buildLocalProviderBaseUrl(provider, port),
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
};
@@ -310,13 +320,21 @@ export function resolveProviderSettingsPath(provider: CLIProxyProvider): string
/**
* Get global env vars to inject into all third-party profiles.
* Returns empty object if disabled.
*
* Opt-in output limits (issue #231) are merged in on top of the global env so
* they reach every cliproxy launch path (local, remote, composite). When unset,
* getOutputLimitsEnv() returns {} and nothing is injected, preserving the
* downstream CLI's own default caps. Output limits are independent of the
* global_env enable flag: a user can opt into limits without enabling global
* telemetry-disable env.
*/
function getGlobalEnvVars(): Record<string, string> {
const outputLimitsEnv = getOutputLimitsEnv();
const globalEnvConfig = getGlobalEnvConfig();
if (!globalEnvConfig.enabled) {
return {};
return { ...outputLimitsEnv };
}
return globalEnvConfig.env;
return { ...globalEnvConfig.env, ...outputLimitsEnv };
}
/**
@@ -357,6 +375,25 @@ function ensureRequiredEnvVars(
/** Localhost hostnames used for local CLIProxy endpoints */
const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']);
/**
* Return the CLIProxy route path for a provider.
*
* - claude uses the root path (empty string → "/" after buildProxyUrl normalises it)
* because CLIProxyAPI's Claude Code contract registers /v1/messages at the root; the
* /api/provider/ prefix is Plus-only and only for non-Claude providers.
* - all other providers use the scoped /api/provider/<x> path.
*
* Exported so the profile-bridge can reuse the same rule (DRY).
*/
export function buildCliproxyProviderPath(provider: CLIProxyProvider): string {
return provider === 'claude' ? '' : `/api/provider/${provider}`;
}
function buildLocalProviderBaseUrl(provider: CLIProxyProvider, port: number): string {
const rootUrl = `http://127.0.0.1:${port}`;
return provider === 'claude' ? rootUrl : `${rootUrl}/api/provider/${provider}`;
}
/**
* Normalize local CLIProxy endpoint to the expected provider route.
* Only rewrites localhost URLs that target the active local port.
@@ -378,6 +415,10 @@ function normalizeLocalProviderBaseUrl(
: 80;
if (!Number.isFinite(effectivePort) || effectivePort !== port) return baseUrl;
if (provider === 'claude') {
return parsed.origin;
}
const expectedPath = `/api/provider/${provider}`;
if (parsed.pathname === expectedPath && !parsed.search && !parsed.hash) return baseUrl;
@@ -416,7 +457,9 @@ function rewriteLocalhostUrls(
// Omit port suffix for standard web ports (80/443) for cleaner URLs
const standardWebPort = normalizedProtocol === 'https' ? 443 : 80;
const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`;
const remoteBaseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`;
const remoteRootUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`;
const remoteBaseUrl =
provider === 'claude' ? remoteRootUrl : `${remoteRootUrl}/api/provider/${provider}`;
result.ANTHROPIC_BASE_URL = remoteBaseUrl;
@@ -465,7 +508,10 @@ export function getEffectiveEnvVars(
migrateDeprecatedModelNames(expandedPath, provider, settings);
// Migrate legacy iFlow placeholders to supported model IDs
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
// Custom variant settings found - merge with global env
// Custom variant settings found - merge with global env.
// settings.env is spread AFTER globalEnv, so an explicit per-variant
// value (e.g. MAX_MCP_OUTPUT_TOKENS) intentionally overrides the
// config.runtime.outputLimits value carried in globalEnv.
envVars = { ...globalEnv, ...settings.env };
// Ensure required vars are present (fall back to defaults if missing)
envVars = ensureRequiredEnvVars(envVars, provider, port);
@@ -498,7 +544,10 @@ export function getEffectiveEnvVars(
migrateDeprecatedModelNames(settingsPath, provider, settings);
// Migrate legacy iFlow placeholders to supported model IDs
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
// User override found - merge with global env
// User override found - merge with global env.
// settings.env is spread AFTER globalEnv, so an explicit per-variant
// value (e.g. MAX_MCP_OUTPUT_TOKENS) intentionally overrides the
// config.runtime.outputLimits value carried in globalEnv.
envVars = { ...globalEnv, ...settings.env };
// Ensure required vars are present (fall back to defaults if missing)
envVars = ensureRequiredEnvVars(envVars, provider, port);
@@ -695,6 +744,18 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
}
}
if (provider === 'claude' && typeof mergedEnv.ANTHROPIC_BASE_URL === 'string') {
const normalizedBaseUrl = normalizeLocalProviderBaseUrl(
mergedEnv.ANTHROPIC_BASE_URL,
provider,
CLIPROXY_DEFAULT_PORT
);
if (normalizedBaseUrl !== mergedEnv.ANTHROPIC_BASE_URL) {
mergedEnv.ANTHROPIC_BASE_URL = normalizedBaseUrl;
mutated = true;
}
}
// Canonicalize provider-specific model aliases (e.g., AGY Sonnet 4.6 thinking legacy IDs).
for (const key of MODEL_ENV_VAR_KEYS) {
const current = mergedEnv[key];
+41 -33
View File
@@ -135,13 +135,17 @@ export async function configureProviderModel(
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
// Show header with context (gradient like ccs doctor)
console.error('');
console.error(header(`Configure ${catalog.displayName} Model`));
console.error('');
console.error(dim(' Select which model to use for this provider.'));
console.error(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.'));
console.error(dim(' Models marked [DEPRECATED] are not recommended for use.'));
console.error('');
process.stderr.write('\n');
process.stderr.write(String(header(`Configure ${catalog.displayName} Model`)) + '\n');
process.stderr.write('\n');
process.stderr.write(String(dim(' Select which model to use for this provider.')) + '\n');
process.stderr.write(
String(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.')) + '\n'
);
process.stderr.write(
String(dim(' Models marked [DEPRECATED] are not recommended for use.')) + '\n'
);
process.stderr.write('\n');
// Interactive selection
const selectedModel = await InteractivePrompt.selectFromList('Select model:', options, {
@@ -209,19 +213,21 @@ export async function configureProviderModel(
const selectedEntry = catalog.models.find((m) => m.id === selectedModel);
const displayName = selectedEntry?.name || selectedModel;
console.error('');
console.error(ok(`Model set to: ${bold(displayName)}`));
console.error(dim(` Config saved: ${settingsPath}`));
process.stderr.write('\n');
process.stderr.write(String(ok(`Model set to: ${bold(displayName)}`)) + '\n');
process.stderr.write(String(dim(` Config saved: ${settingsPath}`)) + '\n');
// Show deprecation warning if model is deprecated
if (selectedEntry?.deprecated) {
console.error('');
console.error(color('[!] DEPRECATION WARNING', 'warning'));
process.stderr.write('\n');
process.stderr.write(String(color('[!] DEPRECATION WARNING', 'warning')) + '\n');
const reason = selectedEntry.deprecationReason || 'This model is deprecated';
console.error(dim(` ${reason}`));
console.error(dim(' Consider using a non-deprecated model for better compatibility.'));
process.stderr.write(String(dim(` ${reason}`)) + '\n');
process.stderr.write(
String(dim(' Consider using a non-deprecated model for better compatibility.')) + '\n'
);
}
console.error('');
process.stderr.write('\n');
return true;
}
@@ -231,7 +237,9 @@ export async function configureProviderModel(
*/
export async function showCurrentConfig(provider: CLIProxyProvider): Promise<void> {
if (!supportsModelConfig(provider)) {
console.error(info(`Provider ${provider} does not support model configuration`));
process.stderr.write(
String(info(`Provider ${provider} does not support model configuration`)) + '\n'
);
return;
}
@@ -247,33 +255,33 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
? canonicalizeModelForProvider(provider, currentModel)
: undefined;
console.error('');
console.error(header(`${catalog.displayName} Model Configuration`));
console.error('');
process.stderr.write('\n');
process.stderr.write(String(header(`${catalog.displayName} Model Configuration`)) + '\n');
process.stderr.write('\n');
if (currentModel) {
const entry = catalog.models.find((m) => m.id === normalizedCurrentModel);
const displayName = entry?.name || 'Unknown';
console.error(
` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}`
process.stderr.write(
` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}\n`
);
console.error(` ${bold('Config:')} ${dim(settingsPath)}`);
process.stderr.write(` ${bold('Config:')} ${dim(settingsPath)}\n`);
} else {
console.error(` ${bold('Current:')} ${dim('(using defaults)')}`);
console.error(` ${bold('Default:')} ${catalog.defaultModel}`);
process.stderr.write(` ${bold('Current:')} ${dim('(using defaults)')}\n`);
process.stderr.write(` ${bold('Default:')} ${catalog.defaultModel}\n`);
}
console.error('');
console.error(bold('Available models:'));
console.error(dim(' [Pro]/[Ultra] = Requires a paid provider plan'));
console.error(dim(' [DEPRECATED] = Not recommended for use'));
console.error('');
process.stderr.write('\n');
process.stderr.write(String(bold('Available models:')) + '\n');
process.stderr.write(String(dim(' [Pro]/[Ultra] = Requires a paid provider plan')) + '\n');
process.stderr.write(String(dim(' [DEPRECATED] = Not recommended for use')) + '\n');
process.stderr.write('\n');
catalog.models.forEach((m) => {
const isCurrent = m.id === normalizedCurrentModel;
console.error(formatModelDetailed(m, isCurrent));
process.stderr.write(String(formatModelDetailed(m, isCurrent)) + '\n');
});
console.error('');
console.error(dim(`Run "ccs ${provider} --config" to change`));
console.error('');
process.stderr.write('\n');
process.stderr.write(String(dim(`Run "ccs ${provider} --config" to change`)) + '\n');
process.stderr.write('\n');
}
@@ -167,7 +167,7 @@ describe('parseExecutorFlags', () => {
beforeEach(() => {
originalExitCode = process.exitCode as number | undefined;
process.exitCode = 0;
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
@@ -290,7 +290,7 @@ describe('validateFlagCombinations', () => {
beforeEach(() => {
originalExitCode = process.exitCode as number | undefined;
process.exitCode = 0;
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
});
afterEach(() => {
@@ -42,6 +42,7 @@ describe('resolveBrowserLaunchFlags — no browser flags', () => {
}));
mock.module('../../../config/config-loader-facade', () => ({
getBrowserConfig: () => makeBrowserConfig(false),
hasExplicitClaudeBrowserDevtoolsPort: () => false,
loadOrCreateUnifiedConfig: () => ({}),
getThinkingConfig: () => ({}),
}));
@@ -73,6 +74,7 @@ describe('resolveBrowserLaunchFlags — with browser-launch override', () => {
}));
mock.module('../../../config/config-loader-facade', () => ({
getBrowserConfig: () => makeBrowserConfig(true, 'auto'),
hasExplicitClaudeBrowserDevtoolsPort: () => false,
loadOrCreateUnifiedConfig: () => ({}),
getThinkingConfig: () => ({}),
}));
@@ -113,6 +115,7 @@ describe('resolveBrowserLaunchFlags — blocked override warning', () => {
}));
mock.module('../../../config/config-loader-facade', () => ({
getBrowserConfig: () => makeBrowserConfig(false, 'never'),
hasExplicitClaudeBrowserDevtoolsPort: () => false,
loadOrCreateUnifiedConfig: () => ({}),
getThinkingConfig: () => ({}),
}));
@@ -143,6 +146,7 @@ describe('resolveBrowserRuntime — attach disabled', () => {
}));
mock.module('../../../config/config-loader-facade', () => ({
getBrowserConfig: () => makeBrowserConfig(false),
hasExplicitClaudeBrowserDevtoolsPort: () => false,
loadOrCreateUnifiedConfig: () => ({}),
getThinkingConfig: () => ({}),
}));
@@ -173,6 +177,7 @@ describe('resolveBrowserRuntime — active runtime env', () => {
}));
mock.module('../../../config/config-loader-facade', () => ({
getBrowserConfig: () => makeBrowserConfig(true, 'always'),
hasExplicitClaudeBrowserDevtoolsPort: () => false,
loadOrCreateUnifiedConfig: () => ({}),
getThinkingConfig: () => ({}),
}));
@@ -71,6 +71,17 @@ describe('execClaudeWithCLIProxy browser flag validation', () => {
return fs.existsSync(filePath);
}
function makeWebSearchProvisioningFail(): void {
const ccsDir = path.join(tmpHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
'version: 13\nwebsearch:\n enabled: true\n providers:\n duckduckgo:\n enabled: true\n',
'utf8'
);
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
}
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
@@ -81,6 +92,120 @@ describe('execClaudeWithCLIProxy browser flag validation', () => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('keeps WebSearch provisioning strict for --config settings writes', async () => {
makeWebSearchProvisioningFail();
let requestCount = 0;
const server = http.createServer((_req, res) => {
requestCount += 1;
res.writeHead(200, { 'content-type': 'application/json' });
res.end('{"ok":true}');
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
throw new Error('Test server did not bind to a TCP port');
}
try {
await expect(
execClaudeWithCLIProxy(
fakeClaudePath,
'gemini',
[
'--proxy-host',
'127.0.0.1',
'--proxy-port',
String(address.port),
'--proxy-auth-token',
'SECRET_TOKEN_FOR_VALIDATION',
'--remote-only',
'--config',
],
{}
)
).rejects.toThrow(
'WebSearch is enabled, but CCS could not prepare the local WebSearch tool.'
);
expect(requestCount).toBeGreaterThan(0);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it('degrades WebSearch provisioning failures for CLIProxy launches', async () => {
makeWebSearchProvisioningFail();
const markerPath = path.join(tmpHome, 'fake-claude-launched');
fs.writeFileSync(
fakeClaudePath,
`#!/bin/sh\nprintf launched > ${JSON.stringify(markerPath)}\nexit 0\n`,
{ mode: 0o755 }
);
fs.chmodSync(fakeClaudePath, 0o755);
let requestCount = 0;
const server = http.createServer((_req, res) => {
requestCount += 1;
res.writeHead(200, { 'content-type': 'application/json' });
res.end('{"ok":true}');
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
throw new Error('Test server did not bind to a TCP port');
}
const exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
await execClaudeWithCLIProxy(
fakeClaudePath,
'gemini',
[
'--proxy-host',
'127.0.0.1',
'--proxy-port',
String(address.port),
'--proxy-auth-token',
'SECRET_TOKEN_FOR_VALIDATION',
'--remote-only',
'--print',
'hello',
],
{}
);
expect(await waitForFile(markerPath)).toBe(true);
expect(requestCount).toBeGreaterThan(0);
expect(exitSpy).toHaveBeenCalledWith(0);
} finally {
exitSpy.mockRestore();
logSpy.mockRestore();
errorSpy.mockRestore();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it('validates conflicting browser launch flags before remote proxy checks', async () => {
let requestCount = 0;
const server = http.createServer((_req, res) => {
@@ -57,7 +57,7 @@ describe('warnBrokenModels', () => {
let errorSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
mockGetCurrentModel.mockReset();
mockIsModelBroken.mockReturnValue(false);
mockGetModelIssueUrl.mockReturnValue(undefined);
+37 -33
View File
@@ -207,9 +207,9 @@ export function parseExecutorFlags(
const forceHeadless = args.includes('--headless');
if (pasteCallback && portForward) {
console.error(fail('Cannot use --paste-callback with --port-forward'));
console.error(' --paste-callback: Manually paste OAuth redirect URL');
console.error(' --port-forward: Use SSH port forwarding for callback');
process.stderr.write(String(fail('Cannot use --paste-callback with --port-forward')) + '\n');
process.stderr.write(' --paste-callback: Manually paste OAuth redirect URL\n');
process.stderr.write(' --port-forward: Use SSH port forwarding for callback\n');
process.exit(1);
}
@@ -247,8 +247,8 @@ export function parseExecutorFlags(
if (kiroMethodValue.present) {
const rawMethod = kiroMethodValue.value;
if (kiroMethodValue.missingValue || !rawMethod) {
console.error(fail('--kiro-auth-method requires a value'));
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.stderr.write(String(fail('--kiro-auth-method requires a value')) + '\n');
process.stderr.write(' Supported values: aws, aws-authcode, google, github, idc\n');
process.exitCode = 1;
// Caller must check parseFailed and bail — matching original return behavior
return buildPartialFlags({
@@ -280,8 +280,8 @@ export function parseExecutorFlags(
}
const normalized = rawMethod.trim().toLowerCase();
if (!isKiroAuthMethod(normalized)) {
console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`));
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.stderr.write(String(fail(`Invalid --kiro-auth-method value: ${rawMethod}`)) + '\n');
process.stderr.write(' Supported values: aws, aws-authcode, google, github, idc\n');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
@@ -318,7 +318,7 @@ export function parseExecutorFlags(
if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) {
kiroIDCStartUrl = kiroIDCStartUrlValue.value;
} else if (kiroIDCStartUrlValue.present) {
console.error(fail('--kiro-idc-start-url requires a value'));
process.stderr.write(String(fail('--kiro-idc-start-url requires a value')) + '\n');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
@@ -353,7 +353,7 @@ export function parseExecutorFlags(
if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) {
kiroIDCRegion = kiroIDCRegionValue.value;
} else if (kiroIDCRegionValue.present) {
console.error(fail('--kiro-idc-region requires a value'));
process.stderr.write(String(fail('--kiro-idc-region requires a value')) + '\n');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
@@ -388,8 +388,8 @@ export function parseExecutorFlags(
if (kiroIDCFlowValue.present) {
const rawFlow = kiroIDCFlowValue.value;
if (kiroIDCFlowValue.missingValue || !rawFlow) {
console.error(fail('--kiro-idc-flow requires a value'));
console.error(' Supported values: authcode, device');
process.stderr.write(String(fail('--kiro-idc-flow requires a value')) + '\n');
process.stderr.write(' Supported values: authcode, device\n');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
@@ -420,8 +420,8 @@ export function parseExecutorFlags(
}
const normalized = rawFlow.trim().toLowerCase();
if (!isKiroIDCFlow(normalized)) {
console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`));
console.error(' Supported values: authcode, device');
process.stderr.write(String(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`)) + '\n');
process.stderr.write(' Supported values: authcode, device\n');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
@@ -458,7 +458,7 @@ export function parseExecutorFlags(
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
} else if (gitlabBaseUrlValue.present) {
console.error(fail('--gitlab-url requires a value'));
process.stderr.write(String(fail('--gitlab-url requires a value')) + '\n');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
@@ -492,14 +492,14 @@ export function parseExecutorFlags(
const thinkingParse = parseThinkingOverride(args);
if (thinkingParse.error) {
const { flag } = thinkingParse.error;
console.error(fail(`${flag} requires a value`));
process.stderr.write(String(fail(`${flag} requires a value`)) + '\n');
if (provider === 'codex') {
console.error(' Codex examples: --effort xhigh, --effort high, --effort medium');
console.error(' Alias: --thinking xhigh (same behavior)');
process.stderr.write(' Codex examples: --effort xhigh, --effort high, --effort medium\n');
process.stderr.write(' Alias: --thinking xhigh (same behavior)\n');
} else {
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
console.error(' Levels: minimal, low, medium, high, xhigh, max, auto');
process.stderr.write(' Examples: --thinking low, --thinking 8192, --thinking off\n');
process.stderr.write(' Levels: minimal, low, medium, high, xhigh, max, auto\n');
}
process.exit(1);
@@ -511,7 +511,7 @@ export function parseExecutorFlags(
const hasNo1mFlag = args.includes('--no-1m') || args.some((arg) => arg.startsWith('--no-1m='));
if (has1mFlag && hasNo1mFlag) {
console.error(fail('Cannot use both --1m and --no-1m flags'));
process.stderr.write(String(fail('Cannot use both --1m and --no-1m flags')) + '\n');
process.exit(1);
} else if (has1mFlag) {
extendedContextOverride = true;
@@ -584,7 +584,7 @@ export function validateFlagCombinations(
} = parsed;
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
process.stderr.write(String(fail('--kiro-auth-method is only valid for ccs kiro')) + '\n');
process.exitCode = 1;
return false;
}
@@ -594,19 +594,21 @@ export function validateFlagCombinations(
provider !== 'kiro' &&
!compositeProviders.includes('kiro')
) {
console.error(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
)
process.stderr.write(
String(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
)
) + '\n'
);
process.exitCode = 1;
return false;
}
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
console.error(fail('Kiro IDC login requires --kiro-idc-start-url'));
console.error(
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
process.stderr.write(String(fail('Kiro IDC login requires --kiro-idc-start-url')) + '\n');
process.stderr.write(
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start\n'
);
process.exitCode = 1;
return false;
@@ -617,10 +619,12 @@ export function validateFlagCombinations(
kiroAuthMethod !== 'idc' &&
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)
) {
console.error(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
)
process.stderr.write(
String(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
)
) + '\n'
);
process.exitCode = 1;
return false;
@@ -628,7 +632,7 @@ export function validateFlagCombinations(
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url';
console.error(fail(`${flagName} is only valid for ccs gitlab`));
process.stderr.write(String(fail(`${flagName} is only valid for ccs gitlab`)) + '\n');
process.exitCode = 1;
return false;
}
+24 -17
View File
@@ -95,18 +95,18 @@ export async function handleImport(context: AuthCoordinationContext): Promise<bo
if (!forceImport) return false;
if (provider !== 'kiro') {
console.error(fail('--import is only available for Kiro'));
console.error(` Run "ccs ${provider} --auth" to authenticate`);
process.stderr.write(String(fail('--import is only available for Kiro')) + '\n');
process.stderr.write(` Run "ccs ${provider} --auth" to authenticate` + '\n');
process.exit(1);
}
if (forceAuth) {
console.error(fail('Cannot use --import with --auth'));
console.error(' --import: Import existing token from Kiro IDE');
console.error(' --auth: Trigger new OAuth flow in browser');
process.stderr.write(String(fail('Cannot use --import with --auth')) + '\n');
process.stderr.write(' --import: Import existing token from Kiro IDE' + '\n');
process.stderr.write(' --auth: Trigger new OAuth flow in browser' + '\n');
process.exit(1);
}
if (forceLogout) {
console.error(fail('Cannot use --import with --logout'));
process.stderr.write(String(fail('Cannot use --import with --logout')) + '\n');
process.exit(1);
}
@@ -121,8 +121,8 @@ export async function handleImport(context: AuthCoordinationContext): Promise<bo
...(setNickname ? { nickname: setNickname } : {}),
});
if (!authSuccess) {
console.error(fail('Failed to import Kiro token from IDE'));
console.error(' Make sure you are logged into Kiro IDE first');
process.stderr.write(String(fail('Failed to import Kiro token from IDE')) + '\n');
process.stderr.write(' Make sure you are logged into Kiro IDE first' + '\n');
process.exit(1);
}
process.exit(0);
@@ -177,7 +177,10 @@ export async function runAntigravityGate(
`Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
);
}
console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.'));
process.stderr.write(
String(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')) +
'\n'
);
return { earlyReturn: true };
}
@@ -189,10 +192,12 @@ export async function runAntigravityGate(
acceptedByFlag: acceptAgyRisk,
});
if (!acknowledged) {
console.error(
fail(
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
)
process.stderr.write(
String(
fail(
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
)
) + '\n'
);
process.exit(1);
}
@@ -262,9 +267,9 @@ export async function ensureProviderAuthentication(
}
if (failures.length > 0) {
const succeeded = compositeProviders.filter((p) => !failures.includes(p));
console.error(fail(`Auth failed for: ${failures.join(', ')}`));
process.stderr.write(String(fail(`Auth failed for: ${failures.join(', ')}`)) + '\n');
if (succeeded.length > 0) {
console.error(info(`Succeeded: ${succeeded.join(', ')}`));
process.stderr.write(String(info(`Succeeded: ${succeeded.join(', ')}`)) + '\n');
}
process.exit(1);
}
@@ -279,9 +284,11 @@ export async function ensureProviderAuthentication(
}
}
if (unauthenticatedProviders.length > 0) {
console.error(fail('Composite variant requires authentication for multiple providers:'));
process.stderr.write(
String(fail('Composite variant requires authentication for multiple providers:')) + '\n'
);
for (const p of unauthenticatedProviders) {
console.error(` - ${p} (run "ccs ${p} --auth")`);
process.stderr.write(` - ${p} (run "ccs ${p} --auth")` + '\n');
}
process.exit(1);
}
+10 -3
View File
@@ -20,7 +20,10 @@ import {
resolveOptionalBrowserAttachRuntime,
syncBrowserMcpToConfigDir,
} from '../../utils/browser';
import { getBrowserConfig } from '../../config/config-loader-facade';
import {
getBrowserConfig,
hasExplicitClaudeBrowserDevtoolsPort,
} from '../../config/config-loader-facade';
export interface BrowserLaunchSetupResult {
/** CLI override flag if --browser-launch / --no-browser-launch was passed */
@@ -57,7 +60,9 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
}
const browserConfig = getBrowserConfig();
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig, process.env, {
hasExplicitDevtoolsPort: hasExplicitClaudeBrowserDevtoolsPort(),
});
const claudeBrowserExposure = resolveBrowserExposure(
{
enabled: browserAttachConfig.enabled,
@@ -85,7 +90,9 @@ export async function resolveBrowserRuntime(
inheritedClaudeConfigDir: string | undefined
): Promise<Pick<BrowserLaunchSetupResult, 'browserRuntimeEnv'>> {
const browserConfig = getBrowserConfig();
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig, process.env, {
hasExplicitDevtoolsPort: hasExplicitClaudeBrowserDevtoolsPort(),
});
const claudeBrowserExposure = resolveBrowserExposure(
{
enabled: browserAttachConfig.enabled,
+39 -21
View File
@@ -13,6 +13,9 @@
import { ChildProcess } from 'child_process';
import * as fs from 'fs';
import { fail, info, warn } from '../../utils/ui';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor');
import {
generateConfig,
getProviderConfig,
@@ -23,7 +26,11 @@ import { supportsModelConfig } from '../model-catalog';
import { CLIProxyProvider, ExecutorConfig } from '../types';
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
import { ensureWebSearchMcpOrThrow, displayWebSearchStatus } from '../../utils/websearch-manager';
import {
ensureWebSearchMcpForLaunch,
ensureWebSearchMcpOrThrow,
displayWebSearchStatus,
} from '../../utils/websearch-manager';
import {
ensureImageAnalysisMcpOrThrow,
syncImageAnalysisMcpToConfigDir,
@@ -107,14 +114,14 @@ export async function execClaudeWithCLIProxy(
// Validate Claude CLI exists before proceeding
if (!fs.existsSync(claudeCli)) {
console.error(fail(`Claude CLI not found at: ${claudeCli}`));
console.error(' Run "ccs doctor --fix" to reinstall or check your PATH');
process.stderr.write(`${fail(`Claude CLI not found at: ${claudeCli}`)}\n`);
process.stderr.write(' Run "ccs doctor --fix" to reinstall or check your PATH\n');
process.exit(1);
}
const log = (msg: string) => {
if (verbose) {
console.error(`[cliproxy] ${msg}`);
logger.info('verbose', msg);
}
};
@@ -154,10 +161,7 @@ export async function execClaudeWithCLIProxy(
log,
});
// Setup first-class CCS WebSearch runtime
ensureWebSearchMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
displayWebSearchStatus();
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
@@ -188,6 +192,7 @@ export async function execClaudeWithCLIProxy(
const {
forceConfig,
forceImport,
addAccount,
showAccounts,
useAccount,
@@ -203,16 +208,16 @@ export async function execClaudeWithCLIProxy(
const thinkingCfg = getThinkingConfig();
if (thinkingParse.duplicateDisplays.length > 0) {
console.warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}`
process.stderr.write(
`[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}\n`
);
}
if (thinkingParse.sourceFlag === '--effort' && provider !== 'codex') {
console.warn(
warn(
process.stderr.write(
`${warn(
'`--effort` is primarily for codex. Continuing as alias of `--thinking` for compatibility.'
)
)}\n`
);
}
@@ -221,13 +226,17 @@ export async function execClaudeWithCLIProxy(
// Handle --config
if (forceConfig && supportsModelConfig(provider)) {
ensureWebSearchMcpOrThrow();
// Block --config for composite variants (per-tier models in config.yaml)
if (cfg.isComposite) {
const variantName = cfg.profileName || provider;
console.log(
warn('Composite variants use per-tier config. Edit config.yaml to change tier models.')
);
console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`);
process.stderr.write(
` Use "ccs cliproxy edit ${variantName}" to modify composite variants\n`
);
process.exit(1);
} else {
// Run the one-time stale-pin migration on the pre-existing settings file
@@ -261,8 +270,17 @@ export async function execClaudeWithCLIProxy(
await handleLogout(authCtx);
// Handle --import (early exit, Kiro only)
if (forceImport) {
ensureWebSearchMcpOrThrow();
}
await handleImport(authCtx);
// Setup first-class CCS WebSearch runtime for non-strict user launches.
const shouldDisplayWebSearchStatus = ensureWebSearchMcpForLaunch();
if (shouldDisplayWebSearchStatus) {
displayWebSearchStatus();
}
// 3. Ensure OAuth completed (if provider requires it)
const remoteAuthToken = proxyConfig.authToken?.trim();
const skipLocalAuth = resolveSkipLocalAuth(remoteAuthToken, useRemoteProxy);
@@ -369,7 +387,7 @@ export async function execClaudeWithCLIProxy(
);
} catch (error) {
const err = error as Error;
console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`));
process.stderr.write(`${warn(`Failed to start HTTPS tunnel: ${err.message}`)}\n`);
throw new Error(`HTTPS tunnel startup failed: ${err.message}`);
}
} else if (useRemoteProxy && proxyConfig.protocol === 'https' && provider === 'codex') {
@@ -526,16 +544,16 @@ export async function execClaudeWithCLIProxy(
const webSearchEnv = getWebSearchHookEnv();
if (process.env.CCS_DEBUG) {
console.error(
`[cliproxy-browser-debug] keys=${Object.keys(env)
logger.info('browser-env-keys', 'CCS_BROWSER_* keys in environment', {
keys: Object.keys(env)
.filter((key) => key.startsWith('CCS_BROWSER_'))
.sort()
.join(',')} ws=${env.CCS_BROWSER_DEVTOOLS_WS_URL || ''}`
);
.sort(),
ws: env.CCS_BROWSER_DEVTOOLS_WS_URL || '',
});
}
logEnvironment(env, webSearchEnv, verbose);
if (imageAnalysisWarning) {
console.error(info(imageAnalysisWarning));
process.stderr.write(`${info(imageAnalysisWarning)}\n`);
}
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
@@ -547,7 +565,7 @@ export async function execClaudeWithCLIProxy(
thinkingParse.sourceDisplay
);
console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`);
process.stderr.write(`[i] Thinking: ${thinkingLabel} (${sourceLabel})\n`);
}
// 12. Filter CCS flags, spawn Claude CLI, start quota monitor, wire cleanup
+19 -16
View File
@@ -14,6 +14,9 @@ import { fail } from '../../utils/ui';
import { getCliproxyWritablePath } from '../config/config-generator';
import { getPortCheckCommand, getCatCommand } from '../../utils/platform-commands';
import { CLIProxyBackend } from '../types';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor:lifecycle-manager');
/**
* Wait for TCP port to become available
@@ -62,7 +65,7 @@ export async function waitForProxyReady(
export function spawnProxy(binaryPath: string, configPath: string, verbose: boolean): ChildProcess {
const log = (msg: string) => {
if (verbose) {
console.error(`[cliproxy] ${msg}`);
logger.info('executor.lifecycle.spawn_verbose', msg);
}
};
@@ -81,7 +84,7 @@ export function spawnProxy(binaryPath: string, configPath: string, verbose: bool
proxy.unref();
proxy.on('error', (error) => {
console.error(fail(`CLIProxy spawn error: ${error.message}`));
process.stderr.write(String(fail(`CLIProxy spawn error: ${error.message}`)) + '\n');
});
return proxy;
@@ -108,20 +111,20 @@ export async function waitForProxyReadyWithSpinner(
readySpinner.fail(`${backendLabel} startup failed`);
const err = error as Error;
console.error('');
console.error(fail(`${backendLabel} failed to start`));
console.error('');
console.error('Possible causes:');
console.error(` 1. Port ${port} already in use`);
console.error(' 2. Binary crashed on startup');
console.error(' 3. Invalid configuration');
console.error('');
console.error('Troubleshooting:');
console.error(` - Check port: ${getPortCheckCommand(port)}`);
console.error(' - Run with --verbose for detailed logs');
console.error(` - View config: ${getCatCommand(configPath)}`);
console.error(' - Try: ccs doctor --fix');
console.error('');
process.stderr.write('' + '\n');
process.stderr.write(String(fail(`${backendLabel} failed to start`)) + '\n');
process.stderr.write('' + '\n');
process.stderr.write('Possible causes:' + '\n');
process.stderr.write(` 1. Port ${port} already in use` + '\n');
process.stderr.write(' 2. Binary crashed on startup' + '\n');
process.stderr.write(' 3. Invalid configuration' + '\n');
process.stderr.write('' + '\n');
process.stderr.write('Troubleshooting:' + '\n');
process.stderr.write(` - Check port: ${getPortCheckCommand(port)}` + '\n');
process.stderr.write(' - Run with --verbose for detailed logs' + '\n');
process.stderr.write(` - View config: ${getCatCommand(configPath)}` + '\n');
process.stderr.write(' - Try: ccs doctor --fix' + '\n');
process.stderr.write('' + '\n');
throw new Error(`CLIProxy startup failed: ${err.message}`);
}
+25 -13
View File
@@ -23,6 +23,18 @@ export interface ModelWarningsContext {
customSettingsPath?: string;
}
/**
* Write a line to stderr preserving prior `console.error` semantics.
*
* These lines are primary user-facing model warnings (rendered via the ui
* `warn()` helper or human-readable guidance the user must act on), so they
* stay on stderr verbatim rather than being routed through the structured
* logger.
*/
function stderr(line: string): void {
process.stderr.write(String(line) + '\n');
}
/**
* Check all active models for known issues and emit warnings.
*
@@ -40,17 +52,17 @@ export function warnBrokenModels(context: ModelWarningsContext): void {
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
console.error('');
console.error(
stderr('');
stderr(
warn(
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
)
);
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
stderr(' Tool calls will fail. Consider changing the model in config.yaml.');
if (issueUrl) {
console.error(` Tracking: ${issueUrl}`);
stderr(` Tracking: ${issueUrl}`);
}
console.error('');
stderr('');
}
}
} else {
@@ -59,22 +71,22 @@ export function warnBrokenModels(context: ModelWarningsContext): void {
const modelEntry = findModel(provider, currentModel);
const issueUrl = getModelIssueUrl(provider, currentModel);
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
console.error('');
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
stderr('');
stderr(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
if (replacementModel) {
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
stderr(` Tool calls will fail. Use "${replacementModel}" instead.`);
} else {
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
stderr(' Tool calls will fail. Consider changing the model in config.yaml.');
}
if (issueUrl) {
console.error(` Tracking: ${issueUrl}`);
stderr(` Tracking: ${issueUrl}`);
}
if (skipLocalAuth) {
console.error(' Note: Model may be overridden by remote proxy configuration.');
stderr(' Note: Model may be overridden by remote proxy configuration.');
} else {
console.error(` Run "ccs ${provider} --config" to change model.`);
stderr(` Run "ccs ${provider} --config" to change model.`);
}
console.error('');
stderr('');
}
}
}
+14 -11
View File
@@ -12,6 +12,9 @@ import { fail, warn, info } from '../../utils/ui';
import { CLIProxyProvider } from '../types';
import { handleBanDetection, warnPossible403Ban } from '../accounts/account-safety';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor:retry-handler');
/**
* Check if error is network-related
@@ -32,12 +35,12 @@ export function isNetworkError(error: Error): boolean {
* Handle network error with user-friendly message
*/
export function handleNetworkError(_error: Error): never {
console.error('');
console.error(fail('No network connection detected'));
console.error('');
console.error('CLIProxy binary download requires internet access.');
console.error('Please check your network connection and try again.');
console.error('');
process.stderr.write(String('') + '\n');
process.stderr.write(String(fail('No network connection detected')) + '\n');
process.stderr.write(String('') + '\n');
process.stderr.write(String('CLIProxy binary download requires internet access.') + '\n');
process.stderr.write(String('Please check your network connection and try again.') + '\n');
process.stderr.write(String('') + '\n');
process.exit(1);
}
@@ -63,16 +66,16 @@ export async function handleTokenExpiration(
}
// Token expired and refresh failed - trigger re-auth
console.error(warn('OAuth token expired and refresh failed'));
process.stderr.write(String(warn('OAuth token expired and refresh failed')) + '\n');
if (tokenResult.error) {
console.error(` ${tokenResult.error}`);
process.stderr.write(String(` ${tokenResult.error}`) + '\n');
}
console.error(` Run "ccs ${provider} --auth" to re-authenticate`);
process.stderr.write(String(` Run "ccs ${provider} --auth" to re-authenticate`) + '\n');
process.exit(1);
}
if (tokenResult.refreshed && verbose) {
console.error('[cliproxy] Token was refreshed proactively');
logger.info('token.refreshed', 'Token was refreshed proactively', { provider, verbose });
}
}
@@ -86,7 +89,7 @@ export async function handleQuotaCheck(provider: CLIProxyProvider): Promise<void
const preflight = await preflightCheck(provider);
if (!preflight.proceed) {
console.error(fail(`Cannot start session: ${preflight.reason}`));
process.stderr.write(String(fail(`Cannot start session: ${preflight.reason}`)) + '\n');
process.exit(1);
}
+26 -15
View File
@@ -26,6 +26,9 @@ import {
import { withStartupLock } from '../services/startup-lock';
import { killProcessOnPort } from '../../utils/platform-commands';
import { stopQuotaMonitor } from '../quota/quota-manager';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor:session-bridge');
export interface ProxySessionResult {
sessionId?: string;
@@ -43,7 +46,7 @@ export async function checkOrJoinProxy(
): Promise<ProxySessionResult> {
const log = (msg: string) => {
if (verbose) {
console.error(`[cliproxy] ${msg}`);
logger.info('proxy.check_or_join.trace', msg);
}
};
@@ -129,16 +132,18 @@ export async function checkOrJoinProxy(
// Truly blocked by another application
const { getPortCheckCommand } = await import('../../utils/platform-commands');
console.error('');
console.error(
warn(
`Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})`
)
process.stderr.write('\n');
process.stderr.write(
String(
warn(
`Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})`
)
) + '\n'
);
console.error('');
console.error('To fix this, close the blocking application or run:');
console.error(` ${getPortCheckCommand(port)}`);
console.error('');
process.stderr.write('\n');
process.stderr.write('To fix this, close the blocking application or run:\n');
process.stderr.write(` ${getPortCheckCommand(port)}\n`);
process.stderr.write('\n');
throw new Error(`Port ${port} is in use by another application`);
}
@@ -162,9 +167,13 @@ export function registerProxySession(
const sessionId = registerSession(port, pid, installedVersion, backend);
if (verbose) {
console.error(
`[cliproxy] Registered session ${sessionId} with new proxy (PID ${pid}, version ${installedVersion})`
);
logger.info('proxy.session.registered', 'Registered session with new proxy', {
sessionId,
port,
pid,
version: installedVersion,
backend,
});
}
return sessionId;
@@ -184,7 +193,7 @@ export function setupCleanupHandlers(
): void {
const log = (msg: string) => {
if (verbose) {
console.error(`[cliproxy] ${msg}`);
logger.info('proxy.cleanup.trace', msg);
}
};
@@ -228,7 +237,9 @@ export function setupCleanupHandlers(
});
claude.on('error', (error) => {
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
process.stderr.write(
String(require('../../utils/ui').fail(`Claude CLI error: ${error}`)) + '\n'
);
stopSessionResources();
process.exit(1);
});
@@ -38,7 +38,7 @@ export async function uploadTokenToRemote(
if (!target.isRemote) {
if (verbose) {
console.error('[upload] Remote mode not enabled, skipping upload');
process.stderr.write('[upload] Remote mode not enabled, skipping upload\n');
}
return false;
}
@@ -48,7 +48,9 @@ export async function uploadTokenToRemote(
try {
tokenContent = fs.readFileSync(tokenFilePath, 'utf-8');
} catch (error) {
console.error(fail(`Failed to read token file: ${(error as Error).message}`));
process.stderr.write(
String(fail(`Failed to read token file: ${(error as Error).message}`)) + '\n'
);
return false;
}
@@ -56,7 +58,7 @@ export async function uploadTokenToRemote(
try {
JSON.parse(tokenContent);
} catch {
console.error(fail('Invalid token file: not valid JSON'));
process.stderr.write(String(fail('Invalid token file: not valid JSON')) + '\n');
return false;
}
@@ -67,7 +69,7 @@ export async function uploadTokenToRemote(
const authKey = target.managementKey ?? target.authToken;
if (verbose) {
console.error(`[upload] Uploading ${fileName} to ${target.host}`);
process.stderr.write(`[upload] Uploading ${fileName} to ${target.host}\n`);
}
const controller = new AbortController();
@@ -95,7 +97,7 @@ export async function uploadTokenToRemote(
if (!response.ok) {
const text = await response.text();
console.error(fail(`Upload failed: ${response.status} ${text}`));
process.stderr.write(String(fail(`Upload failed: ${response.status} ${text}`)) + '\n');
return false;
}
@@ -105,16 +107,18 @@ export async function uploadTokenToRemote(
console.log(ok(`Token uploaded to remote server: ${fileName}`));
return true;
} else {
console.error(fail(`Upload failed: ${result.error || result.message || 'Unknown error'}`));
process.stderr.write(
String(fail(`Upload failed: ${result.error || result.message || 'Unknown error'}`)) + '\n'
);
return false;
}
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
console.error(fail('Upload timed out'));
process.stderr.write(String(fail('Upload timed out')) + '\n');
} else {
console.error(fail(`Upload failed: ${(error as Error).message}`));
process.stderr.write(String(fail(`Upload failed: ${(error as Error).message}`)) + '\n');
}
return false;
}
@@ -132,14 +136,14 @@ export async function uploadAllTokensToRemote(tokenDir: string, verbose = false)
if (!target.isRemote) {
if (verbose) {
console.error('[upload] Remote mode not enabled, skipping upload');
process.stderr.write('[upload] Remote mode not enabled, skipping upload\n');
}
return 0;
}
if (!fs.existsSync(tokenDir)) {
if (verbose) {
console.error(`[upload] Token directory does not exist: ${tokenDir}`);
process.stderr.write(`[upload] Token directory does not exist: ${tokenDir}\n`);
}
return 0;
}
@@ -148,7 +152,7 @@ export async function uploadAllTokensToRemote(tokenDir: string, verbose = false)
if (files.length === 0) {
if (verbose) {
console.error('[upload] No token files found');
process.stderr.write('[upload] No token files found\n');
}
return 0;
}
+35 -2
View File
@@ -1,7 +1,9 @@
import type { CLIProxyProvider } from './types';
import { ConfigError } from '../errors/error-types';
export type OAuthFlowType = 'authorization_code' | 'device_code';
export type TokenRefreshOwnership = 'ccs' | 'cliproxy' | 'unsupported';
export type AuthStartSupport = 'cliproxy-cli' | 'unsupported';
export interface ProviderCapabilities {
displayName: string;
@@ -14,6 +16,10 @@ export interface ProviderCapabilities {
authUrlProviderName: string;
/** Who owns token refresh logic for this provider. */
refreshOwnership: TokenRefreshOwnership;
/** Whether CCS can start account linking through the bundled CLIProxy binary. */
authStartSupport: AuthStartSupport;
/** User-facing reason when account linking cannot be started. */
authStartUnsupportedReason?: string;
/** Filename prefixes used to identify auth tokens for this provider. */
authFilePrefixes: readonly string[];
/** Token JSON "type" values accepted for this provider. */
@@ -34,6 +40,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'gemini',
authUrlProviderName: 'gemini-cli',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['gemini-', 'google-'],
tokenTypeValues: ['gemini'],
aliases: ['gemini-cli'],
@@ -46,6 +53,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'codex',
authUrlProviderName: 'codex',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['codex-', 'openai-'],
tokenTypeValues: ['codex'],
aliases: [],
@@ -58,6 +66,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'antigravity',
authUrlProviderName: 'antigravity',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['antigravity-', 'agy-'],
tokenTypeValues: ['antigravity'],
aliases: ['antigravity'],
@@ -69,7 +78,10 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackPort: null,
callbackProviderName: 'qwen',
authUrlProviderName: 'qwen',
refreshOwnership: 'cliproxy',
refreshOwnership: 'unsupported',
authStartSupport: 'unsupported',
authStartUnsupportedReason:
'Alibaba Qwen account linking is not supported by the bundled CLIProxy runtime. Use an API-key Qwen profile; CLIProxyAPI does not expose Qwen OAuth yet.',
authFilePrefixes: ['qwen-'],
tokenTypeValues: ['qwen'],
aliases: [],
@@ -82,6 +94,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'iflow',
authUrlProviderName: 'iflow',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['iflow-'],
tokenTypeValues: ['iflow'],
aliases: [],
@@ -94,6 +107,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'kiro',
authUrlProviderName: 'kiro',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['kiro-', 'aws-', 'codewhisperer-'],
tokenTypeValues: ['kiro', 'codewhisperer'],
aliases: ['codewhisperer'],
@@ -106,6 +120,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'copilot',
authUrlProviderName: 'github',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['github-copilot-', 'copilot-', 'gh-'],
tokenTypeValues: ['github-copilot', 'copilot'],
aliases: ['github-copilot', 'copilot'],
@@ -118,6 +133,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'anthropic',
authUrlProviderName: 'anthropic',
refreshOwnership: 'unsupported',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['claude-', 'anthropic-'],
tokenTypeValues: ['claude', 'anthropic'],
aliases: ['anthropic'],
@@ -130,6 +146,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'kimi',
authUrlProviderName: 'kimi',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['kimi-'],
tokenTypeValues: ['kimi'],
aliases: ['moonshot'],
@@ -142,6 +159,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'cursor',
authUrlProviderName: 'cursor',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['cursor.', 'cursor-'],
tokenTypeValues: ['cursor'],
aliases: [],
@@ -154,6 +172,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'gitlab',
authUrlProviderName: 'gitlab',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['gitlab-'],
tokenTypeValues: ['gitlab'],
aliases: ['gitlab-duo'],
@@ -166,6 +185,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'codebuddy',
authUrlProviderName: 'codebuddy',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['codebuddy-'],
tokenTypeValues: ['codebuddy'],
aliases: ['tencent'],
@@ -178,6 +198,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'kilo',
authUrlProviderName: 'kilo',
refreshOwnership: 'unsupported',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['kilo-'],
tokenTypeValues: ['kilo'],
aliases: [],
@@ -190,6 +211,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackProviderName: 'qoder',
authUrlProviderName: 'qoder',
refreshOwnership: 'unsupported',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['qoder-'],
tokenTypeValues: ['qoder'],
aliases: [],
@@ -254,7 +276,7 @@ export function buildProviderAliasMap(
const existingProvider = aliasMap.get(normalized);
if (existingProvider && existingProvider !== provider) {
throw new Error(
throw new ConfigError(
`Provider alias collision for "${normalized}": ${existingProvider} and ${provider}`
);
}
@@ -330,6 +352,17 @@ export function isRefreshDelegatedToCLIProxy(provider: CLIProxyProvider): boolea
return PROVIDER_CAPABILITIES[provider].refreshOwnership === 'cliproxy';
}
export function getUnsupportedAuthStartReason(provider: CLIProxyProvider): string | null {
const capabilities = PROVIDER_CAPABILITIES[provider];
if (capabilities.authStartSupport !== 'unsupported') {
return null;
}
return (
capabilities.authStartUnsupportedReason ??
`${capabilities.displayName} account linking is not supported by the bundled CLIProxy runtime.`
);
}
export function getProviderAuthFilePrefixes(provider: CLIProxyProvider): readonly string[] {
return PROVIDER_CAPABILITIES[provider].authFilePrefixes;
}
@@ -7,11 +7,12 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import * as http from 'http';
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
import { CodexReasoningProxy } from '../../ai-providers/codex-reasoning-proxy';
// Mock upstream server that echoes requests
let mockUpstream: http.Server;
let mockUpstreamPort: number;
let lastRequest: { body: unknown; headers: http.IncomingHttpHeaders } | null = null;
let lastRequest: { path: string; body: unknown; headers: http.IncomingHttpHeaders } | null = null;
// Track response to send back
let mockResponse: { status: number; body: unknown; stream?: boolean } = {
@@ -28,6 +29,7 @@ beforeAll(async () => {
req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
lastRequest = {
path: req.url || '',
body: body ? JSON.parse(body) : null,
headers: req.headers,
};
@@ -184,6 +186,50 @@ describe('ToolSanitizationProxy Integration', () => {
}
});
it('normalizes codex effort aliases through the provider-scoped local proxy chain', async () => {
const toolProxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const toolPort = await toolProxy.start();
const reasoningProxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${toolPort}`,
modelMap: {
defaultModel: 'gpt-5.5-high',
opusModel: 'gpt-5.5-xhigh',
sonnetModel: 'gpt-5.5-high',
haikuModel: 'gpt-5.5-mini-medium',
},
defaultEffort: 'medium',
});
const reasoningPort = await reasoningProxy.start();
try {
const response = await fetch(
`http://127.0.0.1:${reasoningPort}/api/provider/codex/v1/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.5-high',
messages: [{ role: 'user', content: 'hi' }],
}),
}
);
expect(response.ok).toBe(true);
expect(lastRequest).not.toBeNull();
expect(lastRequest!.path).toBe('/api/provider/codex/v1/messages');
expect((lastRequest!.body as Record<string, unknown>).model).toBe('gpt-5.5');
expect(
((lastRequest!.body as Record<string, unknown>).reasoning as Record<string, unknown>)
.effort
).toBe('high');
} finally {
reasoningProxy.stop();
toolProxy.stop();
}
});
it('normalizes dotted Claude thinking model IDs for root/composite routes', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
+23 -10
View File
@@ -16,6 +16,9 @@
import * as http from 'http';
import * as https from 'https';
import type { Socket } from 'net';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:https-tunnel-proxy');
export interface HttpsTunnelConfig {
/** Remote server hostname */
@@ -72,9 +75,13 @@ export class HttpsTunnelProxy {
};
}
private log(message: string): void {
/**
* Trace-level operational log gated on verbose mode (request routing, lifecycle chatter).
* Errors/warnings are logged directly via logger.* and are not gated.
*/
private trace(message: string): void {
if (this.config.verbose) {
console.error(`[https-tunnel] ${message}`);
logger.info('tunnel.trace', message);
}
}
@@ -104,7 +111,7 @@ export class HttpsTunnelProxy {
reject(new Error('Failed to bind to any port'));
return;
}
this.log(
this.trace(
`Started on port ${this.port}, tunneling to https://${this.config.remoteHost}:${this.config.remotePort}`
);
resolve(this.port);
@@ -132,7 +139,7 @@ export class HttpsTunnelProxy {
this.server = null;
this.port = null;
this.startingPromise = null;
this.log('Stopped');
this.trace('Stopped');
}
getPort(): number | null {
@@ -182,7 +189,7 @@ export class HttpsTunnelProxy {
const method = req.method || 'GET';
const requestPath = req.url || '/';
this.log(
this.trace(
`${method} ${requestPath} → https://${this.config.remoteHost}:${this.config.remotePort}${requestPath}`
);
@@ -190,7 +197,9 @@ export class HttpsTunnelProxy {
await this.forwardRequest(req, res, requestPath);
} catch (error) {
const err = error as Error;
this.log(`Error: ${err.message}`);
logger.error('tunnel.request_failed', 'Tunnel request handler failed', {
err: { name: err.name, message: err.message },
});
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
@@ -231,26 +240,30 @@ export class HttpsTunnelProxy {
upstreamReq.on('timeout', () => {
const timeoutError = new Error('Upstream request timeout');
this.log(`Timeout: ${timeoutError.message}`);
logger.warn('tunnel.upstream_timeout', timeoutError.message);
upstreamReq.destroy();
reject(timeoutError);
});
upstreamReq.on('error', (err) => {
this.log(`Upstream error: ${err.message}`);
logger.error('tunnel.upstream_error', 'Upstream request error', {
err: { name: err.name, message: err.message },
});
reject(err);
});
// Handle client disconnect (premature close)
originalReq.on('error', (err) => {
this.log(`Client request error: ${err.message}`);
logger.error('tunnel.client_request_error', 'Client request error', {
err: { name: err.name, message: err.message },
});
upstreamReq.destroy();
reject(err);
});
originalReq.on('close', () => {
if (!originalReq.complete) {
this.log('Client disconnected prematurely');
logger.warn('tunnel.client_premature_close', 'Client disconnected prematurely');
upstreamReq.destroy();
}
});
+80 -99
View File
@@ -12,9 +12,6 @@
import * as http from 'http';
import * as https from 'https';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { URL } from 'url';
import { ToolNameMapper, type Tool, type ContentBlock } from '../ai-providers/tool-name-mapper';
import { sanitizeToolSchemas } from '../ai-providers/schema-sanitizer';
@@ -28,7 +25,6 @@ import {
import { getModelMaxLevel } from '../model-catalog';
import { createLogger } from '../../services/logging';
import { getCcsDir } from '../../config/config-loader-facade';
import {
attachUpstreamResponseTimeout,
writeForwardResponseHead,
@@ -273,8 +269,6 @@ export class ToolSanitizationProxy {
private server: http.Server | null = null;
private port: number | null = null;
private readonly config: Required<ToolSanitizationProxyConfig>;
private readonly logFilePath: string;
private readonly debugMode: boolean;
private readonly logger = createLogger('cliproxy:tool-sanitization-proxy');
constructor(config: ToolSanitizationProxyConfig) {
@@ -285,69 +279,6 @@ export class ToolSanitizationProxy {
timeoutMs: config.timeoutMs ?? 120000,
allowSelfSigned: config.allowSelfSigned ?? false,
};
this.debugMode = process.env.CCS_DEBUG === '1';
this.logFilePath = this.initLogFile();
}
/**
* Initialize log file path and ensure directory exists.
*/
private initLogFile(): string {
const logsDir = path.join(getCcsDir(), 'logs');
try {
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
} catch (err) {
// Fallback to temp directory if logs dir creation fails
if (this.debugMode) {
console.error(
`[tool-sanitization-proxy] Failed to create logs dir: ${(err as Error).message}`
);
}
return path.join(os.tmpdir(), 'tool-sanitization-proxy.log');
}
return path.join(logsDir, 'tool-sanitization-proxy.log');
}
/**
* Write log entry to file (always) and console (if CCS_DEBUG=1).
*/
private writeLog(level: 'info' | 'warn' | 'error', message: string): void {
const timestamp = new Date().toISOString();
const prefix = level === 'info' ? '[i]' : level === 'warn' ? '[!]' : '[X]';
const logLine = `${timestamp} ${prefix} ${message}\n`;
// Always write to file
try {
fs.appendFileSync(this.logFilePath, logLine);
} catch {
// Silently ignore file write errors
}
// Console output only in debug mode
if (this.debugMode) {
console.error(`${prefix} ${message}`);
}
this.logger[level](level, message, {
debugMode: this.debugMode,
logFilePath: this.logFilePath,
});
}
private log(message: string): void {
if (this.config.verbose) {
this.writeLog('info', `[tool-sanitization-proxy] ${message}`);
}
}
private warn(message: string): void {
if (this.config.warnOnSanitize) {
this.writeLog('warn', `Tool name sanitized: ${message}`);
}
}
/**
@@ -365,7 +296,10 @@ export class ToolSanitizationProxy {
this.server.listen(0, '127.0.0.1', () => {
const address = this.server?.address();
this.port = typeof address === 'object' && address ? address.port : 0;
this.writeLog('info', `Tool sanitization proxy active (port ${this.port})`);
this.logger.info(
'tool-sanitization.proxy.active',
`Tool sanitization proxy active (port ${this.port})`
);
resolve(this.port);
});
@@ -418,7 +352,12 @@ export class ToolSanitizationProxy {
const fullUpstreamUrl = new URL(requestPath, upstreamBase);
const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname);
this.log(`${method} ${requestPath} → ${fullUpstreamUrl.href}`);
if (this.config.verbose) {
this.logger.info(
'tool-sanitization.proxy.request',
`${method} ${requestPath} → ${fullUpstreamUrl.href}`
);
}
// Only buffer+rewrite JSON POST requests
const contentType = String(req.headers['content-type'] || '');
@@ -458,9 +397,14 @@ export class ToolSanitizationProxy {
}
const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath);
if (normalizedModel !== modifiedBody.model) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"`
this.logger.warn(
'tool-sanitization.proxy.model-normalized',
`Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"`,
{
provider: providerFromPath ?? 'root',
from: modifiedBody.model,
to: normalizedModel,
}
);
modifiedBody = { ...modifiedBody, model: normalizedModel };
}
@@ -480,14 +424,22 @@ export class ToolSanitizationProxy {
if (schemaResult.totalRemoved > 0) {
for (const entry of schemaResult.removedByTool) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} Gemini-unsupported properties`
this.logger.warn(
'tool-sanitization.proxy.schema-sanitized',
`Schema sanitized for "${entry.name}": removed ${entry.removed.length} Gemini-unsupported properties`,
{ tool: entry.name, removedFields: entry.removed }
);
}
if (this.config.verbose) {
this.logger.info(
'tool-sanitization.proxy.schema-summary',
`Sanitized ${schemaResult.totalRemoved} schema properties across ${schemaResult.removedByTool.length} tool(s)`,
{
totalRemoved: schemaResult.totalRemoved,
toolCount: schemaResult.removedByTool.length,
}
);
}
this.log(
`Sanitized ${schemaResult.totalRemoved} schema properties across ${schemaResult.removedByTool.length} tool(s)`
);
}
let rewrittenTools = schemaResult.tools as Tool[];
@@ -501,14 +453,26 @@ export class ToolSanitizationProxy {
if (fieldResult.totalRemoved > 0) {
for (const entry of fieldResult.removedByTool) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Tool fields stripped for "${entry.name}" (${providerFromPath ?? 'model-routed'}): ${entry.removed.join(', ')}`
this.logger.warn(
'tool-sanitization.proxy.fields-stripped',
`Tool fields stripped for "${entry.name}" (${providerFromPath ?? 'model-routed'}): ${entry.removed.join(', ')}`,
{
tool: entry.name,
provider: providerFromPath ?? 'model-routed',
removedFields: entry.removed,
}
);
}
if (this.config.verbose) {
this.logger.info(
'tool-sanitization.proxy.fields-summary',
`Stripped ${fieldResult.totalRemoved} unsupported top-level tool field(s) across ${fieldResult.removedByTool.length} tool(s)`,
{
totalRemoved: fieldResult.totalRemoved,
toolCount: fieldResult.removedByTool.length,
}
);
}
this.log(
`Stripped ${fieldResult.totalRemoved} unsupported top-level tool field(s) across ${fieldResult.removedByTool.length} tool(s)`
);
}
rewrittenTools = fieldResult.tools;
@@ -521,19 +485,32 @@ export class ToolSanitizationProxy {
// Log sanitization warnings
if (mapper.hasChanges()) {
const changes = mapper.getChanges();
for (const change of changes) {
this.warn(`"${change.original}" → "${change.sanitized}"`);
if (this.config.warnOnSanitize) {
for (const change of changes) {
this.logger.warn(
'tool-sanitization.proxy.name-sanitized',
`Tool name sanitized: "${change.original}" → "${change.sanitized}"`,
{ from: change.original, to: change.sanitized }
);
}
}
if (this.config.verbose) {
this.logger.info(
'tool-sanitization.proxy.name-summary',
`Sanitized ${changes.length} tool name(s)`,
{ count: changes.length }
);
}
this.log(`Sanitized ${changes.length} tool name(s)`);
}
// Warn about hash collisions (multiple originals → same sanitized)
if (mapper.hasCollisions()) {
const collisions = mapper.getCollisions();
for (const collision of collisions) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Hash collision detected: ${collision.originals.join(', ')} → "${collision.sanitized}"`
this.logger.warn(
'tool-sanitization.proxy.hash-collision',
`Hash collision detected: ${collision.originals.join(', ')} → "${collision.sanitized}"`,
{ originals: collision.originals, sanitized: collision.sanitized }
);
}
}
@@ -549,7 +526,11 @@ export class ToolSanitizationProxy {
}
} catch (error) {
const err = error as Error;
this.log(`Error: ${err.message}`);
if (this.config.verbose) {
this.logger.error('tool-sanitization.proxy.request-error', `Error: ${err.message}`, {
error: err.message,
});
}
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
@@ -845,9 +826,9 @@ export class ToolSanitizationProxy {
clearUpstreamResponseTimeout();
try {
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
this.writeLog(
'warn',
'[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
this.logger.warn(
'tool-sanitization.proxy.empty-response',
'Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
);
clientRes.write(
this.buildSyntheticErrorResponse(
@@ -903,9 +884,9 @@ export class ToolSanitizationProxy {
// Safety net: if upstream sent data but no content blocks, inject synthetic response
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
this.writeLog(
'warn',
'[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
this.logger.warn(
'tool-sanitization.proxy.empty-response',
'Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
);
clientRes.write(
this.buildSyntheticErrorResponse(
+19 -5
View File
@@ -14,6 +14,9 @@ import {
buildClaudeQuotaWindows,
buildClaudeCoreUsageSummary,
} from './quota-fetcher-claude-normalizer';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:quota:claude');
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
@@ -250,7 +253,11 @@ async function runClaudeUsageFetch(
});
if (verbose) {
console.error(`[i] Claude OAuth usage status: ${response.status} (attempt ${attempt})`);
logger.info('quota.fetch.status', `Claude OAuth usage status: ${response.status}`, {
provider: 'claude',
status: response.status,
attempt,
});
}
if (response.status === 401) {
@@ -331,10 +338,17 @@ async function runClaudeUsageFetch(
: 'Unknown error';
if (verbose) {
const errorDetails =
error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error);
console.error(
`[!] Claude OAuth usage failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
logger.warn(
'quota.fetch.failed',
`Claude OAuth usage failed (attempt ${attempt}): ${lastError}`,
{
provider: 'claude',
attempt,
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
}
);
}
+51 -10
View File
@@ -13,6 +13,9 @@ import { sanitizeEmail, isTokenExpired } from '../auth/auth-utils';
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
import { sanitizeCodexFeatureLabel } from './quota-label-sanitizer';
import { extractCanonicalEmailFromAccountId } from '../accounts/email-account-identity';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:quota:codex');
/** ChatGPT backend API base URL */
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
@@ -628,12 +631,17 @@ export async function fetchCodexQuota(
accountId: string,
verbose = false
): Promise<CodexQuotaResult> {
if (verbose) console.error(`[i] Fetching Codex quota for ${accountId}...`);
if (verbose)
logger.info('quota.fetch.start', 'Fetching Codex quota for account', { provider: 'codex' });
const authData = readCodexAuthData(accountId);
if (!authData) {
const error = 'Auth file not found for Codex account';
if (verbose) console.error(`[!] Error: ${error}`);
if (verbose)
logger.warn('quota.fetch.auth_missing', error, {
provider: 'codex',
errorCode: 'auth_file_missing',
});
return buildCodexFailureResult(accountId, {
error,
errorCode: 'auth_file_missing',
@@ -644,7 +652,11 @@ export async function fetchCodexQuota(
if (authData.isExpired) {
const error = 'Token expired - re-authenticate with ccs cliproxy auth codex';
if (verbose) console.error(`[!] Error: ${error}`);
if (verbose)
logger.warn('quota.fetch.token_expired', error, {
provider: 'codex',
errorCode: 'token_expired',
});
return buildCodexFailureResult(accountId, {
error,
errorCode: 'token_expired',
@@ -656,7 +668,11 @@ export async function fetchCodexQuota(
if (!authData.accountId) {
const error = 'Missing ChatGPT-Account-Id in auth file';
if (verbose) console.error(`[!] Error: ${error}`);
if (verbose)
logger.warn('quota.fetch.missing_account_id', error, {
provider: 'codex',
errorCode: 'missing_account_id',
});
return buildCodexFailureResult(accountId, {
error,
errorCode: 'missing_account_id',
@@ -685,7 +701,12 @@ export async function fetchCodexQuota(
clearTimeout(timeoutId);
if (verbose) console.error(`[i] Codex API status: ${response.status} (attempt ${attempt})`);
if (verbose)
logger.info('quota.fetch.status', `Codex API status: ${response.status}`, {
provider: 'codex',
status: response.status,
attempt,
});
if (!response.ok) {
const bodyText = await response.text();
@@ -696,10 +717,14 @@ export async function fetchCodexQuota(
const windows = buildCodexQuotaWindows(data);
const unknownWindowLabels = getUnknownCodexWindowLabels(windows);
if (unknownWindowLabels.length > 0 && shouldLogCodexWindowWarnings(verbose)) {
console.error(
`[!] Codex quota detected unknown window labels: ${unknownWindowLabels.join(', ')}`
logger.warn(
'quota.fetch.unknown_window_labels',
'Codex quota detected unknown window labels; window classification may need an update for upstream API changes',
{
provider: 'codex',
labels: unknownWindowLabels,
}
);
console.error(' Window classification may need an update for upstream API changes.');
}
const coreUsage = buildCodexCoreUsageSummary(windows);
@@ -714,7 +739,11 @@ export async function fetchCodexQuota(
else if (normalized === 'team') planType = 'team';
}
if (verbose) console.error(`[i] Codex windows found: ${windows.length}`);
if (verbose)
logger.info('quota.fetch.windows', `Codex windows found: ${windows.length}`, {
provider: 'codex',
count: windows.length,
});
return {
success: true,
@@ -734,7 +763,19 @@ export async function fetchCodexQuota(
: 'Unknown error';
if (verbose) {
console.error(`[!] Codex quota error (attempt ${attempt}): ${lastErrorMsg}`);
logger.warn(
'quota.fetch.failed',
`Codex quota error (attempt ${attempt}): ${lastErrorMsg}`,
{
provider: 'codex',
attempt,
errorCode: isAbortError ? 'network_timeout' : 'network_error',
err:
err instanceof Error
? { name: err.name, message: err.message }
: { message: String(err) },
}
);
}
// Retry timeout once; other failures return immediately.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,114 @@
/**
* Auth file discovery for the Gemini CLI quota fetcher (direct-path credentials).
*
* Locates and parses the on-disk Gemini CLI auth file for a given account,
* supporting both the legacy `gemini-<sanitized>.json` filename and the newer
* `<email>-gen-lang-client-<projectId>.json` pattern. Scans both the active
* auth directory and the paused-account directory.
*
* Returns the access token, project ID, expiry, and expired flag. The live
* token is returned only so the caller can place it in an Authorization header;
* it is never logged by this module or its callers.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getAuthDir } from '../../config/config-generator';
import { getPausedDir } from '../../accounts/account-manager';
import { isTokenExpired } from '../../auth/auth-utils';
import { sanitizeEmail } from '../../auth/auth-utils';
import { isGeminiAuthFile } from './managed-request';
import { extractAccessToken, extractExpiry, resolveGeminiCliProjectId } from './token-parsing';
import type { GeminiCliAuthData } from './types';
/**
* Read auth data from a Gemini CLI auth file on disk.
*
* Resolution order per auth directory:
* 1. Exact legacy match: `gemini-<sanitized-account>.json`
* 2. Directory scan for files matching {@link isGeminiAuthFile}, filtered
* by account email/filename and Gemini type.
*
* Scans both the active auth dir and the paused-account dir. Returns null if
* no usable auth file (with an access token) is found.
*/
export function readGeminiCliAuthData(accountId: string): GeminiCliAuthData | null {
const authDirs = [getAuthDir(), getPausedDir()];
const sanitizedId = sanitizeEmail(accountId);
const expectedFiles = [
`gemini-${sanitizedId}.json`, // Legacy format
`${accountId}-gen-lang-client-`, // New format prefix (partial match)
];
for (const authDir of authDirs) {
if (!fs.existsSync(authDir)) continue;
// Try exact legacy match first
const legacyPath = path.join(authDir, expectedFiles[0]);
if (fs.existsSync(legacyPath)) {
try {
const content = fs.readFileSync(legacyPath, 'utf-8');
const data = JSON.parse(content) as Record<string, unknown>;
const accessToken = extractAccessToken(data);
if (accessToken) {
const projectId =
typeof data.project_id === 'string'
? data.project_id
: resolveGeminiCliProjectId(String(data.account || ''));
const expiry = extractExpiry(data);
return {
accessToken,
projectId,
isExpired: isTokenExpired(expiry ?? undefined),
expiresAt: expiry,
};
}
} catch {
// Continue to fallback
}
}
// Scan directory for matching files
const files = fs.readdirSync(authDir);
for (const file of files) {
if (!isGeminiAuthFile(file)) continue;
const candidatePath = path.join(authDir, file);
try {
const content = fs.readFileSync(candidatePath, 'utf-8');
const data = JSON.parse(content) as Record<string, unknown>;
// Check if this file matches our account
const fileEmail = typeof data.email === 'string' ? data.email : null;
const fileType = typeof data.type === 'string' ? data.type : null;
const matchesEmail = fileEmail === accountId;
const matchesFilename = file.startsWith(`${accountId}-`) || file.includes(sanitizedId);
const isGeminiType = fileType === 'gemini' || fileType === 'gemini-cli';
// Must match account AND be gemini type (or legacy gemini- prefix)
if ((matchesEmail || matchesFilename) && (isGeminiType || file.startsWith('gemini-'))) {
const accessToken = extractAccessToken(data);
if (accessToken) {
const projectId =
typeof data.project_id === 'string'
? data.project_id
: resolveGeminiCliProjectId(String(data.account || ''));
const expiry = extractExpiry(data);
return {
accessToken,
projectId,
isExpired: isTokenExpired(expiry ?? undefined),
expiresAt: expiry,
};
}
}
} catch {
continue;
}
}
}
return null;
}
@@ -0,0 +1,62 @@
/**
* Bucket building for the Gemini CLI quota fetcher.
*
* Translates raw upstream quota buckets (snake_case and camelCase tolerant)
* into the normalized {@link GeminiCliBucket} array grouped by model series
* and token type. Delegates the grouping to the shared
* `gemini-cli-quota-normalizer` so the grouping rules stay in one place.
*/
import {
buildGeminiCliBucketsFromParsedBuckets,
type GeminiCliParsedBucket,
} from '../gemini-cli-quota-normalizer';
import type { GeminiCliBucket } from '../quota-types';
import type { RawGeminiCliBucket } from './types';
import { normalizeNumberValue, normalizeStringValue } from './shared-utils';
/**
* Build a {@link GeminiCliBucket} array from raw upstream quota buckets.
*
* Each raw bucket is normalized into a {@link GeminiCliParsedBucket}:
* - skips buckets with no resolvable model id
* - coalesces remaining_fraction / remaining_amount / reset_time across
* naming variants, with a fallback of `1` (full) when none are present
* but a reset time or non-positive amount implies exhaustion
* Then delegates to {@link buildGeminiCliBucketsFromParsedBuckets} for the
* model-series and token-type grouping.
*/
export function buildGeminiCliBuckets(rawBuckets: RawGeminiCliBucket[]): GeminiCliBucket[] {
const parsedBuckets = rawBuckets
.map((bucket): GeminiCliParsedBucket | null => {
const modelId = normalizeStringValue(bucket.model_id ?? bucket.modelId);
if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.token_type ?? bucket.tokenType);
const remainingFractionRaw = normalizeNumberValue(
bucket.remaining_fraction ?? bucket.remainingFraction
);
const remainingAmount = normalizeNumberValue(
bucket.remaining_amount ?? bucket.remainingAmount
);
const resetTime = normalizeStringValue(bucket.reset_time ?? bucket.resetTime);
let fallbackFraction: number | null = null;
if (remainingAmount !== null) {
fallbackFraction = remainingAmount <= 0 ? 0 : null;
} else if (resetTime) {
fallbackFraction = 0;
}
return {
modelId,
tokenType,
remainingFraction: remainingFractionRaw ?? fallbackFraction ?? 1,
remainingAmount,
resetTime,
};
})
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
return buildGeminiCliBucketsFromParsedBuckets(parsedBuckets);
}
@@ -0,0 +1,34 @@
/**
* Constants for the Gemini CLI quota fetcher submodule.
*
* Google Cloud Code API endpoints, error-detail sanitization limits, and
* upstream request timeouts. Extracted verbatim from the original god file;
* do not change values without coordinating with callers and tests.
*/
/** Google Cloud Code internal API base URL. */
export const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com';
/** Google Cloud Code API version path segment. */
export const GEMINI_CLI_API_VERSION = 'v1internal';
/** retrieveUserQuota endpoint - returns bucket-based model quotas. */
export const GEMINI_CLI_QUOTA_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:retrieveUserQuota`;
/** loadCodeAssist endpoint - returns tier/credit metadata. */
export const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:loadCodeAssist`;
/** Max characters retained from a sanitized upstream error detail. */
export const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
/** Suffix appended when an error detail is truncated. */
export const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
/** Credit type identifying Google One AI (paid tier) credits. */
export const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
/** Timeout for the primary (preferred) management API attempt, in ms. */
export const MANAGEMENT_API_TIMEOUT_MS = 5000;
/** Timeout for the secondary / fallback upstream request, in ms. */
export const SECONDARY_REQUEST_TIMEOUT_MS = 2000;
@@ -0,0 +1,318 @@
/**
* Error parsing and failure-result builders for the Gemini CLI quota fetcher.
*
* Translates non-200 upstream responses into structured {@link GeminiCliQuotaResult}
* failure payloads with sanitized error details, recovery hints, and provider
* entitlement evidence. Token values in error bodies are always redacted before
* being surfaced (see {@link sanitizeGeminiCliErrorDetail}).
*/
import {
buildProviderEntitlementEvidence,
isModelCapacityExhausted,
} from '../../auth/provider-entitlement-evidence';
import type {
GeminiCliFailureResultOptions,
GeminiCliQuotaResult,
ParsedGeminiCliErrorBody,
} from './types';
import {
GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH,
GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX,
} from './constants';
/**
* Build a structured failure {@link GeminiCliQuotaResult} with empty buckets.
* Centralizes the common failure shape so each HTTP-status branch only needs
* to supply its specific error/hint/entitlement fields.
*/
export function buildGeminiCliFailureResult(
accountId: string,
projectId: string | null,
options: GeminiCliFailureResultOptions
): GeminiCliQuotaResult {
return {
success: false,
buckets: [],
projectId,
tierLabel: null,
tierId: null,
creditBalance: null,
lastUpdated: Date.now(),
accountId,
error: options.error,
httpStatus: options.httpStatus,
errorCode: options.errorCode,
errorDetail: options.errorDetail,
actionHint: options.actionHint,
retryable: options.retryable,
needsReauth: options.needsReauth,
isForbidden: options.isForbidden,
entitlement: options.entitlement,
};
}
/**
* Sanitize an upstream error body for safe inclusion in a quota result.
*
* - Collapses HTML responses to a placeholder (never leaks provider HTML).
* - Redacts common token/credential/secret field names and `Bearer <token>`.
* - Collapses internal whitespace to single spaces.
* - Truncates to {@link GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH} with a sentinel suffix.
*
* Returns undefined for empty input. Token values are never preserved.
*/
export function sanitizeGeminiCliErrorDetail(bodyText: string): string | undefined {
const trimmed = bodyText.trim();
if (!trimmed) {
return undefined;
}
if (/^<!doctype html/i.test(trimmed) || /^<html/i.test(trimmed) || /^<[^>]+>/.test(trimmed)) {
return '[HTML error response omitted]';
}
let sanitized = trimmed
.replace(
/"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token)"\s*:\s*"[^"]*"/gi,
'"$1":"[redacted]"'
)
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]')
.replace(/\s+/g, ' ');
if (sanitized.length > GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH) {
sanitized = `${sanitized.slice(
0,
GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH - GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX.length
)}${GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX}`;
}
return sanitized;
}
/**
* Recursively extract the first non-empty message-like field from a nested
* error `details` array/object. Looks for `message`, `localizedMessage`,
* `description`, `reason`, and `error` keys at any level.
*/
export function extractGeminiCliNestedMessage(value: unknown): string | undefined {
if (Array.isArray(value)) {
for (const entry of value) {
const nested = extractGeminiCliNestedMessage(entry);
if (nested) return nested;
}
return undefined;
}
if (!value || typeof value !== 'object') {
return undefined;
}
const record = value as Record<string, unknown>;
const directMessage = [
record.message,
record.localizedMessage,
record.description,
record.reason,
record.error,
].find(
(candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0
);
if (directMessage) {
return directMessage;
}
return undefined;
}
/**
* Parse an upstream error body into a structured {@link ParsedGeminiCliErrorBody}.
*
* Extracts a top-level code/status, a message (looking inside `error` objects
* and nested `details`), and a sanitized error detail. Non-JSON bodies fall
* back to the raw (sanitized) trimmed text as the message. HTML bodies surface
* only as the sanitized detail placeholder, never as the message.
*/
export function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody {
const trimmed = bodyText.trim();
if (!trimmed) {
return {};
}
const sanitizedDetail = sanitizeGeminiCliErrorDetail(trimmed);
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const topLevelMessage = [parsed.message, parsed.error].find(
(candidate): candidate is string =>
typeof candidate === 'string' && candidate.trim().length > 0
);
const topLevelCode = [parsed.code, parsed.status].find(
(candidate): candidate is string =>
typeof candidate === 'string' && candidate.trim().length > 0
);
if (parsed.error && typeof parsed.error === 'object') {
const error = parsed.error as Record<string, unknown>;
return {
errorCode:
[error.status, error.code, topLevelCode].find(
(candidate): candidate is string =>
typeof candidate === 'string' && candidate.trim().length > 0
) || undefined,
errorDetail: sanitizedDetail,
message:
[
error.message,
error.error,
extractGeminiCliNestedMessage(error.details),
topLevelMessage,
].find(
(candidate): candidate is string =>
typeof candidate === 'string' && candidate.trim().length > 0
) || undefined,
};
}
return {
errorCode: topLevelCode,
errorDetail: sanitizedDetail,
message:
[topLevelMessage, extractGeminiCliNestedMessage(parsed.details)].find(
(candidate): candidate is string =>
typeof candidate === 'string' && candidate.trim().length > 0
) || undefined,
};
} catch {
return {
errorDetail: sanitizedDetail,
message: sanitizedDetail === '[HTML error response omitted]' ? undefined : trimmed,
};
}
}
/**
* Build a user-facing recovery hint for a 403 (forbidden) upstream response.
* Inspects the parsed message/detail for verification, project, or generic
* access signals and returns the matching recovery instruction.
*/
export function buildGeminiCliForbiddenActionHint(parsed: ParsedGeminiCliErrorBody): string {
const combined = `${parsed.message || ''} ${parsed.errorDetail || ''}`.toLowerCase();
if (combined.includes('verify') || combined.includes('verification')) {
return 'Complete the Google account verification mentioned above, then retry quota refresh.';
}
if (combined.includes('project')) {
return 'Confirm this Google project still has Gemini CLI quota access, then retry.';
}
return 'Check the Google account or workspace access shown above, then retry quota refresh.';
}
/**
* Build a structured failure result from an HTTP non-200 upstream response.
*
* Status-specific behavior:
* - 401: marks the result as needsReauth (user must re-run `ccs gemini --auth`)
* - 403: marks forbidden with runtime-inferred not_entitled evidence and a
* context-aware action hint
* - 429: distinguishes MODEL_CAPACITY_EXHAUSTED (entitled but capacity-stressed)
* from generic rate limiting
* - >=500: retryable provider-unavailable result
* - other: generic non-retryable quota_request_failed
*/
export function buildGeminiCliHttpFailureResult(
accountId: string,
projectId: string | null,
status: number,
bodyText: string
): GeminiCliQuotaResult {
const parsed = parseGeminiCliErrorBody(bodyText);
if (status === 401) {
return buildGeminiCliFailureResult(accountId, projectId, {
error: parsed.message || 'Token expired or invalid',
httpStatus: 401,
errorCode: parsed.errorCode || 'reauth_required',
errorDetail: parsed.errorDetail,
actionHint: 'Run ccs gemini --auth to reconnect this account.',
needsReauth: true,
retryable: false,
});
}
if (status === 403) {
return buildGeminiCliFailureResult(accountId, projectId, {
error: parsed.message || 'Quota access forbidden for this account',
httpStatus: 403,
errorCode: parsed.errorCode || 'quota_api_forbidden',
errorDetail: parsed.errorDetail,
actionHint: buildGeminiCliForbiddenActionHint(parsed),
isForbidden: true,
retryable: false,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'medium',
accessState: 'not_entitled',
capacityState: 'unknown',
}),
});
}
if (status === 429) {
if (isModelCapacityExhausted(parsed.message, parsed.errorDetail, parsed.errorCode)) {
return buildGeminiCliFailureResult(accountId, projectId, {
error: parsed.message || 'Model capacity exhausted for this account right now',
httpStatus: 429,
errorCode: 'capacity_exhausted',
errorDetail: parsed.errorDetail,
actionHint:
'Retry later or switch to another Gemini model. This indicates temporary model capacity, not an authentication failure.',
retryable: true,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'medium',
accessState: 'entitled',
capacityState: 'capacity_exhausted',
notes: 'Upstream returned MODEL_CAPACITY_EXHAUSTED for this model.',
}),
});
}
return buildGeminiCliFailureResult(accountId, projectId, {
error: parsed.message || 'Rate limited - try again later',
httpStatus: 429,
errorCode: parsed.errorCode || 'rate_limited',
errorDetail: parsed.errorDetail,
actionHint: 'Retry after a short delay.',
retryable: true,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'low',
accessState: 'unknown',
capacityState: 'rate_limited',
}),
});
}
if (status >= 500) {
return buildGeminiCliFailureResult(accountId, projectId, {
error: parsed.message || `Gemini quota service unavailable (HTTP ${status})`,
httpStatus: status,
errorCode: parsed.errorCode || 'provider_unavailable',
errorDetail: parsed.errorDetail,
actionHint: 'Retry later. This looks like a temporary Google upstream problem.',
retryable: true,
});
}
return buildGeminiCliFailureResult(accountId, projectId, {
error: parsed.message || `Gemini quota request failed (HTTP ${status})`,
httpStatus: status,
errorCode: parsed.errorCode || 'quota_request_failed',
errorDetail: parsed.errorDetail,
actionHint: 'Inspect the upstream response details and retry if appropriate.',
retryable: false,
});
}
@@ -0,0 +1,41 @@
/**
* Barrel for the Gemini CLI quota fetcher submodule.
*
* Re-exports the original public surface of `quota-fetcher-gemini-cli.ts`
* so the file at the original path can be reduced to a thin re-export
* (preserving import paths and signatures). Submodules are private
* implementation detail; only the symbols below are part of the contract.
*/
// Public API
export { fetchGeminiCliQuota, fetchAllGeminiCliQuotas } from './quota-fetcher';
// Exported helpers (also part of the public surface - used by tests and
// the bucket/grouping normalization tests).
export { resolveGeminiCliProjectId } from './token-parsing';
export { buildGeminiCliBuckets } from './bucket-building';
// Test exports: keep the original `__testExports` bag shape stable so the
// existing test suite (which destructures `__testExports`) keeps working.
export {
sanitizeGeminiCliErrorDetail,
extractGeminiCliNestedMessage,
parseGeminiCliErrorBody,
buildGeminiCliForbiddenActionHint,
} from './error-parsing';
// Re-export `__testExports` as a single object to preserve the original
// named-const export shape (`__testExports`).
import {
sanitizeGeminiCliErrorDetail,
extractGeminiCliNestedMessage,
parseGeminiCliErrorBody,
buildGeminiCliForbiddenActionHint,
} from './error-parsing';
export const __testExports = {
sanitizeGeminiCliErrorDetail,
extractGeminiCliNestedMessage,
parseGeminiCliErrorBody,
buildGeminiCliForbiddenActionHint,
};
@@ -0,0 +1,327 @@
/**
* Managed and direct HTTP request machinery for the Gemini CLI quota fetcher.
*
* Wraps the two upstream call paths used when fetching Gemini CLI quota and
* supplementary metadata:
* - managed: delegated through the CLIProxy management API (uses $TOKEN$
* substitution so the local process never holds the live token)
* - direct: bearer-token fetch against the Google Cloud Code endpoint
*
* The preferred path is configurable per call. On a 401 from the direct path,
* the managed path is retried as a delegated-auth refresh fallback. A
* `GeminiManagedAuthUnavailableError` is thrown when managed auth is required
* but unreachable, so callers can surface a retryable failure result.
*/
import {
buildManagementHeaders,
buildProxyUrl,
getProxyTarget,
} from '../../proxy/proxy-target-resolver';
import { mapExternalProviderName } from '../../provider-capabilities';
import { sanitizeEmail } from '../../auth/auth-utils';
import { MANAGEMENT_API_TIMEOUT_MS, SECONDARY_REQUEST_TIMEOUT_MS } from './constants';
import { getRemainingTimeoutMs, normalizeStringValue, safeParseJson } from './shared-utils';
import type {
ManagedGeminiAuthContext,
ManagedGeminiAuthLookupResult,
ManagedGeminiRequestResult,
ManagedResponse,
ManagementApiCallResponse,
ManagementAuthFile,
} from './types';
/**
* Thrown when Gemini delegated auth refresh via the CLIProxy management API
* is required but temporarily unreachable. Callers translate this into a
* retryable failure result.
*/
export class GeminiManagedAuthUnavailableError extends Error {
constructor() {
super('CLIProxy managed Gemini auth is temporarily unavailable');
this.name = 'GeminiManagedAuthUnavailableError';
}
}
/**
* Read a fetch Response into the normalized {@link ManagedResponse} shape.
* `viaManagement` marks whether the response came through the managed API so
* downstream log messages can attribute the source correctly.
*/
export async function readManagedResponse(
response: Response,
viaManagement: boolean
): Promise<ManagedResponse> {
const bodyText = await response.text();
return {
status: response.status,
bodyText,
json: safeParseJson(bodyText),
viaManagement,
};
}
/**
* Check whether a filename matches the Gemini CLI auth file naming patterns.
* Recognizes three patterns:
* - legacy: gemini-*.json
* - new: *-gen-lang-client-*.json
* - email: contains "@" (verified against type inside the payload later)
*/
export function isGeminiAuthFile(filename: string): boolean {
if (!filename.endsWith('.json')) return false;
// Legacy pattern: gemini-email.json
if (filename.startsWith('gemini-')) return true;
// New pattern: email-gen-lang-client-projectId.json
if (filename.includes('-gen-lang-client-')) return true;
// Check if contains @ (email pattern) - will verify type inside
if (filename.includes('@')) return true;
return false;
}
/**
* Determine whether a management-API auth-file descriptor belongs to the
* given Gemini account. Matches on provider/type normalized to "gemini",
* then on email, filename, or sanitized email substring.
*/
export function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
const rawProvider = normalizeStringValue(file.provider ?? file.type);
if (!rawProvider || mapExternalProviderName(rawProvider) !== 'gemini') {
return false;
}
const email = normalizeStringValue(file.email);
const normalizedAccountId = accountId.trim().toLowerCase();
if (email?.toLowerCase() === normalizedAccountId) {
return true;
}
const normalizedName = normalizeStringValue(file.name);
if (!normalizedName) {
return false;
}
const normalizedFileName = normalizedName.toLowerCase();
const sanitizedAccount = sanitizeEmail(accountId).toLowerCase();
return (
normalizedFileName === `gemini-${sanitizedAccount}.json` ||
normalizedFileName.startsWith(`${normalizedAccountId}-gen-lang-client-`) ||
normalizedFileName.includes(sanitizedAccount)
);
}
/**
* Look up the management-API auth index for a Gemini account.
* Hits `/v0/management/auth-files` and matches the entry for this account.
* Returns `{ unavailable: true }` if the management API is unreachable or
* returns a non-OK response, so callers can fall back to direct auth.
*/
export async function findManagedGeminiAuthIndex(
accountId: string,
timeoutMs: number
): Promise<ManagedGeminiAuthLookupResult> {
const target = getProxyTarget();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), {
signal: controller.signal,
headers: buildManagementHeaders(target),
});
clearTimeout(timeoutId);
if (!response.ok) {
return { authIndex: null, unavailable: true };
}
const data = (await response.json()) as { files?: ManagementAuthFile[] };
const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId));
return { authIndex: match?.auth_index ?? null, unavailable: false };
} catch {
clearTimeout(timeoutId);
return { authIndex: null, unavailable: true };
}
}
/**
* Look up the management auth index for a Gemini account, deduping concurrent
* lookups for the same account via the shared {@link ManagedGeminiAuthContext}.
* The first caller wins; subsequent callers await the same promise.
*/
export async function getManagedGeminiAuthIndex(
accountId: string,
timeoutMs: number,
context?: ManagedGeminiAuthContext
): Promise<ManagedGeminiAuthLookupResult> {
if (!context) {
return await findManagedGeminiAuthIndex(accountId, timeoutMs);
}
context.authIndexLookupPromise ??= findManagedGeminiAuthIndex(accountId, timeoutMs);
return await context.authIndexLookupPromise;
}
/**
* Perform a single upstream request to the Gemini CLI API via the CLIProxy
* management `/v0/management/api-call` endpoint. Uses `$TOKEN$` substitution
* so the live token never leaves the management API. Returns
* `{ unavailable: true }` if the management path is unreachable; returns
* `{ response: null, unavailable: false }` if the request succeeded but no
* matching auth file was found.
*/
export async function performManagedGeminiRequest(
accountId: string,
url: string,
body: string,
timeoutMs: number,
authContext?: ManagedGeminiAuthContext
): Promise<ManagedGeminiRequestResult> {
const deadlineMs = Date.now() + timeoutMs;
const lookupResult = await getManagedGeminiAuthIndex(
accountId,
getRemainingTimeoutMs(deadlineMs),
authContext
);
if (lookupResult.unavailable) {
return { response: null, unavailable: true };
}
const authIndex = lookupResult.authIndex;
if (authIndex === null || authIndex === undefined) {
return { response: null, unavailable: false };
}
const target = getProxyTarget();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs));
try {
const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), {
method: 'POST',
signal: controller.signal,
headers: buildManagementHeaders(target, {
'Content-Type': 'application/json',
}),
body: JSON.stringify({
auth_index: authIndex,
method: 'POST',
url,
header: {
Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json',
},
data: body,
}),
});
clearTimeout(timeoutId);
if (!response.ok) {
return { response: null, unavailable: true };
}
const apiResponse = (await response.json()) as ManagementApiCallResponse;
const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : '';
return {
response: {
status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500,
bodyText,
json: safeParseJson(bodyText),
viaManagement: true,
},
unavailable: false,
};
} catch {
clearTimeout(timeoutId);
return { response: null, unavailable: true };
}
}
/**
* Perform a Gemini CLI upstream request, preferring the managed path when
* requested and falling back to direct bearer-token auth. On a 401 from the
* direct path, retries via managed auth as a delegated-auth refresh; throws
* {@link GeminiManagedAuthUnavailableError} if that retry is unreachable.
*
* @param accountId Account identifier (email), used for managed auth lookup.
* @param accessToken Bearer token for the direct path. Never logged.
* @param url Target Gemini CLI API URL.
* @param body JSON request body string.
* @param preferManagement When true, try the managed path first.
* @param authContext Optional shared context to dedupe auth-index lookups.
*/
export async function performGeminiCliRequest(
accountId: string,
accessToken: string,
url: string,
body: string,
preferManagement = false,
authContext?: ManagedGeminiAuthContext
): Promise<ManagedResponse> {
let managementAttempted = false;
let managementUnavailable = false;
if (preferManagement) {
managementAttempted = true;
const managedResult = await performManagedGeminiRequest(
accountId,
url,
body,
MANAGEMENT_API_TIMEOUT_MS,
authContext
);
managementUnavailable = managedResult.unavailable;
if (managedResult.response) {
return managedResult.response;
}
}
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
managementAttempted ? SECONDARY_REQUEST_TIMEOUT_MS : MANAGEMENT_API_TIMEOUT_MS
);
try {
const response = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body,
});
clearTimeout(timeoutId);
const directResult = await readManagedResponse(response, false);
if (directResult.status !== 401) {
return directResult;
}
if (managementAttempted) {
if (managementUnavailable) {
throw new GeminiManagedAuthUnavailableError();
}
return directResult;
}
const managedResult = await performManagedGeminiRequest(
accountId,
url,
body,
SECONDARY_REQUEST_TIMEOUT_MS,
authContext
);
if (managedResult.response) {
return managedResult.response;
}
if (managedResult.unavailable) {
throw new GeminiManagedAuthUnavailableError();
}
return directResult;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
@@ -0,0 +1,242 @@
/**
* Top-level quota fetch orchestration for Gemini CLI accounts.
*
* Coordinates auth-file discovery, the managed/direct upstream quota request,
* supplementary tier/credit metadata, and structured failure-result building.
* Preserves the structured logging from the original god file (events:
* gemini_cli.fetch_start, gemini_cli.auth_file_missing, gemini_cli.token_expired,
* gemini_cli.missing_project_id, gemini_cli.api_status, gemini_cli.buckets_found,
* gemini_cli.quota_fetch_error). Token values are never logged.
*/
import { getProviderAccounts, setAccountTier } from '../../accounts/account-manager';
import { getTokenExpiryTimestamp } from '../../auth/auth-utils';
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
import type { GeminiCliQuotaResult } from '../quota-types';
import { readGeminiCliAuthData } from './auth-file-discovery';
import { buildGeminiCliBuckets } from './bucket-building';
import { buildGeminiCliFailureResult, buildGeminiCliHttpFailureResult } from './error-parsing';
import { GeminiManagedAuthUnavailableError, performGeminiCliRequest } from './managed-request';
import { fetchGeminiCliSupplementary } from './supplementary-metadata';
import { logger } from './shared-utils';
import { GEMINI_CLI_QUOTA_URL } from './constants';
import type { GeminiCliAuthData, GeminiCliQuotaResponse, ManagedGeminiAuthContext } from './types';
/**
* Internal helper: fetch quota with already-validated auth data.
*
* Extracted to support the auto-refresh retry path: the caller resolves auth
* data once (legacy file or managed), then this function performs the upstream
* quota request and supplementary metadata fetch in parallel. On success it
* persists the resolved tier back to the account via `setAccountTier`.
*/
export async function fetchWithAuthData(
authData: GeminiCliAuthData,
accountId: string,
verbose: boolean
): Promise<GeminiCliQuotaResult> {
if (!authData.projectId) {
const error = 'Cannot resolve project ID from auth file';
if (verbose) {
logger.error('gemini_cli.missing_project_id', `Error: ${error}`, {
provider: 'gemini',
accountId,
});
}
return buildGeminiCliFailureResult(accountId, null, {
error,
errorCode: 'missing_project_id',
actionHint: 'Run ccs gemini --auth to reconnect this account and recover the project ID.',
retryable: false,
});
}
const authContext: ManagedGeminiAuthContext = {};
const supplementaryPromise = fetchGeminiCliSupplementary(
accountId,
authData.accessToken,
authData.projectId,
verbose,
authContext
);
const requestBody = JSON.stringify({ project: authData.projectId });
try {
const response = await performGeminiCliRequest(
accountId,
authData.accessToken,
GEMINI_CLI_QUOTA_URL,
requestBody,
authData.isExpired,
authContext
);
if (verbose) {
const source = response.viaManagement ? 'managed' : 'direct';
logger.info(
'gemini_cli.api_status',
`Gemini CLI API status via ${source}: ${response.status}`,
{ provider: 'gemini', accountId, httpStatus: response.status, source }
);
}
if (response.status !== 200) {
return buildGeminiCliHttpFailureResult(
accountId,
authData.projectId,
response.status,
response.bodyText
);
}
const data = response.json as GeminiCliQuotaResponse | null;
const rawBuckets = data?.buckets || [];
const buckets = buildGeminiCliBuckets(rawBuckets);
const supplementary = await supplementaryPromise;
if (verbose) {
logger.info('gemini_cli.buckets_found', `Gemini CLI buckets found: ${buckets.length}`, {
provider: 'gemini',
accountId,
bucketCount: buckets.length,
});
}
if (supplementary.normalizedTier !== 'unknown') {
setAccountTier('gemini', accountId, supplementary.normalizedTier);
}
return {
success: true,
buckets,
projectId: authData.projectId,
tierLabel: supplementary.tierLabel,
tierId: supplementary.tierId,
creditBalance: supplementary.creditBalance,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: supplementary.normalizedTier,
rawTierId: supplementary.tierId,
rawTierLabel: supplementary.tierLabel,
source: supplementary.tierId ? 'runtime_api' : 'runtime_inference',
confidence: supplementary.tierId ? 'high' : 'medium',
accessState: 'entitled',
capacityState: 'available',
}),
lastUpdated: Date.now(),
accountId,
};
} catch (err) {
if (err instanceof GeminiManagedAuthUnavailableError) {
return buildGeminiCliFailureResult(accountId, authData.projectId, {
error: 'Gemini delegated auth refresh is temporarily unavailable',
errorCode: 'managed_auth_unavailable',
errorDetail: err.message,
actionHint: 'Retry later. CLIProxy management could not refresh this Gemini account.',
retryable: true,
});
}
const errorMsg =
err instanceof Error && err.name === 'AbortError'
? 'Request timeout'
: err instanceof Error
? err.message
: 'Unknown error';
if (verbose) {
logger.error('gemini_cli.quota_fetch_error', `Gemini CLI quota error: ${errorMsg}`, {
provider: 'gemini',
accountId,
err: err instanceof Error ? { name: err.name, message: errorMsg } : { message: errorMsg },
});
}
return buildGeminiCliFailureResult(accountId, authData.projectId, {
error: errorMsg,
errorCode:
err instanceof Error && err.name === 'AbortError' ? 'network_timeout' : 'network_error',
actionHint: 'Retry later. This looks temporary.',
retryable: true,
httpStatus: err instanceof Error && err.name === 'AbortError' ? 408 : undefined,
});
}
}
/**
* Fetch quota for a single Gemini CLI account.
*
* Reads the on-disk auth file, emits the structured `gemini_cli.fetch_start`
* and `gemini_cli.auth_file_missing` / `gemini_cli.token_expired` log events
* (gated on `verbose`), and delegates to {@link fetchWithAuthData}. Token
* values are never logged; only the expiry label is surfaced.
*
* @param accountId - Account identifier (email)
* @param verbose - Show detailed diagnostics
* @returns Quota result with buckets, percentages, tier, and entitlement evidence
*/
export async function fetchGeminiCliQuota(
accountId: string,
verbose = false
): Promise<GeminiCliQuotaResult> {
if (verbose) {
logger.info('gemini_cli.fetch_start', `Fetching Gemini CLI quota for ${accountId}...`, {
provider: 'gemini',
accountId,
});
}
const authData = readGeminiCliAuthData(accountId);
if (!authData) {
const error = 'Auth file not found for Gemini account';
if (verbose) {
logger.error('gemini_cli.auth_file_missing', `Error: ${error}`, {
provider: 'gemini',
accountId,
});
}
return buildGeminiCliFailureResult(accountId, null, {
error,
errorCode: 'auth_file_missing',
actionHint: 'Run ccs gemini --auth to reconnect this account.',
retryable: false,
});
}
if (authData.isExpired && verbose) {
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown';
logger.info(
'gemini_cli.token_expired',
`Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.`,
{ provider: 'gemini', accountId, tokenExpired: true, expiresAt: expiryLabel }
);
}
return await fetchWithAuthData(authData, accountId, verbose);
}
/**
* Fetch quota for all configured Gemini CLI accounts in parallel.
*
* @param verbose - Show detailed diagnostics (forwarded to each per-account fetch)
* @returns Array of `{ account, quota }` entries, one per active Gemini account.
* Returns an empty array when there are no Gemini accounts configured.
*/
export async function fetchAllGeminiCliQuotas(
verbose = false
): Promise<{ account: string; quota: GeminiCliQuotaResult }[]> {
const accounts = getProviderAccounts('gemini');
if (accounts.length === 0) {
return [];
}
const results = await Promise.all(
accounts.map(async (account) => ({
account: account.id,
quota: await fetchGeminiCliQuota(account.id, verbose),
}))
);
return results;
}
@@ -0,0 +1,62 @@
/**
* Shared utilities for the Gemini CLI quota fetcher submodule.
*
* Includes the diagnostic logger (provider context = cliproxy:quota:gemini-cli)
* and small value-normalization helpers used by multiple submodules. Token
* values are never logged here; accountId is attached as provider context
* only.
*/
import { createLogger } from '../../../services/logging';
/**
* Diagnostic-only logger for Gemini CLI quota fetch progress, upstream HTTP
* status, and recovery hints. Token values live in auth files and are never
* read into log messages.
*/
export const logger = createLogger('cliproxy:quota:gemini-cli');
/**
* Normalize a raw value into a trimmed non-empty string, or null.
* Returns null for empty strings, non-strings, or whitespace-only input.
*/
export function normalizeStringValue(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
/**
* Normalize a raw value into a finite number, or null.
* Accepts actual numbers and numeric strings; rejects NaN/Infinity.
*/
export function normalizeNumberValue(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
/**
* Best-effort JSON.parse that returns null on failure instead of throwing.
* Used when normalizing upstream response bodies into a `json` field.
*/
export function safeParseJson(bodyText: string): unknown {
try {
return JSON.parse(bodyText);
} catch {
return null;
}
}
/**
* Compute the remaining milliseconds available before a deadline, clamped
* to a minimum of 1ms so AbortController timeouts are always positive.
*/
export function getRemainingTimeoutMs(deadlineMs: number): number {
return Math.max(1, deadlineMs - Date.now());
}
@@ -0,0 +1,149 @@
/**
* Supplementary tier/credit metadata fetcher for the Gemini CLI quota fetcher.
*
* Wraps the `loadCodeAssist` endpoint to resolve the account's tier label,
* tier id, normalized tier (free/pro/ultra/unknown), and Google One AI credit
* balance. Runs alongside the primary quota fetch and shares the same
* managed-auth context so auth-index lookups are deduped.
*/
import {
getProviderTierLabel,
normalizeProviderTierId,
} from '../../auth/provider-entitlement-evidence';
import { performGeminiCliRequest } from './managed-request';
import { logger, normalizeNumberValue, normalizeStringValue } from './shared-utils';
import { GEMINI_CLI_CODE_ASSIST_URL, GEMINI_CLI_G1_CREDIT_TYPE } from './constants';
import type {
GeminiCliCodeAssistResponse,
GeminiCliSupplementaryInfo,
ManagedGeminiAuthContext,
} from './types';
/**
* Resolve the tier id from a loadCodeAssist response.
* Prefers the paid tier id, then the current tier id. Lowercased.
* Returns null if neither is present.
*/
export function resolveGeminiCliTierId(payload: GeminiCliCodeAssistResponse | null): string | null {
if (!payload) return null;
const currentTier = payload.currentTier ?? payload.current_tier;
const paidTier = payload.paidTier ?? payload.paid_tier;
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
return rawId ? rawId.toLowerCase() : null;
}
/**
* Resolve a human-readable tier label from the loadCodeAssist tier id.
* Returns null when the tier id cannot be mapped to a known label.
*/
export function resolveGeminiCliTierLabel(
payload: GeminiCliCodeAssistResponse | null
): string | null {
const tierId = resolveGeminiCliTierId(payload);
return getProviderTierLabel(tierId);
}
/**
* Resolve the Google One AI credit balance for the account from the
* loadCodeAssist response. Sums all credits with type `GOOGLE_ONE_AI` on the
* paid tier (preferred) or current tier. Returns null if no matching credits
* are present.
*/
export function resolveGeminiCliCreditBalance(
payload: GeminiCliCodeAssistResponse | null
): number | null {
if (!payload) return null;
const paidTier = payload.paidTier ?? payload.paid_tier;
const currentTier = payload.currentTier ?? payload.current_tier;
const tier = paidTier ?? currentTier;
if (!tier) return null;
const credits = tier.availableCredits ?? tier.available_credits ?? [];
let total = 0;
let found = false;
for (const credit of credits) {
const creditType = normalizeStringValue(credit.creditType ?? credit.credit_type);
if (creditType !== GEMINI_CLI_G1_CREDIT_TYPE) continue;
const amount = normalizeNumberValue(credit.creditAmount ?? credit.credit_amount);
if (amount !== null) {
total += amount;
found = true;
}
}
return found ? total : null;
}
/**
* Fetch supplementary tier/credit metadata for a Gemini account via the
* loadCodeAssist endpoint. Never throws: on any failure returns a
* supplementary info with `normalizedTier: 'unknown'` so the primary quota
* fetch can still complete. Diagnostic logging is gated on `verbose` and
* records only accountId, HTTP status, and the source (managed/direct);
* token values are never logged.
*/
export async function fetchGeminiCliSupplementary(
accountId: string,
accessToken: string,
projectId: string,
verbose: boolean,
authContext?: ManagedGeminiAuthContext
): Promise<GeminiCliSupplementaryInfo> {
const requestBody = JSON.stringify({
cloudaicompanionProject: projectId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: projectId,
},
});
try {
const response = await performGeminiCliRequest(
accountId,
accessToken,
GEMINI_CLI_CODE_ASSIST_URL,
requestBody,
false,
authContext
);
if (response.status !== 200) {
if (verbose) {
const source = response.viaManagement ? 'managed' : 'direct';
logger.info(
'gemini_cli.supplementary_metadata_unavailable',
`Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}`,
{ provider: 'gemini', accountId, httpStatus: response.status, source }
);
}
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
}
const payload = response.json as GeminiCliCodeAssistResponse | null;
return {
tierLabel: resolveGeminiCliTierLabel(payload),
tierId: resolveGeminiCliTierId(payload),
creditBalance: resolveGeminiCliCreditBalance(payload),
normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)),
};
} catch (error) {
if (verbose) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.info(
'gemini_cli.supplementary_metadata_skipped',
`Gemini CLI supplementary metadata skipped: ${message}`,
{
provider: 'gemini',
accountId,
err: error instanceof Error ? { name: error.name, message } : { message },
}
);
}
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
}
}
@@ -0,0 +1,73 @@
/**
* Token parsing helpers for Gemini CLI auth files.
*
* Extracts access tokens, expiry, and project IDs from the raw auth file
* payload. Gemini auth files come in two structural variants:
* - flat: { access_token, expired, project_id, account }
* - nested:{ token: { access_token, expiry }, project_id, account }
* These helpers handle both without throwing on shape mismatches.
*/
/**
* Extract the access token from a Gemini auth file payload.
* Handles both flat (`access_token`) and nested (`token.access_token`) shapes.
* Returns null if no usable token is present.
*/
export function extractAccessToken(data: Record<string, unknown>): string | null {
// Flat structure: { access_token: "..." }
if (typeof data.access_token === 'string') {
return data.access_token;
}
// Nested structure: { token: { access_token: "..." } }
if (data.token && typeof data.token === 'object') {
const token = data.token as Record<string, unknown>;
if (typeof token.access_token === 'string') {
return token.access_token;
}
}
return null;
}
/**
* Extract the token expiry from a Gemini auth file payload.
* Handles both flat (`expired`) and nested (`token.expiry`) shapes.
* Returns the raw string/number, or null if absent.
*/
export function extractExpiry(data: Record<string, unknown>): string | number | null {
// Flat structure: { expired: "..." }
if (typeof data.expired === 'string') {
return data.expired;
}
if (typeof data.expired === 'number') {
return data.expired;
}
// Nested structure: { token: { expiry: "..." } }
if (data.token && typeof data.token === 'object') {
const token = data.token as Record<string, unknown>;
if (typeof token.expiry === 'string') {
return token.expiry;
}
if (typeof token.expiry === 'number') {
return token.expiry;
}
}
return null;
}
/**
* Extract the project ID from an auth file's `account` field.
* Input shape: "user@example.com (cloudaicompanion-abc-123)"
* Returns the last parenthesized segment, or null if no match.
*
* Example:
* "user@example.com (cloudaicompanion-abc-123)" -> "cloudaicompanion-abc-123"
*/
export function resolveGeminiCliProjectId(accountField: string): string | null {
const regex = /\(([^()]+)\)/g;
let match: RegExpExecArray | null;
let lastMatch: string | null = null;
while ((match = regex.exec(accountField)) !== null) {
lastMatch = match[1];
}
return lastMatch;
}
@@ -0,0 +1,132 @@
/**
* Shared types for the Gemini CLI quota fetcher submodule.
*
* Extracted from the original quota-fetcher-gemini-cli.ts god file. These
* interfaces describe raw API response shapes, internal parsed structures,
* and managed-auth context used across the submodules.
*/
import type { GeminiCliBucket, GeminiCliQuotaResult } from '../quota-types';
import type { ProviderEntitlementEvidence } from '../../auth/provider-entitlement-types';
/** Auth data extracted from a Gemini CLI auth file. */
export interface GeminiCliAuthData {
accessToken: string;
projectId: string | null;
isExpired: boolean;
expiresAt: string | number | null;
}
/** Raw bucket shape returned by the Gemini CLI quota API. */
export interface RawGeminiCliBucket {
model_id?: string;
modelId?: string;
token_type?: string | null;
tokenType?: string | null;
remaining_fraction?: number;
remainingFraction?: number;
remaining_amount?: number;
remainingAmount?: number;
reset_time?: string | null;
resetTime?: string | null;
}
/** Raw quota API response wrapper. */
export interface GeminiCliQuotaResponse {
buckets?: RawGeminiCliBucket[];
}
/** Credit entry inside a tier (supports snake_case and camelCase variants). */
export interface GeminiCliCredits {
creditType?: string;
credit_type?: string;
creditAmount?: string | number;
credit_amount?: string | number;
}
/** User tier inside a loadCodeAssist response. */
export interface GeminiCliUserTier {
id?: string;
availableCredits?: GeminiCliCredits[];
available_credits?: GeminiCliCredits[];
}
/** loadCodeAssist response shape (currentTier + paidTier). */
export interface GeminiCliCodeAssistResponse {
currentTier?: GeminiCliUserTier | null;
current_tier?: GeminiCliUserTier | null;
paidTier?: GeminiCliUserTier | null;
paid_tier?: GeminiCliUserTier | null;
}
/** Parsed error body extracted from an upstream non-200 response. */
export interface ParsedGeminiCliErrorBody {
errorCode?: string;
errorDetail?: string;
message?: string;
}
/** Supplementary tier/credit info resolved alongside the quota buckets. */
export interface GeminiCliSupplementaryInfo {
tierLabel: string | null;
tierId: string | null;
creditBalance: number | null;
normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown';
}
/** Auth-file entry as returned by the CLIProxy management API. */
export interface ManagementAuthFile {
auth_index?: string | number;
provider?: string;
type?: string;
email?: string;
name?: string;
}
/** api-call response envelope from the CLIProxy management endpoint. */
export interface ManagementApiCallResponse {
status_code?: number;
body?: string;
}
/** Normalized HTTP response used by both direct and managed code paths. */
export interface ManagedResponse {
status: number;
bodyText: string;
json: unknown;
viaManagement: boolean;
}
/** Per-account managed-auth context used to dedupe auth-index lookups. */
export interface ManagedGeminiAuthContext {
authIndexLookupPromise?: Promise<ManagedGeminiAuthLookupResult>;
}
/** Result of looking up a Gemini auth file index via management API. */
export interface ManagedGeminiAuthLookupResult {
authIndex: string | number | null;
unavailable: boolean;
}
/** Result of performing a managed Gemini upstream request. */
export interface ManagedGeminiRequestResult {
response: ManagedResponse | null;
unavailable: boolean;
}
/** Options bag for {@link buildGeminiCliFailureResult}. */
export interface GeminiCliFailureResultOptions {
error: string;
httpStatus?: number;
errorCode?: string;
errorDetail?: string;
actionHint?: string;
retryable?: boolean;
needsReauth?: boolean;
isForbidden?: boolean;
entitlement?: ProviderEntitlementEvidence;
}
// Re-export the public result shapes so callers can import everything from
// the barrel without reaching into quota-types directly.
export type { GeminiCliBucket, GeminiCliQuotaResult, ProviderEntitlementEvidence };
+20 -2
View File
@@ -9,6 +9,13 @@ import * as fs from 'node:fs';
import { getAccountTokenPath, getProviderAccounts } from '../accounts/account-manager';
import type { GhcpQuotaResult, GhcpQuotaSnapshot } from './quota-types';
import { clampPercent } from '../../utils/percentage';
import { createLogger } from '../../services/logging';
// Diagnostic-only logger: token load failures, fetch progress, and upstream
// error reasons. accountId is attached as provider context; token values are
// never logged (the error string from readGhcpAccessToken is generic and
// contains no token material).
const logger = createLogger('cliproxy:quota:ghcp');
const GHCP_USAGE_URL = 'https://api.github.com/copilot_internal/user';
const GHCP_USAGE_TIMEOUT_MS = 10000;
@@ -174,11 +181,22 @@ export async function fetchGhcpQuota(accountId: string, verbose = false): Promis
const { accessToken, error } = readGhcpAccessToken(accountId);
if (!accessToken) {
// Safe diagnostic: accountId + generic error only (never log token values/file contents).
if (verbose) console.error(`[!] ghcp quota token error (${accountId}): ${error}`);
if (verbose) {
logger.error('ghcp.token_load_error', `ghcp quota token error (${accountId}): ${error}`, {
provider: 'ghcp',
accountId,
reason: error ?? 'unknown',
});
}
return buildEmptyQuotaResult(error || 'Failed to load auth token', accountId);
}
if (verbose) console.error(`[i] Fetching ghcp quota for ${accountId}...`);
if (verbose) {
logger.info('ghcp.fetch_start', `Fetching ghcp quota for ${accountId}...`, {
provider: 'ghcp',
accountId,
});
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), GHCP_USAGE_TIMEOUT_MS);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,175 @@
/**
* fetchAccountQuota: top-level Antigravity account quota orchestrator.
*
* Reads the local auth file, calls loadCodeAssist (project + tier), then
* fetchAvailableModels. Merges the results into a QuotaResult, attaching
* entitlement evidence and persisting the resolved tier back to the account
* manager. Preserves the structured createLogger('cliproxy:quota:fetcher')
* logging from P3 (quota.fetch.start / auth_state / project_resolved / models).
*/
import type { CLIProxyProvider } from '../../types';
import type { AccountTier } from '../../accounts/account-manager';
import { setAccountTier } from '../../accounts/account-manager';
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
import { createLogger } from '../../../services/logging';
import { readAuthData } from './auth-file-reader';
import { fetchAvailableModels } from './available-models-fetcher';
import { getProjectId } from './project-lookup';
import { mergeAntigravityTierEvidence } from './status-classifier';
import type { QuotaResult } from './types';
const logger = createLogger('cliproxy:quota:fetcher');
/**
* Fetch quota for an Antigravity account.
*
* @param provider - Provider name (only 'agy' supported)
* @param accountId - Account identifier (email)
* @param verbose - Show detailed diagnostics
* @returns Quota result with models and percentages
*/
export async function fetchAccountQuota(
provider: CLIProxyProvider,
accountId: string,
verbose = false
): Promise<QuotaResult> {
if (verbose)
logger.info('quota.fetch.start', 'Fetching quota for account', { provider, accountId });
// Only Antigravity supports quota fetching
if (provider !== 'agy') {
const error = `Quota not supported for provider: ${provider}`;
if (verbose) logger.warn('quota.fetch.unsupported_provider', error, { provider });
// Stable machine code so callers branch on a code, not the human string.
// This is "no quota API for this provider", which is healthy — distinct
// from a transient fetch failure or an expired token.
return {
success: false,
models: [],
lastUpdated: Date.now(),
error,
errorCode: 'quota_not_supported',
};
}
// Read auth data from auth file (checks both active and paused directories)
const authData = readAuthData(provider, accountId);
if (!authData) {
const error = 'Auth file not found for account';
if (verbose) logger.warn('quota.fetch.auth_missing', error, { provider, accountId });
return {
success: false,
models: [],
lastUpdated: Date.now(),
error,
errorCode: 'auth_file_missing',
actionHint: 'Reconnect this account so CCS can read a current auth token.',
};
}
const accessToken = authData.accessToken;
if (verbose) {
const expiryState = authData.isExpired
? 'expired'
: authData.expiresAt
? `expires ${authData.expiresAt}`
: 'expiry unknown';
logger.info('quota.fetch.auth_state', `Auth token state: ${expiryState}`, {
provider,
state: expiryState,
});
}
// Get project ID and tier - prefer stored project ID, but always call API for tier
let projectId = authData.projectId;
let apiTier: AccountTier = 'unknown';
let rawTierId: string | null = null;
let rawTierLabel: string | null = null;
// Always call loadCodeAssist to get accurate tier from API.
// If the file token is stale, the helper retries through CLIProxy management auth.
const lastProjectResult = await getProjectId(accountId, accessToken);
if (!lastProjectResult.projectId && !projectId) {
const error = lastProjectResult.error || 'Failed to retrieve project ID';
if (verbose)
logger.warn('quota.fetch.project_lookup_failed', error, {
provider,
errorCode: lastProjectResult.errorCode,
httpStatus: lastProjectResult.httpStatus,
});
return {
success: false,
models: [],
lastUpdated: Date.now(),
error,
errorCode: lastProjectResult.errorCode,
errorDetail: lastProjectResult.errorDetail,
actionHint: lastProjectResult.actionHint,
retryable: lastProjectResult.retryable,
httpStatus: lastProjectResult.httpStatus,
needsReauth: lastProjectResult.needsReauth,
isUnprovisioned: lastProjectResult.isUnprovisioned,
entitlement: lastProjectResult.entitlement,
isExpired: authData.isExpired,
expiresAt: authData.expiresAt || undefined,
};
}
// Use API project ID if available, else fallback to stored
projectId = lastProjectResult.projectId || projectId;
apiTier = lastProjectResult.tier || 'unknown';
rawTierId = lastProjectResult.rawTierId || null;
rawTierLabel = lastProjectResult.rawTierLabel || null;
if (verbose)
logger.info('quota.fetch.project_resolved', `Project ID: ${projectId || 'not found'}`, {
provider,
});
// Fetch models with quota
const result = await fetchAvailableModels(accountId, accessToken, projectId as string);
if (verbose)
logger.info('quota.fetch.models', `Models found: ${result.models.length}`, {
provider,
count: result.models.length,
});
result.accountId = accountId;
result.projectId = projectId || undefined;
// Determine tier from API response only
if (result.success) {
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
result.tier = finalTier;
result.entitlement = buildProviderEntitlementEvidence({
normalizedTier: finalTier,
rawTierId,
rawTierLabel,
source: rawTierId ? 'runtime_api' : 'runtime_inference',
confidence: rawTierId ? 'high' : 'medium',
accessState: 'entitled',
capacityState: 'available',
});
if (finalTier !== 'unknown') {
setAccountTier(provider, accountId, finalTier);
}
} else {
result.isExpired = authData.isExpired;
result.expiresAt = authData.expiresAt || undefined;
result.entitlement = mergeAntigravityTierEvidence(
result.entitlement,
apiTier,
rawTierId,
rawTierLabel
);
}
if (verbose && result.error) {
console.log(`[!] Error: ${result.error}`);
}
return result;
}
@@ -0,0 +1,119 @@
/**
* fetchAllProviderQuotas and findAvailableAccount.
*
* fetchAllProviderQuotas fans quota fetches out across all accounts of a
* provider in parallel and groups them by GCP project id (accounts that share
* a project pool quota together, so failover between them won't help).
* findAvailableAccount wraps that to pick the first account that still has
* remaining quota (used by the auto-switch preflight check).
*/
import type { CLIProxyProvider } from '../../types';
import { getProviderAccounts, type AccountInfo } from '../../accounts/account-manager';
import { fetchAccountQuota } from './account-quota-fetcher';
import { readProjectIdFromAuthFile } from './auth-file-reader';
import type { AllAccountsQuotaResult, QuotaResult } from './types';
/**
* Fetch quota for all accounts of a provider.
* Also detects accounts sharing the same GCP project (failover won't help).
*
* @param provider - Provider name (only 'agy' supported for quota)
* @param verbose - Show detailed diagnostics
* @returns Results for all accounts with project grouping
*/
export async function fetchAllProviderQuotas(
provider: CLIProxyProvider,
verbose = false
): Promise<AllAccountsQuotaResult> {
const accounts = getProviderAccounts(provider);
const results: AllAccountsQuotaResult = {
provider,
accounts: [],
projectGroups: {},
lastUpdated: Date.now(),
};
if (accounts.length === 0) {
return results;
}
// Fetch quota for each account in parallel
const quotaPromises = accounts.map(async (account) => {
const quota = await fetchAccountQuota(provider, account.id, verbose);
// Read project ID from auth file if not in quota result
let projectId = quota.projectId;
if (!projectId) {
projectId = readProjectIdFromAuthFile(provider, account.id) || undefined;
}
return {
account,
quota: { ...quota, accountId: account.id, projectId },
};
});
const quotaResults = await Promise.all(quotaPromises);
// Build project groups for detecting shared projects
for (const { account, quota } of quotaResults) {
results.accounts.push({ account, quota });
if (quota.projectId) {
if (!results.projectGroups[quota.projectId]) {
results.projectGroups[quota.projectId] = [];
}
results.projectGroups[quota.projectId].push(account.id);
}
}
return results;
}
/**
* Find an available account with remaining quota.
* Used by preflight check for auto-switching.
*
* @param provider - Provider name
* @param excludeAccountId - Account to exclude (current exhausted account)
* @param verbose - Show detailed diagnostics
* @returns Account with available quota, or null if none available
*/
export async function findAvailableAccount(
provider: CLIProxyProvider,
excludeAccountId?: string,
verbose = false
): Promise<{ account: AccountInfo; quota: QuotaResult } | null> {
const allQuotas = await fetchAllProviderQuotas(provider, verbose);
// Get excluded account's project ID to avoid switching to same-project accounts
const excludedProjectId = allQuotas.accounts.find((a) => a.account.id === excludeAccountId)?.quota
.projectId;
for (const { account, quota } of allQuotas.accounts) {
// Skip excluded account
if (excludeAccountId && account.id === excludeAccountId) {
continue;
}
// Skip failed quota fetches
if (!quota.success) {
continue;
}
// Skip accounts sharing the same GCP project (quota is pooled)
if (excludedProjectId && quota.projectId === excludedProjectId) {
continue;
}
// Check if any model has remaining quota (> 5% to avoid edge cases)
const hasQuota = quota.models.some((m) => m.percentage > 5);
if (hasQuota) {
return { account, quota };
}
}
return null;
}
@@ -0,0 +1,93 @@
/**
* Auth file reader for Antigravity quota fetching.
*
* Reads the local Antigravity auth file (active or paused directory) and
* extracts the access token, refresh token, project id, and expiry state.
* Falls back to scanning the directory and matching by the embedded email
* field when the canonical sanitized filename is not present.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getAuthDir } from '../../config/config-generator';
import type { CLIProxyProvider } from '../../types';
import { isTokenExpired, sanitizeEmail } from '../../auth/auth-utils';
import { getPausedDir } from '../../accounts/account-manager';
import type { AntigravityAuthFile, AuthData } from './types';
/**
* Read auth data from the auth file (access token, project_id, expiry state).
* Checks both active and paused auth directories (quota is needed for paused
* accounts too).
*/
export function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | null {
const authDirs = [getAuthDir(), getPausedDir()];
// Sanitize accountId (email) to match auth file naming: @ and . → _
const sanitizedId = sanitizeEmail(accountId);
const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`;
const expectedFile = `${prefix}${sanitizedId}.json`;
for (const authDir of authDirs) {
if (!fs.existsSync(authDir)) continue;
const filePath = path.join(authDir, expectedFile);
// Direct file access (most common case)
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as AntigravityAuthFile;
if (!data.access_token) continue;
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || null,
projectId: data.project_id || null,
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
};
} catch {
continue;
}
}
// Fallback: scan directory for matching email in file content
const files = fs.readdirSync(authDir);
for (const file of files) {
if (file.startsWith(prefix) && file.endsWith('.json')) {
const candidatePath = path.join(authDir, file);
try {
const content = fs.readFileSync(candidatePath, 'utf-8');
const data = JSON.parse(content) as AntigravityAuthFile;
// Match by email field inside the auth file
if (data.email === accountId && data.access_token) {
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || null,
projectId: data.project_id || null,
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
};
}
} catch {
continue;
}
}
}
}
return null;
}
/**
* Read project ID directly from auth file without making an API call.
* Used for quick project ID comparison in the doctor command.
*/
export function readProjectIdFromAuthFile(
provider: CLIProxyProvider,
accountId: string
): string | null {
const authData = readAuthData(provider, accountId);
return authData?.projectId || null;
}
@@ -0,0 +1,99 @@
/**
* fetchAvailableModels call for Antigravity quota.
*
* Fetches the model -> remaining-fraction map from the Cloud Code internal
* API and projects it into ModelQuota[] percentages (0-100). The projectId
* is intentionally NOT sent in the body (CLIProxyAPI sends an empty {} body
* for this endpoint); it is accepted only for symmetry with the project
* lookup flow.
*/
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
import { ANTIGRAVITY_API_BASE, ANTIGRAVITY_API_VERSION, FETCHMODELS_HEADERS } from './constants';
import { performAntigravityRequest } from './http-client';
import { buildAntigravityFailure } from './status-classifier';
import type { FetchAvailableModelsResponse, ModelQuota, QuotaResult } from './types';
/**
* Fetch available models with quota info.
* Note: projectId is kept for potential future use but not sent in body
* (CLIProxyAPI sends empty {} body for this endpoint).
*/
export async function fetchAvailableModels(
accountId: string,
accessToken: string,
_projectId: string
): Promise<QuotaResult> {
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`;
const response = await performAntigravityRequest(
accountId,
accessToken,
url,
FETCHMODELS_HEADERS,
JSON.stringify({})
);
if (response.status < 200 || response.status >= 300) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
...buildAntigravityFailure(response.status, response.bodyText),
};
}
const data = response.json as FetchAvailableModelsResponse | null;
if (!data) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: 'Invalid quota response from provider',
errorCode: 'provider_unavailable',
retryable: true,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'low',
accessState: 'unknown',
capacityState: 'temporarily_unavailable',
notes: 'Provider returned a 2xx response with an empty or invalid quota payload.',
}),
};
}
const models: ModelQuota[] = [];
if (data.models && typeof data.models === 'object') {
for (const [modelId, modelData] of Object.entries(data.models)) {
const quotaInfo = modelData.quotaInfo || modelData.quota_info;
if (!quotaInfo) continue;
const remaining =
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null;
let percentage: number;
if (typeof remaining === 'number' && isFinite(remaining)) {
percentage = Math.max(0, Math.min(100, Math.round(remaining * 100)));
} else if (resetTime) {
percentage = 0;
} else {
continue;
}
models.push({
name: modelId,
displayName: modelData.displayName,
percentage,
resetTime,
});
}
}
return {
success: true,
models,
lastUpdated: Date.now(),
};
}
@@ -0,0 +1,30 @@
/**
* Constants for the Antigravity quota fetcher.
*
* Google Cloud Code internal API endpoints, fixed headers used by the
* CLIProxyAPIPlus control-plane requests, and the shared timeout applied to
* every Antigravity management API call.
*/
/** Google Cloud Code API endpoints */
export const ANTIGRAVITY_DAILY_API_BASE = 'https://daily-cloudcode-pa.googleapis.com';
export const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
export const ANTIGRAVITY_API_VERSION = 'v1internal';
export const ANTIGRAVITY_LOADCODEASSIST_BASE_URLS = [
ANTIGRAVITY_DAILY_API_BASE,
ANTIGRAVITY_API_BASE,
] as const;
export const MANAGEMENT_API_TIMEOUT_MS = 5000;
/** Headers for loadCodeAssist (matches current CLIProxyAPIPlus control-plane requests) */
export const LOADCODEASSIST_HEADERS = {
'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.21.9 darwin/arm64 google-api-nodejs-client/10.3.0',
'X-Goog-Api-Client': 'gl-node/22.21.1',
};
/** Headers for fetchAvailableModels (matches CLIProxyAPI antigravity_executor.go) */
export const FETCHMODELS_HEADERS = {
'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.104.0 darwin/arm64',
};
@@ -0,0 +1,248 @@
/**
* HTTP transport for Antigravity quota requests.
*
* Wraps fetch() with three fallback strategies:
* 1. Direct call with the local access token.
* 2. If that returns 401 (token rejected), retry through CLIProxy management
* auth using the proxy's stored token.
* 3. For loadCodeAssist, fall back across the daily then prod Cloud Code hosts.
*
* Every call is bounded by MANAGEMENT_API_TIMEOUT_MS via an AbortController.
* Network errors become synthetic 503 responses; abort timeouts become 408.
*/
import { sanitizeEmail } from '../../auth/auth-utils';
import {
buildManagementHeaders,
buildProxyUrl,
getProxyTarget,
} from '../../proxy/proxy-target-resolver';
import { MANAGEMENT_API_TIMEOUT_MS } from './constants';
import type { ManagedResponse, ManagementApiCallResponse, ManagementAuthFile } from './types';
/** Best-effort JSON.parse; returns null on failure. */
export function safeParseJson(bodyText: string): unknown {
try {
return JSON.parse(bodyText);
} catch {
return null;
}
}
/** Read the response body once and return a normalized ManagedResponse. */
async function readManagedResponse(
response: Response,
viaManagement: boolean
): Promise<ManagedResponse> {
const bodyText = await response.text();
return {
status: response.status,
bodyText,
json: safeParseJson(bodyText),
viaManagement,
};
}
/** Does this management-API auth file belong to the given Antigravity account? */
function isAntigravityAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
const provider = (file.provider || file.type || '').trim().toLowerCase();
if (provider !== 'antigravity' && provider !== 'agy') {
return false;
}
const normalizedAccount = accountId.trim().toLowerCase();
const normalizedEmail = file.email?.trim().toLowerCase();
if (normalizedEmail && normalizedEmail === normalizedAccount) {
return true;
}
const normalizedName = file.name?.trim().toLowerCase();
if (!normalizedName) {
return false;
}
const sanitizedAccount = sanitizeEmail(accountId).toLowerCase();
return (
normalizedName === `antigravity-${sanitizedAccount}.json` ||
normalizedName === `agy-${sanitizedAccount}.json`
);
}
/** Ask CLIProxy management API for the auth_index of the Antigravity account. */
async function findManagedAntigravityAuthIndex(accountId: string): Promise<string | number | null> {
const target = getProxyTarget();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
try {
const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), {
signal: controller.signal,
headers: buildManagementHeaders(target),
});
clearTimeout(timeoutId);
if (!response.ok) {
return null;
}
const data = (await response.json()) as { files?: ManagementAuthFile[] };
const match = data.files?.find((file) => isAntigravityAuthFileForAccount(file, accountId));
return match?.auth_index ?? null;
} catch {
clearTimeout(timeoutId);
return null;
}
}
/**
* Run a request through the CLIProxy management api-call endpoint using the
* proxy's stored token (substituted server-side as $TOKEN$). Returns null if
* the proxy can't handle the request.
*/
async function performManagedAntigravityRequest(
accountId: string,
url: string,
headers: Record<string, string>,
body: string
): Promise<ManagedResponse | null> {
const authIndex = await findManagedAntigravityAuthIndex(accountId);
if (authIndex === null || authIndex === undefined) {
return null;
}
const target = getProxyTarget();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
try {
const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), {
method: 'POST',
signal: controller.signal,
headers: buildManagementHeaders(target, {
'Content-Type': 'application/json',
}),
body: JSON.stringify({
auth_index: authIndex,
method: 'POST',
url,
header: {
...headers,
Authorization: 'Bearer $TOKEN$',
},
data: body,
}),
});
clearTimeout(timeoutId);
if (!response.ok) {
return null;
}
const apiResponse = (await response.json()) as ManagementApiCallResponse;
const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : '';
return {
status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500,
bodyText,
json: safeParseJson(bodyText),
viaManagement: true,
};
} catch {
clearTimeout(timeoutId);
return null;
}
}
/**
* Perform a single Antigravity POST. Tries direct with the local access token
* first; on 401, retries through CLIProxy management auth. Network errors map
* to synthetic 503 responses so the caller's status-based classifier still
* works; abort timeouts map to 408.
*/
export async function performAntigravityRequest(
accountId: string,
accessToken: string,
url: string,
headers: Record<string, string>,
body: string
): Promise<ManagedResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
...headers,
Authorization: `Bearer ${accessToken}`,
},
body,
});
clearTimeout(timeoutId);
const directResult = await readManagedResponse(response, false);
if (directResult.status !== 401) {
return directResult;
}
const managedResult = await performManagedAntigravityRequest(accountId, url, headers, body);
return managedResult ?? directResult;
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
return {
status: 408,
bodyText: '',
json: null,
viaManagement: false,
};
}
const message = err instanceof Error ? err.message : 'Unknown error';
return {
status: 503,
bodyText: message,
json: null,
viaManagement: false,
};
}
}
/**
* Run a request against each base URL in order, returning the first 2xx
* response. If none succeed, return the last failure (or a synthetic 503 if
* no URLs were attempted).
*/
export async function performAntigravityRequestWithBaseUrlFallback(
accountId: string,
accessToken: string,
baseUrls: readonly string[],
apiPath: string,
headers: Record<string, string>,
body: string
): Promise<ManagedResponse> {
let lastResponse: ManagedResponse | null = null;
for (const baseUrl of baseUrls) {
const response = await performAntigravityRequest(
accountId,
accessToken,
`${baseUrl}/${apiPath}`,
headers,
body
);
if (response.status >= 200 && response.status < 300) {
return response;
}
lastResponse = response;
}
return (
lastResponse ?? {
status: 503,
bodyText: 'No Antigravity API endpoint available',
json: null,
viaManagement: false,
}
);
}
@@ -0,0 +1,110 @@
/**
* Antigravity loadCodeAssist project + tier lookup.
*
* Calls loadCodeAssist against the daily then prod Cloud Code hosts to resolve
* the GCP project id and the account tier (paidTier.id takes priority over
* currentTier.id). Returns a structured ProjectLookupResult that the top-level
* fetchAccountQuota merges into a QuotaResult.
*/
import {
buildProviderEntitlementEvidence,
getProviderTierLabel,
normalizeProviderTierId,
} from '../../auth/provider-entitlement-evidence';
import {
ANTIGRAVITY_API_VERSION,
ANTIGRAVITY_LOADCODEASSIST_BASE_URLS,
LOADCODEASSIST_HEADERS,
} from './constants';
import { performAntigravityRequestWithBaseUrlFallback } from './http-client';
import { buildAntigravityFailure } from './status-classifier';
import type { LoadCodeAssistResponse, ProjectLookupResult } from './types';
/**
* Get project ID and tier via loadCodeAssist endpoint.
* Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier).
* Falls back across the daily then prod Cloud Code hosts.
*/
export async function getProjectId(
accountId: string,
accessToken: string
): Promise<ProjectLookupResult> {
const body = JSON.stringify({
metadata: {
ide_name: 'antigravity',
ide_type: 'ANTIGRAVITY',
ide_version: '1.21.9',
},
});
const response = await performAntigravityRequestWithBaseUrlFallback(
accountId,
accessToken,
ANTIGRAVITY_LOADCODEASSIST_BASE_URLS,
`${ANTIGRAVITY_API_VERSION}:loadCodeAssist`,
LOADCODEASSIST_HEADERS,
body
);
if (response.status < 200 || response.status >= 300) {
return {
projectId: null,
...buildAntigravityFailure(response.status, response.bodyText),
};
}
const data = response.json as LoadCodeAssistResponse | null;
if (!data) {
return {
projectId: null,
error: 'Invalid quota response from provider',
errorCode: 'provider_unavailable',
retryable: true,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'low',
accessState: 'unknown',
capacityState: 'temporarily_unavailable',
notes: 'Provider returned a 2xx response with an empty or invalid project payload.',
}),
};
}
// Extract project ID from response
let projectId: string | undefined;
if (typeof data.cloudaicompanionProject === 'string') {
projectId = data.cloudaicompanionProject;
} else if (typeof data.cloudaicompanionProject === 'object') {
projectId = data.cloudaicompanionProject?.id;
}
if (!projectId?.trim()) {
return {
projectId: null,
error: 'Sign in to Antigravity app to activate quota.',
errorCode: 'account_unprovisioned',
actionHint: 'Complete sign-in in the Antigravity app, then retry quota refresh.',
isUnprovisioned: true,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'medium',
accessState: 'unknown',
capacityState: 'unknown',
notes: 'Project provisioning is incomplete for this account.',
}),
};
}
// Extract tier - paidTier reflects actual subscription status, takes priority
const rawTierId = (data.paidTier?.id || data.currentTier?.id || '').trim() || null;
const tier = normalizeProviderTierId(rawTierId);
return {
projectId: projectId.trim(),
tier,
rawTierId,
rawTierLabel: getProviderTierLabel(rawTierId),
};
}
@@ -0,0 +1,195 @@
/**
* Status classifier for Antigravity quota fetch failures.
*
* Maps an upstream HTTP status code (plus optional response body) into a stable
* QuotaResult failure fragment: error code, action hint, retryability, and
* provider entitlement evidence. Also merges tier evidence from a successful
* project lookup with entitlement evidence derived from a failed models fetch.
*/
import type { AccountTier } from '../../accounts/account-manager';
import { buildProviderEntitlementEvidence } from '../../auth/provider-entitlement-evidence';
import type { ProviderEntitlementEvidence } from '../../auth/provider-entitlement-types';
import type { QuotaResult } from './types';
/** Trim and cap upstream error bodies to keep payloads small. */
export function normalizeErrorDetail(bodyText: string): string | undefined {
const normalized = bodyText.trim();
if (!normalized) {
return undefined;
}
if (normalized.length <= 400) {
return normalized;
}
return `${normalized.slice(0, 397)}...`;
}
/**
* Build the failure fragment for an Antigravity quota request. The returned
* object is spread into a QuotaResult by callers. Status 401/403/429/408/5xx
* each have their own stable error code and capacity/access state signal.
*/
export function buildAntigravityFailure(
status: number | undefined,
bodyText?: string
): Pick<
QuotaResult,
| 'error'
| 'errorCode'
| 'errorDetail'
| 'actionHint'
| 'retryable'
| 'httpStatus'
| 'needsReauth'
| 'entitlement'
> & { isForbidden?: boolean } {
const detail = normalizeErrorDetail(bodyText || '');
if (status === 401) {
return {
httpStatus: 401,
error: 'Token expired or invalid',
errorCode: 'reauth_required',
actionHint:
'Re-authenticate this account. If CLIProxy is running, retry after the proxy finishes refreshing the token.',
needsReauth: true,
errorDetail: detail,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'medium',
accessState: 'unknown',
capacityState: 'unknown',
}),
};
}
if (status === 403) {
return {
httpStatus: 403,
error: 'Access forbidden',
errorCode: 'quota_api_forbidden',
actionHint: 'This account does not have Gemini Code Assist quota access.',
isForbidden: true,
errorDetail: detail,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'medium',
accessState: 'not_entitled',
capacityState: 'unknown',
}),
};
}
if (status === 429) {
return {
httpStatus: 429,
error: 'Rate limited - try again later',
errorCode: 'rate_limited',
actionHint: 'Retry later. This looks temporary.',
retryable: true,
errorDetail: detail,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'low',
accessState: 'unknown',
capacityState: 'rate_limited',
}),
};
}
if (status === 408) {
return {
httpStatus: 408,
error: 'Request timeout',
errorCode: 'network_timeout',
actionHint: 'Retry later. This looks temporary.',
retryable: true,
errorDetail: detail,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'low',
accessState: 'unknown',
capacityState: 'temporarily_unavailable',
}),
};
}
if (typeof status === 'number' && status >= 500) {
return {
httpStatus: status,
error: `API error: ${status}`,
errorCode: 'provider_unavailable',
actionHint: 'Retry later. The provider appears unavailable.',
retryable: true,
errorDetail: detail,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'low',
accessState: 'unknown',
capacityState: 'temporarily_unavailable',
}),
};
}
if (typeof status === 'number' && status >= 400) {
return {
httpStatus: status,
error: `API error: ${status}`,
errorCode: 'quota_request_failed',
errorDetail: detail,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'low',
accessState: 'unknown',
capacityState: 'unknown',
}),
};
}
return {
error: 'Quota request failed',
errorCode: 'quota_request_failed',
errorDetail: detail,
entitlement: buildProviderEntitlementEvidence({
normalizedTier: 'unknown',
source: 'runtime_inference',
confidence: 'low',
accessState: 'unknown',
capacityState: 'unknown',
}),
};
}
/**
* Merge entitlement evidence from a successful project lookup with evidence
* from a failed models fetch. A known tier id from the project lookup always
* wins (runtime_api source, high confidence); otherwise we fall back to the
* pre-existing evidence or build a fresh runtime_inference record.
*/
export function mergeAntigravityTierEvidence(
entitlement: ProviderEntitlementEvidence | undefined,
tier: AccountTier,
rawTierId: string | null,
rawTierLabel: string | null
): ProviderEntitlementEvidence | undefined {
if (tier === 'unknown' && !entitlement) {
return undefined;
}
return buildProviderEntitlementEvidence({
normalizedTier: tier,
rawTierId,
rawTierLabel,
source: rawTierId ? 'runtime_api' : (entitlement?.source ?? 'runtime_inference'),
confidence: rawTierId ? 'high' : (entitlement?.confidence ?? 'medium'),
accessState: entitlement?.accessState ?? 'unknown',
capacityState: entitlement?.capacityState ?? 'unknown',
notes: entitlement?.notes ?? null,
});
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Shared types for the Antigravity quota fetcher.
*
* Public types (ModelQuota, QuotaResult, AllAccountsQuotaResult) are re-exported
* from the barrel at ../quota-fetcher.ts so existing import paths keep working.
*/
import type { CLIProxyProvider } from '../../types';
import type { AccountInfo, AccountTier } from '../../accounts/account-manager';
import type { ProviderEntitlementEvidence } from '../../auth/provider-entitlement-types';
/** Individual model quota info */
export interface ModelQuota {
/** Model name, e.g., "gemini-3-pro-high" */
name: string;
/** Display name from API, e.g., "Gemini 3 Pro" */
displayName?: string;
/** Remaining quota as percentage (0-100) */
percentage: number;
/** ISO timestamp when quota resets, null if unknown */
resetTime: string | null;
}
/** Quota fetch result */
export interface QuotaResult {
/** Whether fetch succeeded */
success: boolean;
/** Quota for each available model */
models: ModelQuota[];
/** Timestamp of fetch */
lastUpdated: number;
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** True if account lacks quota access (403) */
isForbidden?: boolean;
/** Error message if fetch failed */
error?: string;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
/** True if token is expired and needs re-auth */
isExpired?: boolean;
/** True if token refresh cannot proceed and the account should be re-authenticated */
needsReauth?: boolean;
/** ISO timestamp when token expires/expired */
expiresAt?: string;
/** True if account hasn't been activated in official Antigravity app */
isUnprovisioned?: boolean;
/** Account ID (email) this quota belongs to */
accountId?: string;
/** GCP project ID for this account */
projectId?: string;
/** Detected account tier based on model access */
tier?: AccountTier;
/** Richer provider entitlement evidence derived from live/runtime signals */
entitlement?: ProviderEntitlementEvidence;
}
/** Result for all accounts of a provider */
export interface AllAccountsQuotaResult {
/** Provider name */
provider: CLIProxyProvider;
/** Results per account */
accounts: Array<{
account: AccountInfo;
quota: QuotaResult;
}>;
/** Accounts grouped by project ID (for detecting shared projects) */
projectGroups: Record<string, string[]>;
/** Timestamp of fetch */
lastUpdated: number;
}
// ---------------------------------------------------------------------------
// Internal types (not part of the public surface)
// ---------------------------------------------------------------------------
/** Auth file structure on disk for Antigravity accounts */
export interface AntigravityAuthFile {
access_token: string;
refresh_token?: string;
email?: string;
expired?: string;
expires_in?: number;
timestamp?: number;
type?: string;
project_id?: string;
}
/** Auth data returned from file */
export interface AuthData {
accessToken: string;
refreshToken: string | null;
projectId: string | null;
isExpired: boolean;
expiresAt: string | null;
}
/** Tier info from loadCodeAssist */
export interface TierInfo {
id?: string;
isDefault?: boolean;
}
/** loadCodeAssist response */
export interface LoadCodeAssistResponse {
cloudaicompanionProject?: string | { id?: string };
/** Current tier (may be trial/temporary) */
currentTier?: TierInfo;
/** Paid tier (reflects actual subscription - takes priority) */
paidTier?: TierInfo;
/** Array of allowed tiers - use isDefault=true to find active tier (CLIProxyAPIPlus approach) */
allowedTiers?: TierInfo[];
}
/** fetchAvailableModels response model */
export interface AvailableModel {
name?: string;
displayName?: string;
quotaInfo?: {
remainingFraction?: number;
remaining_fraction?: number;
remaining?: number;
resetTime?: string;
reset_time?: string;
};
quota_info?: {
remainingFraction?: number;
remaining_fraction?: number;
remaining?: number;
resetTime?: string;
reset_time?: string;
};
}
/** fetchAvailableModels response */
export interface FetchAvailableModelsResponse {
models?: Record<string, AvailableModel>;
}
export interface ManagementAuthFile {
auth_index?: string | number;
provider?: string;
type?: string;
email?: string;
name?: string;
}
export interface ManagementApiCallResponse {
status_code?: number;
body?: string;
}
export interface ManagedResponse {
status: number;
bodyText: string;
json: unknown;
viaManagement: boolean;
}
export interface ProjectLookupResult {
projectId: string | null;
tier?: AccountTier;
rawTierId?: string | null;
rawTierLabel?: string | null;
entitlement?: ProviderEntitlementEvidence;
error?: string;
errorCode?: string;
errorDetail?: string;
actionHint?: string;
retryable?: boolean;
httpStatus?: number;
needsReauth?: boolean;
isUnprovisioned?: boolean;
}
+8 -3
View File
@@ -658,8 +658,8 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
// match the locked tier. If it doesn't, route to a healthy account in the
// locked tier instead. Locks are per-provider: locking "agy" to "ultra"
// does NOT constrain "claude", "codex", "gemini", or "ghcp".
// Graceful degradation: if no locked-tier account is available, fall through
// to the default (don't block the request entirely).
// This is intentionally strict: if no locked-tier account is available, do
// not fail open to a cross-tier default.
const tierLock = getTierLockForProvider(quotaConfig.manual, provider);
if (tierLock !== null && (defaultAccount.tier || 'unknown') !== tierLock) {
const lockedTierAccount = await findHealthyAccount(provider, []);
@@ -673,7 +673,12 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
reason: `Tier lock: selected ${tierLock} account`,
};
}
// No locked-tier account available — fall through and use default
return {
proceed: false,
accountId: '',
reason: `Tier lock: no healthy ${tierLock} account available`,
};
}
// Check if default is paused
+15 -4
View File
@@ -12,6 +12,12 @@ import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loa
import { getInstalledCliproxyVersion } from '../binary-manager';
import { compareVersions } from '../../utils/update-checker';
import { getConfigYamlPath } from '../../config/loader/io-locks';
import { createLogger } from '../../services/logging';
// Diagnostic-only logger for internal binary-compatibility notices. The
// user-facing result of enablePoolRouting is returned via the result
// message; this logger captures the version-compat caveat for diagnostics.
const logger = createLogger('cliproxy:routing:strategy');
export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin';
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false;
@@ -205,10 +211,15 @@ export function enablePoolRouting(
try {
const installedVersion = getInstalledCliproxyVersion();
if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) {
console.warn(
`[!] CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}).\n` +
` The max-retry-credentials and cooling keys may be silently ignored by the running binary.\n` +
` Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`
logger.warn(
'pool_routing.binary_below_minimum',
`CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}). ` +
`The max-retry-credentials and cooling keys may be silently ignored by the running binary. ` +
`Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`,
{
installedVersion,
minimumVersion: POOL_ROUTING_MIN_VERSION,
}
);
}
} catch {
+88 -18
View File
@@ -8,16 +8,19 @@
import * as fs from 'fs';
import * as path from 'path';
import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token';
export interface DashboardInfo {
port: number;
baseUrl: string;
authRequired?: boolean;
}
/**
* Read the port recorded in an existing bar.json.
* Returns null when the file is absent or malformed.
*/
export function resolveBarPort(ccsDir: string): number | null {
const barJsonPath = path.join(ccsDir, 'bar.json');
try {
@@ -34,25 +37,91 @@ export function resolveBarPort(ccsDir: string): number | null {
*
* Both IPv4 (127.0.0.1) and IPv6 (::1) loopback addresses are probed for each
* port. All probes are fired concurrently so worst-case latency is ~1.5 s
* (one timeout) rather than N × 1.5 s sequentially. Priority selection is
* applied after all results are in: the bar.json port is preferred over the
* defaults, and within a port 127.0.0.1 is preferred over [::1].
* (one timeout) rather than N × 1.5 s sequentially. Results are awaited in
* priority order so a lower-priority slow or streaming response cannot block
* returning an already-known higher-priority hit.
*
* Each probe speaks raw HTTP/1.1 over a socket and resolves on the status line,
* which lets discovery distinguish a live-but-auth-protected server (401/403)
* from a healthy one (200) without depending on a higher-level HTTP client.
*
* Token authentication: the probe does NOT send the token in the request.
* The real CCS Bar server reads the token from the 0600 file and includes it
* unconditionally in the x-ccs-bar-token response header. The probe then checks
* that the echoed value matches the locally-read token. A rogue loopback process
* that has not read the 0600 file cannot produce the correct value, so a 200
* without a matching token header is rejected. Sending the token in the request
* would defeat this — any process could echo what it received.
*/
export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
const { request } = await import('undici');
const token = getOrCreateBarAuthToken(ccsDir);
async function probe(url: string): Promise<{ ok: boolean }> {
try {
const { statusCode, body } = await request(url, {
method: 'GET',
headersTimeout: 1500,
bodyTimeout: 1500,
async function probe(url: string): Promise<{ ok: boolean; authRequired: boolean }> {
const net = await import('net');
const parsed = new URL(url);
const port = Number(parsed.port);
const host = parsed.hostname.replace(/^\[|\]$/g, '');
return new Promise((resolve) => {
let rawResponse = '';
let settled = false;
const finish = (statusCode = 0, headerSection = '') => {
if (settled) return;
settled = true;
// Tear down the socket the moment we have enough to decide. The summary
// endpoint only needs the status code for liveness, so a non-CCS
// loopback service that streams forever cannot block discovery from
// returning a higher-priority hit.
socket.destroy();
const authRequired = statusCode === 401 || statusCode === 403;
if (authRequired) {
resolve({ ok: true, authRequired: true });
return;
}
if (statusCode === 200) {
// Accept only when the server includes the correct token in the
// response without having received it in the request. Only the real
// CCS Bar process (which owns the 0600 file) can produce this value.
const echoMatch = headerSection.match(
new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i')
);
const echoedToken = echoMatch ? echoMatch[1].trim() : '';
resolve({ ok: echoedToken === token, authRequired: false });
return;
}
resolve({ ok: false, authRequired: false });
};
const socket = net.connect({ host, port }, () => {
// Do NOT include the token in the request — sending the secret to the
// party being authenticated lets any reflector trivially pass the check.
socket.write(
`GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\nConnection: close\r\n\r\n`
);
});
await body.text();
return { ok: statusCode === 200 };
} catch {
return { ok: false };
}
socket.setTimeout(1500, () => finish());
socket.on('data', (chunk) => {
rawResponse += chunk.toString('utf8');
const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
if (statusMatch) {
const code = Number(statusMatch[1]);
// For non-200 we can finish on the status line alone.
if (code !== 200) {
finish(code, rawResponse);
return;
}
// For 200 we need the headers section to extract the token.
if (rawResponse.includes('\r\n\r\n')) {
finish(code, rawResponse.split('\r\n\r\n')[0]);
}
}
});
socket.on('error', () => finish());
socket.on('end', () => {
const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
if (statusMatch) finish(Number(statusMatch[1]), rawResponse);
else finish();
});
});
}
const barJsonPort = resolveBarPort(ccsDir);
@@ -65,12 +134,13 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
{ port, baseUrl: `http://[::1]:${port}`, url: `http://[::1]:${port}/api/bar/summary` },
]);
const results = await Promise.all(probeTargets.map((t) => probe(t.url)));
const probes = probeTargets.map((t) => probe(t.url));
for (let i = 0; i < probeTargets.length; i++) {
if (results[i].ok) {
const result = await probes[i];
if (result.ok) {
const { port, baseUrl } = probeTargets[i];
return { port, baseUrl };
return { port, baseUrl, authRequired: result.authRequired };
}
}
return null;
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff. Show more