diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml index 1b4b97aa..2fb84552 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yml +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -1,6 +1,6 @@ name: Documentation improvement description: Report missing, outdated, or confusing docs. -title: "docs: " +title: 'docs: ' labels: - documentation body: @@ -24,7 +24,7 @@ body: attributes: label: File or page description: Path or URL if you know it. - placeholder: README.md or docs/cursor-integration.md + placeholder: README.md or https://docs.ccs.kaitran.ca/providers/oauth/cursor - type: textarea id: problem attributes: diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index ed6d044d..77db4250 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -99,11 +99,12 @@ jobs: contents: read env: OPENAI_KEY: ${{ secrets.AI_REVIEW_API_KEY }} - "OPENAI.API_BASE": ${{ vars.AI_REVIEW_BASE_URL }} - "config.model": ${{ vars.AI_REVIEW_MODEL }} - "github_action_config.auto_review": "true" - "github_action_config.auto_describe": "false" - "github_action_config.auto_improve": "false" + 'OPENAI.API_BASE': ${{ vars.AI_REVIEW_BASE_URL }} + 'config.model': ${{ vars.AI_REVIEW_MODEL }} + 'config.reasoning_effort': ${{ vars.AI_REVIEW_REASONING_EFFORT }} + 'github_action_config.auto_review': 'true' + 'github_action_config.auto_describe': 'false' + 'github_action_config.auto_improve': 'false' steps: - name: Generate App Token diff --git a/.github/workflows/docs-parity.yml b/.github/workflows/docs-parity.yml index b7c7c20e..669c1504 100644 --- a/.github/workflows/docs-parity.yml +++ b/.github/workflows/docs-parity.yml @@ -1,29 +1,44 @@ -name: Docs – Quickstart Snippet Parity +name: Docs – Parity and Freshness on: push: paths: - - "docs/quickstart-snippet.md" - - "README.md" - - "docker/README.md" - - "tests/docs/quickstart-parity.sh" - - ".github/workflows/docs-parity.yml" + - 'docs/quickstart-snippet.md' + - 'README.md' + - 'docker/README.md' + - 'macos-bar/README.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + - 'src/**' + - 'scripts/hardening-inventory.js' + - 'scripts/maintainability-metrics.js' + - 'scripts/runtime-source-classifier.js' + - 'tests/docs/quickstart-parity.sh' + - 'tests/docs/documentation-freshness.js' + - '.github/workflows/docs-parity.yml' pull_request: paths: - - "docs/quickstart-snippet.md" - - "README.md" - - "docker/README.md" - - "tests/docs/quickstart-parity.sh" - - ".github/workflows/docs-parity.yml" + - 'docs/quickstart-snippet.md' + - 'README.md' + - 'docker/README.md' + - 'macos-bar/README.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + - 'src/**' + - 'scripts/hardening-inventory.js' + - 'scripts/maintainability-metrics.js' + - 'scripts/runtime-source-classifier.js' + - 'tests/docs/quickstart-parity.sh' + - 'tests/docs/documentation-freshness.js' + - '.github/workflows/docs-parity.yml' jobs: - quickstart-parity: - name: Assert quickstart snippet matches in README.md and docker/README.md + docs-parity: + name: Assert documentation parity and freshness if: >- contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association) || github.event_name == 'push' runs-on: [self-hosted, linux, x64, cliproxy] - steps: - name: Checkout uses: actions/checkout@v4 @@ -32,3 +47,9 @@ jobs: - name: Run quickstart parity check run: bash tests/docs/quickstart-parity.sh + + - name: Validate documentation pointers and relative links + run: node tests/docs/documentation-freshness.js + + - name: Validate generated hardening inventory + run: node scripts/hardening-inventory.js --check diff --git a/CLAUDE.md b/CLAUDE.md index 9d900563..8d09546e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,23 @@ Profile resolution priority: All env values written into settings must be strings. +## Documentation Truth + +Use the narrowest authoritative source: + +1. Source, tests, package scripts, and workflows define implemented behavior. +2. `CLAUDE.md` and `CONTRIBUTING.md` define repository workflow. +3. `docs/README.md` maps maintainer documentation and its owners. +4. The separate `kaitranntt/ccs-docs` repository and published site own user + guides and CLI reference. +5. Generated artifacts and live runtime checks define what shipped or is + currently running. + +Do not copy inventories, line counts, locale lists, target lists, or command +details when a stable source link is enough. Update the owning documentation +when behavior, commands, setup, architecture, security posture, or maintainer +workflow changes. Remove stale claims instead of preserving them as TODOs. + ## User-Facing Change Checklist - Update the matching `--help` handler when CLI behavior changes. @@ -46,7 +63,10 @@ All env values written into settings must be strings. - Use neutral broad examples such as `ccs`, `ccs codex`, `ccs glm`, or `ccs ` unless the page is provider-specific. - If CLI commands, config, providers, install steps, or user workflows change, - update the public CCS docs in `/Users/kaitran/CloudPersonal/ccs/docs`. + update the separate public CCS docs repository. Maintainers using the + standard CloudPersonal checkout may have it at + `/Users/kaitran/CloudPersonal/ccs/docs`; fork contributors can use their own + checkout and coordinate the matching docs change in the PR. Help locations: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df420de4..b8f376bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ If you are new to the project, start with a docs fix, a focused bug fix, or an i | CLI runtime | `src/`, `lib/`, `config/`, `scripts/` | Add or update tests in `tests/` | | Dashboard UI | `ui/src/` | Run `cd ui && bun run validate` | | Web server and config APIs | `src/web-server/`, `src/api/`, `src/config/` | Add unit or integration coverage | -| Documentation | `https://docs.ccs.kaitran.ca`, `README.md`, `docs/`, `CONTRIBUTING.md` | Keep user-facing docs in sync | +| Documentation | `https://docs.ccs.kaitran.ca`, `README.md`, [`docs/`](./docs/README.md), `CONTRIBUTING.md` | Update the owning source, not every page | | Static assets | `assets/` | Verify screenshots and references still match | Useful directories: @@ -92,6 +92,21 @@ If `CI` or `Push CI` stays queued for a long time, it is a maintainer infrastruc `CONTRIBUTING.md` is the human entry point. For AI agents working in this repo, the authoritative automation and workflow rules live in [CLAUDE.md](./CLAUDE.md). +## Documentation Ownership + +Documentation follows this truth order: + +1. Source, tests, package scripts, and workflows define implemented behavior. +2. [CLAUDE.md](./CLAUDE.md) and this guide define repository workflow. +3. [docs/README.md](./docs/README.md) maps maintainer documentation. +4. [docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca) owns detailed user guides + and CLI reference. + +Update documentation when a change affects behavior, commands, setup, +architecture, security posture, or contributor workflow. Prefer links to +authoritative source files over copied trees, counts, and option lists that +drift quickly. + ## AI Review Lane CCS PR review no longer depends on `anthropics/claude-code-action`. The repository review lane is self-hosted PR-Agent: @@ -100,7 +115,7 @@ CCS PR review no longer depends on `anthropics/claude-code-action`. The reposito - Use `/review` on the PR when you need a fresh pass after follow-up commits. - Only the trusted `/review` comment path is enabled. - Keep repository-level reviewer instructions in the root `.pr_agent.toml`. -- Keep runtime wiring and defaults in `ai-review.yml`, which still maps the existing `AI_REVIEW_BASE_URL`, `AI_REVIEW_MODEL`, and `AI_REVIEW_API_KEY` integrations onto PR-Agent's `OPENAI.*` and `config.*` settings. +- Keep runtime wiring and defaults in `ai-review.yml`, which maps the existing `AI_REVIEW_BASE_URL`, `AI_REVIEW_MODEL`, `AI_REVIEW_REASONING_EFFORT`, and `AI_REVIEW_API_KEY` integrations onto PR-Agent's `OPENAI.*` and `config.*` settings. - If you change review defaults, update the workflow or `.pr_agent.toml` alongside the contributor or architecture docs in the same PR. Example: diff --git a/README.md b/README.md index bb57a1b4..6fba155f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# CCS - Claude Code Switch +# CCS - Claude Codex Switch ![CCS Logo](assets/ccs-logo-medium.png) @@ -94,7 +94,8 @@ The proxy also supports request-time `profile:model` selectors, scenario-based model routing through `proxy.routing`, and explicit activation helpers such as `ccs proxy activate --fish`. -Guide: [OpenAI-Compatible Provider Routing](./docs/openai-compatible-providers.md) +Guide: +[OpenAI-Compatible Provider Routing](https://docs.ccs.kaitran.ca/features/proxy/openai-compatible-providers). ### Related Project: claude-code-router @@ -166,7 +167,7 @@ CCS can provision first-class local tools like WebSearch and image analysis for third-party launches instead of leaving you to wire them by hand. Browser automation now has a first-class setup path as well. Deep dive: [WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch) | -[Browser Automation](./docs/browser-automation.md). +[Browser Automation](https://docs.ccs.kaitran.ca/features/workflow/browser-automation). ## Docs Matrix @@ -183,7 +184,7 @@ reference material. | Compare OAuth providers, Claude accounts, and API profiles | [Provider Overview](https://docs.ccs.kaitran.ca/providers/concepts/overview) | | Learn the dashboard structure and feature pages | [Dashboard Overview](https://docs.ccs.kaitran.ca/features/dashboard/overview) | | Configure profiles, paths, and environment variables | [Configuration](https://docs.ccs.kaitran.ca/getting-started/configuration) | -| Understand browser attach vs Codex browser tooling | [Browser Automation](./docs/browser-automation.md) | +| Understand browser attach vs Codex browser tooling | [Browser Automation](https://docs.ccs.kaitran.ca/features/workflow/browser-automation) | | Keep OpenCode aligned with your live CCS setup | [OpenCode Sync Plugin](https://docs.ccs.kaitran.ca/features/workflow/opencode-sync) | | Browse every command and flag | [CLI Commands](https://docs.ccs.kaitran.ca/reference/cli-commands) | | Recover from install, auth, or provider failures | [Troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) | @@ -216,6 +217,7 @@ ccs ollama "summarize these logs" - Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md) - Daily local gate: `bun run format && bun run lint:fix && bun run validate` (`validate` is the fast path only) - Before review or merge confidence: `bun run validate:ci-parity` +- Cross-runtime proxy transport: `bun run test:runtime-matrix` (Node 18/22/26 and Bun) - If PR checks stay queued for more than 10 minutes, assume the self-hosted runner is offline and notify a maintainer instead of retrying blindly - Starter work: [good first issue](https://github.com/kaitranntt/ccs/labels/good%20first%20issue), diff --git a/assets/screenshots/live-auth-monitor.webp b/assets/screenshots/live-auth-monitor.webp index 8bd4d02e..05bbf65a 100644 Binary files a/assets/screenshots/live-auth-monitor.webp and b/assets/screenshots/live-auth-monitor.webp differ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..3a500ad0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,67 @@ +# CCS Maintainer Documentation + +This directory explains how the CLI repository is organized and maintained. +User-facing guides and command reference live at +[docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca). + +## Truth Hierarchy + +Use the narrowest authoritative source: + +1. Source, tests, package scripts, and workflows define implemented behavior. +2. [`CLAUDE.md`](../CLAUDE.md) and + [`CONTRIBUTING.md`](../CONTRIBUTING.md) define repository workflow. +3. The documents below explain architecture, rationale, and maintainer + decisions. +4. Build artifacts, releases, and runtime checks define shipped or observed + state. + +When sources disagree, verify the implementation first and update or remove the +stale prose. Avoid copying volatile counts, recursive trees, and exhaustive +option lists when a source link can remain accurate. + +## Start Here + +| Need | Owner | +| --- | --- | +| Repository domains and source entry points | [Codebase summary](./codebase-summary.md) | +| Coding, testing, error, and size conventions | [Code standards](./code-standards.md) | +| System boundaries and data flow | [System architecture](./system-architecture/index.md) | +| Current maintenance direction | [Project roadmap](./project-roadmap.md) | +| Release mechanics | [Release process](./release-process.md) | +| Dashboard localization | [Dashboard i18n](./i18n-dashboard.md) | +| Test layout and commands | [Test suite](../tests/README.md) | +| Dashboard development | [UI guide](../ui/README.md) | + +Feature-specific maintainer notes remain in this directory. Discover them by +filename, then verify referenced behavior against the linked source. + +## Update Triggers + +Update the owning documentation in the same change when any of these move: + +- CLI or dashboard behavior, commands, flags, or configuration +- installation, deployment, release, or contributor workflow +- architecture, data flow, persistence, security, or public contracts +- source ownership or the stable entry point named by a guide + +Pure refactors need documentation changes only when they invalidate a +maintainer decision or navigation link. Public behavior changes also require a +matching update in the separate `kaitranntt/ccs-docs` repository on the same +target branch. Maintainers using the standard CloudPersonal checkout may have +that repository at `/Users/kaitran/CloudPersonal/ccs/docs`; fork contributors +can use their own checkout and coordinate the matching docs change in the PR. + +## Validation + +Before review: + +```bash +bash tests/docs/quickstart-parity.sh +git diff --check +``` + +The repository-owned docs test currently checks the canonical quickstart +snippet only. For other changed Markdown, verify each relative link and +referenced path directly, then run the focused validation command for any code +or workflow the document describes. diff --git a/docs/browser-automation.md b/docs/browser-automation.md index aeb5e617..d27a323f 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -1,273 +1,61 @@ -# Browser Automation +# Browser Automation Developer Contract -Last Updated: 2026-05-11 +User setup and troubleshooting live in the canonical +[Browser Automation guide](https://docs.ccs.kaitran.ca/features/workflow/browser-automation). +This local file retains security-sensitive runtime invariants that contributors +must preserve until the public guide covers them fully. -CCS provides browser automation through two separate runtime paths: +## Exposure And Runtime Ownership -- **Claude Browser Attach**: reuses a running Chrome/Chromium session through the CCS-managed local `ccs-browser` MCP runtime -- **Codex Browser Tools**: injects Playwright MCP tooling into Codex-target launches +- Claude Browser Attach and Codex Browser Tools are separate lanes; they do not + promise a shared browser session. +- New installs and upgrades without saved browser settings default both lanes + to `enabled: false` and `policy: manual`. +- `--browser` and `--no-browser` are one-run exposure overrides. They do not + change saved policy. +- CCS owns `mcpServers.ccs-browser`, the + `~/.ccs/mcp/ccs-browser-server.cjs` runtime, and Codex `ccs_browser` + overrides. Generic MCP editors are not the primary setup surface. -These are related, but they are not the same implementation and they do not promise a shared browser session. -On new installs, and on upgrades that do not already have explicit browser settings, both lanes -start **disabled** and **manual** so browser tooling is not auto-exposed until you opt in. - -## How Browser Automation Works - -### Claude Browser Attach - -Claude-target CCS launches can provision a managed local MCP server named `ccs-browser`. -That path is designed for workflows where you want Claude to interact with a browser session -that already has useful authenticated state. - -Claude Browser Attach requires a browser launched in attach mode with remote debugging -enabled. A recent Chrome update alone is not sufficient. - -### Codex Browser Tools - -Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides -for the `ccs_browser` runtime config entry. - -This is configured from the same Browser settings surface, but it is distinct from Claude -Browser Attach. - -## Configuration - -### Via Dashboard - -Open `ccs config` -> `Settings` -> `Browser`. - -The Browser screen exposes two sections: - -- **Claude Browser Attach** - - enable/disable the Claude attach lane - - choose the Chrome user-data directory - - set the expected DevTools port - - review readiness and next-step guidance - - copy a generated browser launch command -- **Codex Browser Tools** - - enable/disable CCS-managed browser tooling for Codex-target launches - - review whether the detected Codex build supports managed browser overrides - -Browser policy controls are CLI-first in this release. The dashboard remains the shared setup and -status surface, while `ccs browser policy` is the authoritative place to decide whether browser -tooling is auto-exposed or kept manual by default. Fresh installs, plus upgrades without an -existing browser section, surface both lanes as off/manual until you explicitly enable them. - -### Via CLI - -```bash -ccs help browser -ccs browser setup -ccs browser status -ccs browser doctor -ccs browser policy -ccs browser policy --all manual -``` - -Use `ccs browser setup` for the primary one-command setup path. Use `ccs browser status` for -the current state, `ccs browser doctor` for read-only troubleshooting guidance, and -`ccs browser policy` to control default browser exposure. If you only want browser access for one -run, keep policy manual and add `--browser` to that launch. - -### Via Config File - -Edit `~/.ccs/config.yaml`: - -```yaml -browser: - claude: - enabled: false - policy: manual - user_data_dir: "~/.ccs/browser/chrome-user-data" - devtools_port: 9222 - codex: - enabled: false - policy: manual -``` - -Notes: - -- `claude.policy` and `codex.policy` accept `auto` or `manual` -- `claude.user_data_dir` is a **Chrome user-data directory**, not a display-name browser profile -- `claude.devtools_port` is the expected remote debugging port for attach mode -- `codex.enabled` controls whether CCS injects browser tooling into Codex-target launches -- New installs, plus upgrades without saved browser settings, default both lanes to `enabled: false` and `policy: manual` -- `manual` keeps the lane configured but hidden until a launch explicitly opts in with `--browser` - -## Runtime Policy Controls - -CCS now separates **lane enablement** from **default exposure policy**: - -- `enabled: false` - - the lane is off; this is the default for both lanes on new installs and upgrades without saved browser settings -- `enabled: true` + `policy: auto` - - the lane is exposed automatically on matching launches -- `enabled: true` + `policy: manual` - - the lane stays configured, but CCS keeps browser tooling hidden unless the current launch uses - `--browser` - -One-run launch overrides: - -```bash -ccs browser policy --all manual -ccs glm --browser "inspect the page" -ccs glm --no-browser "summarize the docs" -ccs default --target codex --browser "use the browser tools for this run" -``` - -- `--browser` forces browser tooling on for the current launch when that lane is enabled -- `--no-browser` suppresses browser tooling for the current launch even when policy is `auto` - -## Environment Variable Overrides - -CCS still supports environment-variable overrides for backward compatibility. - -| Variable | Description | -|----------|-------------| -| `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir | -| `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory | -| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override | -| `CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled` | Dangerous local-testing opt-in for Browser MCP response fulfillment; disabled by default | -| `CCS_BROWSER_UPLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for local files that browser upload tools may read | -| `CCS_BROWSER_DOWNLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for caller-provided browser download directories | - -If an override is active, Browser status surfaces should report that the current session is being -managed externally by environment variables. - -Browser MCP request interception can continue or fail matched requests by default. Synthetic response -fulfillment (`action: fulfill`) is more sensitive because it can serve caller-supplied response -content inside the attached browser's target origin. CCS therefore hides and blocks fulfillment unless -`CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled` is set for a trusted local test session. - -The saved browser policy still controls default exposure. Env overrides change the effective attach -path/port for the current shell; they do not bypass `policy: manual`. - -Override precedence is: +## Attach Override Precedence 1. `CCS_BROWSER_USER_DATA_DIR` -2. `CCS_BROWSER_PROFILE_DIR` -3. the persisted `browser.claude.user_data_dir` config value +2. legacy `CCS_BROWSER_PROFILE_DIR` +3. persisted `browser.claude.user_data_dir` -Config-backed Browser Attach always passes an explicit DevTools port to the runtime, even when the -effective value is the default `9222`. Metadata-based port discovery is preserved only for the -legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not set. +Config-backed attach always passes an explicit DevTools port, including the +default `9222`. Metadata-based port discovery exists only for the legacy +profile-dir override when `CCS_BROWSER_DEVTOOLS_PORT` is unset. -### Browser File Transfer Safety +## Interception Safety -Claude Browser Attach file-transfer tools intentionally use a deny-by-default filesystem boundary: +Request interception may continue or fail matched requests by default. +Synthetic fulfillment can serve caller-supplied content inside the target +origin, so it must stay hidden and blocked unless +`CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled` is explicitly set for a trusted +local test. -- Downloads without an explicit `downloadPath` go to a CCS-created temporary session directory. -- A caller-provided `downloadPath` must be inside that temporary session directory or inside one of - the directories listed in `CCS_BROWSER_DOWNLOAD_ROOTS`. -- Local upload and drag-and-drop files must be inside the temporary session download directory or - inside one of the directories listed in `CCS_BROWSER_UPLOAD_ROOTS`. -- Hidden path segments and common secret locations/files, such as `.ssh`, `.aws`, `.ccs`, - `.claude`, `.env`, and private-key filenames, are rejected even inside an allowed root. -- Each file-transfer call is limited to 10 files, and each local file must be at most 10 MiB. +## Event Observation Safety -Set upload/download roots only to purpose-built scratch directories. Do not point these variables at -your home directory, a source checkout with secrets, or a real cloud/tooling config directory. +- `browser_wait_for_event` requires `urlIncludes` for network-request events. +- Download events require either `urlIncludes` or + `suggestedFilenameIncludes`. +- Returned navigation, request, and download URLs must redact query strings, + fragments, and path-scoped bearer values before reaching the MCP caller. -## Managed Runtime Files +## File Transfer Safety -- `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach -- `~/.ccs/mcp/ccs-browser-server.cjs` -> local Claude Browser Attach MCP runtime -- `Codex runtime config overrides` -> CCS manages the `ccs_browser` MCP entry for Codex-target launches +Browser file transfer is deny-by-default: -Do not treat the generic Codex MCP editor as the primary browser setup path. CCS-managed browser -entries should be configured from `Settings -> Browser`. +- implicit downloads use a CCS-created temporary session directory; +- explicit download paths must remain inside that session directory or a + `CCS_BROWSER_DOWNLOAD_ROOTS` allowlisted root; +- uploads and drag-and-drop files must remain inside the session directory or a + `CCS_BROWSER_UPLOAD_ROOTS` allowlisted root; +- hidden segments and common secret locations/files (`.ssh`, `.aws`, `.ccs`, + `.claude`, `.env`, private keys) remain denied inside allowed roots; +- each call permits at most 10 files, each no larger than 10 MiB. -## Primary Setup Flow - -The shortest supported setup path is: - -```bash -ccs browser setup -``` - -That flow: - -1. enables Claude Browser Attach in the saved CCS browser config -2. leaves launch exposure under the saved policy, so `policy: manual` still requires `--browser` -3. keeps the configured DevTools port normalized -4. creates the configured browser user-data directory if needed -5. prints the exact browser launch command for the current platform -6. re-checks readiness and reports the next step if Chrome still needs manual attention - -## Launching Chrome For Claude Attach - -Claude Browser Attach needs a browser launched with remote debugging. - -Typical examples: - -```bash -# macOS -open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data" - -# Linux -google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data" - -# Windows -chrome.exe --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.ccs\\browser\\chrome-user-data" -``` - -Using a dedicated CCS browser data dir is recommended. It avoids profile-locking issues and keeps -automation state separate from your daily browser profile. - -When Claude Browser Attach uses the recommended managed path (`~/.ccs/browser/chrome-user-data`), -CCS now creates that directory automatically the first time it needs it. After that bootstrap step, -the remaining requirement is a running Chrome session started with `--remote-debugging-port`. - -## Troubleshooting - -### Browser status says Claude Browser Attach is disabled - -Run `ccs browser setup`, enable Claude Browser Attach in `Settings -> Browser`, or edit the -browser config block in `~/.ccs/config.yaml`. - -### Browser status says the path is missing - -The configured Chrome user-data directory does not exist yet. - -1. Run `ccs browser setup` -2. If Chrome still is not ready, use the generated launch command -3. Rerun `ccs browser doctor` - -If you are using the CCS-managed default path, this usually means the path could not be created -automatically and now needs manual attention. - -### Browser status says no running browser session was found - -CCS could not find usable DevTools attach metadata for the configured user-data directory. - -1. Run `ccs browser setup` -2. If needed, make sure Chrome was started with `--remote-debugging-port=` -3. Make sure it is using the same `user_data_dir` configured in CCS -4. Rerun `ccs browser doctor` - -For the CCS-managed default path, this is the normal first-run state after CCS bootstraps the -directory for you. - -### Browser status says the DevTools endpoint is unreachable - -CCS found attach metadata, but the endpoint did not answer successfully. - -1. Run `ccs browser setup` -2. If needed, restart the attach browser session -3. Confirm the expected port matches the real remote debugging port -4. Rerun `ccs browser status` - -### Codex Browser Tools are unavailable - -Codex browser tooling depends on a Codex build that supports `--config` overrides. - -If CCS reports `unsupported_build`, upgrade Codex and rerun `ccs browser status`. - -## Security Notes - -- Browser automation may operate inside authenticated browser sessions -- Prefer a dedicated automation user-data dir instead of your everyday browser profile -- Do not commit browser paths, secrets, or generated session state to version control -- Treat `~/.ccs/config.yaml`, `~/.claude.json`, and the browser user-data directory as local machine state -- `browser_wait_for_event` requires explicit scoping for network request events (`urlIncludes`) and download events (`urlIncludes` or `suggestedFilenameIncludes`) -- Event details redact observed navigation, request, and download URLs before returning them to the MCP caller so observed browser metadata does not expose query strings, fragments, or path-scoped bearer values +Allowlist only purpose-built scratch directories. Never allowlist a home +directory, source checkout containing secrets, or real tooling configuration +directory. diff --git a/docs/ccs-bar.md b/docs/ccs-bar.md deleted file mode 100644 index 3226d6ec..00000000 --- a/docs/ccs-bar.md +++ /dev/null @@ -1,144 +0,0 @@ -# CCS Bar — Native macOS Menu Bar App - -CCS Bar is a native macOS menu-bar app that shows live subscription quota and usage at a glance for your Claude Code, Codex, and CLIProxy accounts, without opening the dashboard. - -## What It Is - -CCS Bar is a thin client of the CCS local web-server. It never talks to a provider directly: every call goes to `localhost`, and CCS performs any provider fetch server-side. Opening the menu fires a debounced force-refresh so the glance reflects live data without blocking the UI. - -It is macOS only. - -## What It Shows - -- Per-account quota percent and reset countdown -- Account tier -- Today, 7-day, and 30-day cost -- A 30-day usage sparkline -- Account state (active, paused, default) -- Native subscription rows for Claude Code and Codex - -## Requirements - -- macOS -- CCS CLI installed and configured (`ccs config` works) -- The CCS web-server reachable on loopback. `ccs bar` (or `ccs bar launch`) starts it for you. - -## Install - -```bash -ccs bar install -``` - -This downloads `CCS-Bar.app.zip` from the floating `ccs-bar-latest` GitHub release and installs `CCS Bar.app` into `~/Applications`. Downloads are restricted to `github.com` and `objects.githubusercontent.com`, and extraction is guarded against zip-slip. - -If `CCS Bar.app` is already installed, the command shows the current version and proceeds as a reinstall. - -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 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 -ccs bar install --no-launch # install, skip launch prompt -``` - -### Gatekeeper note - -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. - -If macOS blocks the app on first launch, make an explicit trust decision by right-clicking the app and choosing Open. - -## Run - -There are two ways to run CCS Bar, and both share one background server. - -**Open the app.** Launch `CCS Bar` from Spotlight, Finder, or the Dock. The first time you open the menu, the app looks for a running CCS server and, if it does not find one, starts it in the background using the launch details recorded at install (`~/.ccs/bar/launch.json`). No terminal needed. - -**Or use the CLI.** - -```bash -ccs bar # alias: ccs bar launch -``` - -`ccs bar` probes for a running server (ports 3000, 3001, 3002, 8000, 8080, with the previous `bar.json` port first). If one is live it is reused; otherwise `ccs bar` starts the server as a detached background process and returns your prompt right away, then opens the app. The server keeps running after the command exits, so closing the terminal does not take the bar offline. - -### Server lifecycle - -```bash -ccs bar serve # run the server in the current terminal (advanced; ccs bar runs this detached for you) -ccs bar stop # stop the background server -ccs bar status # report whether the server is running -``` - -### Files under ~/.ccs - -| File | Purpose | -| --- | --- | -| `~/.ccs/bar.json` | Discovery file the app reads to find the live server | -| `~/.ccs/bar/launch.json` | How to start the server (recorded at install) so the app can start it without a shell PATH | -| `~/.ccs/bar/server.pid` | PID of the background server, used by `ccs bar stop` and `ccs bar status` | -| `~/.ccs/bar/serve.log` | stdout and stderr of the background server | - -`~/.ccs/bar.json` looks like this: - -```json -{ "baseUrl": "http://127.0.0.1:3000", "port": 3000, "authMode": "loopback" } -``` - -The Swift app reads `~/.ccs/bar.json` to find the server, and falls back to probing the candidate ports if it is missing or stale. - -## Loopback / Localhost Requirement - -CCS Bar talks only to `http://127.0.0.1:`. v1 supports `authMode: "loopback"` only, meaning dashboard auth disabled on localhost. - -If you bind the dashboard beyond localhost (for example `--host` set to a non-loopback address) with dashboard auth disabled, the bar's read endpoints (`GET /api/bar/summary`, `GET /api/bar/analytics`) are refused for non-loopback callers, and the app cannot reach the server. Keep the dashboard on loopback for CCS Bar to work. - -## Uninstall - -```bash -ccs bar uninstall -``` - -This removes `~/Applications/CCS Bar.app` and the installed version pin. It is a no-op if the app is not present. - -## Troubleshooting - -- 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 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. - -## Development - -The source lives in `macos-bar/`. Contributors can build and run the logic checks with a Swift 5.9+ toolchain (CommandLineTools is enough, full Xcode not required): - -```bash -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`) when the selected ref is `main`. -- 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 with `main` selected as the workflow ref. - -### 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. diff --git a/docs/code-standards.md b/docs/code-standards.md index 459c7330..e118e457 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -1,749 +1,199 @@ # CCS Code Standards -Last Updated: 2026-04-07 - -Code standards, modularization patterns, and conventions for the CCS codebase. - ---- - -## Core Principles - -### YAGNI (You Aren't Gonna Need It) -- No features "just in case" -- Only implement what is currently needed -- Delete unused code rather than commenting it out - -### KISS (Keep It Simple, Stupid) -- Prefer simple solutions over clever ones -- Reduce complexity at every opportunity -- Use established patterns over custom implementations - -### DRY (Don't Repeat Yourself) -- One source of truth for configuration -- Extract common logic into shared utilities -- Use barrel exports to centralize imports - ---- - -## File Organization - -### Directory Structure Rules - -1. **Domain-based organization**: Group files by business domain, not by file type -2. **Barrel exports required**: Every directory must have an `index.ts` aggregating exports -3. **Flat within depth**: Keep nesting to 3 levels maximum -4. **Co-location**: Keep related files together (component + hooks + utils) - -### File Naming Conventions - -| Convention | Example | When to Use | -|------------|---------|-------------| -| kebab-case | `cliproxy-executor.ts` | All TypeScript/TSX files | -| kebab-case | `profile-detector.ts` | Multi-word file names | -| *-adapter.ts | `claude-adapter.ts`, `droid-adapter.ts` | TargetAdapter implementations | -| *-detector.ts | `droid-detector.ts` | Binary detection logic | -| *-manager.ts | `droid-config-manager.ts` | Config/state management | -| PascalCase | `BinaryManager` | Class exports only | -| camelCase | `detectProfile` | Function exports | - -**File names should be descriptive**: LLMs should understand the file's purpose from its name alone without reading content. - -### Correct Examples - -``` -src/cliproxy/binary-manager.ts # Binary management logic -src/commands/doctor-command.ts # Doctor CLI command handler -ui/src/components/cliproxy/provider-editor/index.tsx -``` - -### Incorrect Examples - -``` -src/utils/helper.ts # Too vague -src/cliproxy/manager.ts # Which manager? -ui/src/components/Editor.tsx # Not kebab-case -``` - ---- - -## File Size Limit: 200 Lines - -**Target**: All code files should be under 200 lines. - -**Exceptions** (with justification): -- Data files (model-pricing.ts, model-catalog.ts) -- Entry points with routing logic (ccs.ts) -- Complex transformation logic that cannot be meaningfully split - -### Why 200 Lines? - -1. **Context efficiency**: LLMs process smaller files faster -2. **Single responsibility**: Forces focused, testable modules -3. **Navigation**: Easier to scan and understand -4. **Maintainability**: Reduces merge conflicts - -### When Files Exceed 200 Lines - -If a file grows beyond 200 lines: - -1. **Identify extraction candidates**: - - Helper functions that could be utilities - - Constants and type definitions - - Subcomponents within React components - - Related logic that forms a cohesive unit - -2. **Create subdirectory structure**: - ``` - # Before - provider-editor.tsx (921 lines) - - # After - provider-editor/ - ├── index.tsx # Main component (200 lines) - ├── model-mapping-form.tsx - ├── endpoint-config.tsx - ├── auth-section.tsx - ├── hooks.ts - ├── types.ts - └── utils.ts - ``` - -3. **Preserve public API**: Main export remains the same through barrel export - ---- - -## Barrel Export Pattern - -### What is a Barrel Export? - -An `index.ts` file that aggregates and re-exports module contents: - -```typescript -// src/cliproxy/index.ts - -// Types (with explicit type keyword) -export type { PlatformInfo, BinaryInfo } from './types'; - -// Functions -export { detectPlatform } from './platform-detector'; -export { BinaryManager } from './binary-manager'; - -// From subdirectories -export * from './auth'; -export * from './services'; -``` - -### Rules for Barrel Exports - -1. **Every domain directory must have `index.ts`** -2. **Export types with `export type`** for tree-shaking -3. **Re-export subdirectories** for deep access -4. **Keep barrel exports flat** - no logic, only exports - -### Import Patterns - -```typescript -// CORRECT: Import from domain barrel -import { execClaudeWithCLIProxy, CLIProxyProvider } from '../cliproxy'; -import { Config, Settings } from '../types'; - -// INCORRECT: Import from specific file (bypasses barrel) -import { execClaudeWithCLIProxy } from '../cliproxy/cliproxy-executor'; -``` - -### Exception: Deep Imports - -Allowed when: -- Importing private utilities not exposed in barrel -- Circular dependency avoidance -- Performance-critical tree-shaking - ---- - -## Target Adapter Pattern - -The target adapter pattern enables pluggable support for multiple CLI implementations (Claude Code, Factory Droid, Codex CLI, etc.) while preserving a unified profile system. - -### Pattern Overview - -**Each CLI target implements a `TargetAdapter` interface:** - -```typescript -interface TargetAdapter { - readonly type: TargetType; // 'claude' | 'droid' | 'codex' - readonly displayName: string; // Human-readable name - - detectBinary(): TargetBinaryInfo | null; // Find CLI on system - prepareCredentials(creds: TargetCredentials): Promise; // Deliver credentials - buildArgs(profile: string, userArgs: string[]): string[]; // Build CLI args - buildEnv(creds: TargetCredentials, type: string): Env; // Build env vars - exec(args: string[], env: Env): void; // Spawn CLI process - supportsProfileType(type: string): boolean; // Validate profile -} -``` - -### Key Differences Per Target - -| Aspect | Claude | Droid | Codex | -|--------|--------|-------|-------| -| **Credential delivery** | Environment variables | Config file (~/.factory/settings.json) | Transient `-c` overrides + `CCS_CODEX_API_KEY` | -| **Spawn args** | `claude ` | `droid -m custom:ccs- ` | `codex ` or `codex -c ... ` | -| **Config write** | None (uses env) | `upsertCcsModel()` writes to settings | None at runtime; dashboard edits user-owned `~/.codex/config.toml` only | -| **Binary detection** | `detectClaudeCli()` | `detectDroidCli()` with version check | `detectCodexCli()` plus `--config` capability probe | - -### Target Resolution Priority - -Resolves which adapter to use via `resolveTargetType()`: - -``` -1. --target flag (highest priority) - ↓ -2. explicit runtime entrypoint (`CCS_INTERNAL_ENTRY_TARGET`): - - ccs-droid / ccsd → droid - - ccs-codex / ccsx → codex - - ccsxp → codex (native cliproxy shortcut) - ↓ -3. argv[0] detection (runtime alias pattern / custom alias map): - - ccs-droid → droid - - ccsd → droid - - ccs-codex → codex - - ccsx → codex - - ccs → default - ↓ -4. Profile config: profileConfig.target field - ↓ -5. Fallback: 'claude' (lowest priority) -``` - -### Registration Pattern - -At startup, adapters self-register into the runtime registry: - -```typescript -// In ccs.ts or initialization -registerTarget(new ClaudeAdapter()); -registerTarget(new DroidAdapter()); -registerTarget(new CodexAdapter()); - -// Later, when executing -const targetType = resolveTargetType(args, profileConfig); -const adapter = getTarget(targetType); - -await adapter.prepareCredentials(credentials); -const spawnArgs = adapter.buildArgs(profile, userArgs); -adapter.exec(spawnArgs, adapter.buildEnv(credentials, profileType)); -``` - -### Adding a New Target - -To add support for a new CLI (e.g., `newcli`): - -1. Create `src/targets/newcli-adapter.ts` implementing `TargetAdapter` -2. Implement each required method (detection, credential delivery, spawning) -3. Create `src/targets/newcli-detector.ts` for binary detection logic -4. Export from `src/targets/index.ts` -5. Register in `ccs.ts`: `registerTarget(new NewCliAdapter())` -6. Update `TargetType` union to include `'newcli'` - ---- - -## Monster File Splitting Methodology - -When splitting large files (500+ lines), follow this process: - -### Step 1: Analyze Structure - -Identify logical boundaries: -- Render sections in React components -- Handler groups in route files -- Related utility functions -- Constants and types - -### Step 2: Extract Types First - -```typescript -// types.ts -export interface ProviderEditorProps { - providerId: string; - onSave: (config: ProviderConfig) => void; -} - -export interface ModelMappingValues { - model: string; - endpoint: string; -} -``` - -### Step 3: Extract Utilities - -```typescript -// utils.ts -export function validateEndpoint(url: string): boolean { ... } -export function formatModelName(name: string): string { ... } -``` - -### Step 4: Extract Hooks - -```typescript -// hooks.ts -export function useProviderConfig(providerId: string) { ... } -export function useModelValidation() { ... } -``` - -### Step 5: Extract Subcomponents - -```typescript -// model-mapping-form.tsx -export function ModelMappingForm({ values, onChange }: Props) { ... } -``` - -### Step 6: Compose in Index - -```typescript -// index.tsx -import { ModelMappingForm } from './model-mapping-form'; -import { useProviderConfig } from './hooks'; -import type { ProviderEditorProps } from './types'; - -export function ProviderEditor({ providerId, onSave }: ProviderEditorProps) { - const config = useProviderConfig(providerId); - return ( -
- -
- ); -} - -// Re-export types for consumers -export type { ProviderEditorProps, ModelMappingValues } from './types'; -``` - ---- - -## TypeScript Standards - -### Strict Mode Required - -All projects use TypeScript strict mode: - -```json -{ - "compilerOptions": { - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true - } -} -``` - -### Type Annotations - -```typescript -// CORRECT: Explicit return types for public functions -export function detectProfile(args: string[]): DetectedProfile { ... } - -// CORRECT: Inferred types for internal functions -const formatName = (name: string) => name.trim().toLowerCase(); - -// INCORRECT: any type -function processData(data: any) { ... } // Use unknown or proper type -``` - -### Type Exports - -```typescript -// CORRECT: Use type keyword for type-only exports -export type { Config, Settings } from './config'; - -// CORRECT: Group type exports in barrel -export type { - PlatformInfo, - BinaryInfo, - DownloadProgress, -} from './types'; -``` - ---- - -## ESLint Rules (Enforced) - -| Rule | Level | Notes | -|------|-------|-------| -| `@typescript-eslint/no-unused-vars` | error | Ignore `_` prefix | -| `@typescript-eslint/no-explicit-any` | error | Use proper types | -| `@typescript-eslint/no-non-null-assertion` | error | No `!` assertions | -| `prefer-const` | error | Immutable by default | -| `no-var` | error | Use const/let | -| `eqeqeq` | error | Strict equality | -| `react-hooks/*` | recommended | (UI only) | - ---- - -## Terminal Output Standards - -### CCS Logging Standards - -- Use the shared logger from `src/services/logging/` for CCS-owned runtime diagnostics, request tracing, and structured events. -- Keep `utils/ui` and deliberate `console.log`/`console.error` output for user-facing CLI UX only. -- Redact secrets before persistence; never write raw tokens, cookies, API keys, or password hashes into CCS-owned logs. -- Persist CCS-owned logs only under `getCcsDir()/logs`; do not invent per-feature log roots. -- When adding dashboard polling or diagnostics routes, prevent them from recursively logging the log viewer itself. - -### ASCII Only - -```typescript -// CORRECT -console.log('[OK] Operation successful'); -console.log('[!] Warning message'); -console.log('[X] Error occurred'); -console.log('[i] Information'); - -// INCORRECT - NO EMOJIS -console.log('Operation successful'); // NO -console.log('Warning message'); // NO -``` - -### Color Handling - -```typescript -import { colors } from '../utils/ui'; - -// Colors are TTY-aware and respect NO_COLOR -console.log(colors.green('[OK]') + ' Operation successful'); -``` - -### Box Borders - -Use ASCII box drawing for error displays: - -``` -+=====================================+ -| [X] ERROR: Configuration failed | -| | -| Details: Unable to parse config | -+=====================================+ -``` - -### Cross-Platform Adapter Spawning - -When implementing target adapters, handle platform differences for binary spawning: - -```typescript -// Window shell detection (.cmd, .bat, .ps1 require shell) -const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(binaryPath); - -if (needsShell) { - // Escape arguments and use shell: true - const cmdString = [binaryPath, ...args].map(escapeShellArg).join(' '); - spawn(cmdString, { shell: true, stdio: 'inherit' }); -} else { - // Direct spawn (Unix-like, unshelled Windows executables) - spawn(binaryPath, args, { stdio: 'inherit' }); -} -``` - -This pattern is used in both `ClaudeAdapter` and `DroidAdapter` to ensure cross-platform consistency. - -For all Claude child-process launches (delegation, adapters, proxies, helper spawners), sanitize env before spawn: - -```typescript -const cleanEnv = stripClaudeCodeEnv(mergedEnv); // case-insensitive remove of CLAUDECODE -spawn(binaryPath, args, { env: cleanEnv, stdio: 'inherit' }); -``` - -This prevents Claude Code nested-session guard failures when CCS runs inside parent Claude sessions. - ---- - -## React Component Standards (UI) - -### Component Structure - -```typescript -// component-name.tsx - -// 1. Imports (grouped: react, external, internal, relative) -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { useProfiles } from '@/hooks'; -import { formatName } from './utils'; -import type { ComponentProps } from './types'; - -// 2. Types (if not in separate file) -interface Props { - id: string; - onSave: () => void; -} - -// 3. Component -export function ComponentName({ id, onSave }: Props) { - // Hooks first - const profiles = useProfiles(); - const [state, setState] = useState(null); - - // Handlers - const handleClick = () => { ... }; - - // Render - return ( ... ); -} -``` - -### Naming Conventions - -| Item | Convention | Example | -|------|------------|---------| -| Component files | kebab-case.tsx | `provider-editor.tsx` | -| Component exports | PascalCase | `ProviderEditor` | -| Hook files | use-*.ts | `use-profiles.ts` | -| Hook exports | useCamelCase | `useProfiles` | -| Utility files | kebab-case.ts | `path-utils.ts` | -| Utility exports | camelCase | `formatPath` | - ---- - -## Input State Persistence Patterns - -When building forms and editors that allow users to make changes, follow these patterns to prevent data loss. - -### Pattern 1: Key-Based Remounting - -**Use when**: Component has complex local state that should reset on prop changes. - -```typescript -// Parent component - -``` - -**Why**: Without `key`, React reuses the component instance. Local `useState` values persist even when props change, causing stale data bugs. - -### Pattern 2: Unsaved Changes Confirmation - -**Use when**: User might navigate away while editing. - -```typescript -// Parent tracks dirty state -const [editorHasChanges, setEditorHasChanges] = useState(false); -const [pendingSwitch, setPendingSwitch] = useState(null); - -// Child notifies parent of dirty state -useEffect(() => { - onHasChangesUpdate?.(computedHasChanges); -}, [computedHasChanges, onHasChangesUpdate]); - -// Intercept navigation -const handleSelect = (id: string) => { - if (editorHasChanges && currentId !== id) { - setPendingSwitch(id); // Show confirmation dialog - } else { - setCurrentId(id); - } -}; -``` - -**Flow**: -1. Child computes `hasChanges` from local state vs saved data -2. Child notifies parent via callback -3. Parent intercepts navigation when dirty -4. Show confirmation dialog: "Discard & Switch" or "Cancel" -5. On confirm: reset dirty state, then switch - -### Pattern 3: Auto-Save with Visual Feedback - -**Use when**: Simple inputs that should save immediately. - -```typescript -const [saved, setSaved] = useState(false); - -const handleBlur = async () => { - if (value !== savedValue) { - await saveToBackend(value); - setSaved(true); - setTimeout(() => setSaved(false), 2000); - } -}; - -return ( -
- - {saved && ( - - Saved - - )} -
-); -``` - -**When to use which**: -| Scenario | Pattern | -|----------|---------| -| Complex multi-field editor | Pattern 2 (confirmation dialog) | -| Simple single input | Pattern 3 (auto-save + feedback) | -| List item selection | Pattern 1 (key-based remount) + Pattern 2 | - ---- - -## Quality Gates - -### Pre-Commit Sequence +These standards describe current repository practice. Enforced configuration +and tests take precedence over prose. + +## Source of Truth + +| Contract | Authoritative source | +| --- | --- | +| TypeScript and CLI lint rules | [`eslint.config.mjs`](../eslint.config.mjs) | +| Formatting | [`.prettierrc`](../.prettierrc) | +| Root commands and test entry points | [`package.json`](../package.json) | +| UI commands and dependencies | [`ui/package.json`](../ui/package.json) | +| Commit syntax | [`commitlint.config.cjs`](../commitlint.config.cjs) | +| Release effects | [`.releaserc.cjs`](../.releaserc.cjs) | +| Repository workflow | [`CLAUDE.md`](../CLAUDE.md) and [`CONTRIBUTING.md`](../CONTRIBUTING.md) | + +Update this document when one of those contracts changes. Do not duplicate +exhaustive rule, command, target, or dependency lists here. + +## Design Priorities + +Apply these in order: + +1. **YAGNI**: build only behavior required by the accepted scope. +2. **KISS**: prefer a small local change over a new abstraction or dependency. +3. **DRY**: share stable domain behavior, not incidental similarity. + +Preserve public contracts unless the change intentionally updates them. Error +messages should explain recovery, and configuration changes should remain +CLI-complete with dashboard parity where the feature supports both surfaces. + +## Organization and Imports + +- Keep code inside the domain that owns its behavior. +- Split files at cohesive boundaries such as types, pure utilities, lifecycle + services, hooks, or view components. +- Follow nearby naming and import patterns. New TypeScript files normally use + descriptive kebab-case names. +- There is no universal maximum directory depth. +- Barrel exports are optional compatibility boundaries, not a repository-wide + requirement. Preserve an existing barrel when consumers depend on it; use a + direct import when that is the local pattern or the symbol is intentionally + private. +- Before creating a module, check whether the runtime, standard library, + installed dependencies, or an existing repository utility already owns the + behavior. + +## File Size + +Two different thresholds serve different purposes: + +- **200 lines is a review heuristic.** When a code file grows beyond roughly + 200 lines, check whether it contains multiple responsibilities. A cohesive + file may remain larger. +- **400 lines is the current CLI lint warning.** + [`eslint.config.mjs`](../eslint.config.mjs) configures `max-lines` as a + warning for `src/**/*.ts`, excluding blank lines and comments. + +Do not split mechanically to satisfy a number. Split when the result improves +ownership, testing, reuse, or reviewability. Preserve existing exports and +behavior when they are part of a consumed contract. + +## TypeScript and Linting + +The CLI uses strict TypeScript. Current enforced CLI rules include: + +- no explicit `any` +- no non-null assertions +- no unused variables, except names intentionally prefixed with `_` +- `prefer-const`, `no-var`, and strict equality + +Use `unknown` at untrusted boundaries and narrow it before use. Keep types close +to their owning domain; export types only when another module consumes them. + +New generic `throw new Error(...)` sites are blocked by the local +`ccs/no-new-throw-error` rule. Use the typed errors in +[`src/errors/error-types.ts`](../src/errors/error-types.ts) so +[`src/errors/error-handler.ts`](../src/errors/error-handler.ts) can map failures +consistently. The generated baseline in +[`eslint-rules/throw-error-baseline.json`](../eslint-rules/throw-error-baseline.json) +grandfathers existing sites; do not expand it to bypass a new error design. + +## Terminal and Process Behavior + +- CLI terminal output is ASCII only: `[OK]`, `[!]`, `[X]`, `[i]`. +- Respect `NO_COLOR` and TTY-aware output. +- Prefer argument arrays when spawning processes. When a platform wrapper + requires a shell, use the existing quoting and wrapper utilities rather than + interpolating untrusted input. +- Preserve target-specific stdio, signal, and exit-code behavior. The adapter + contract lives in + [`src/targets/target-adapter.ts`](../src/targets/target-adapter.ts). + +## Target Adapters + +Runtime target behavior is divided across: + +| Concern | Source | +| --- | --- | +| Target names, aliases, persistence | [`src/targets/target-metadata.ts`](../src/targets/target-metadata.ts) | +| Selection priority and flag parsing | [`src/targets/target-resolver.ts`](../src/targets/target-resolver.ts) | +| Adapter contract | [`src/targets/target-adapter.ts`](../src/targets/target-adapter.ts) | +| Registration and lookup | [`src/targets/target-registry.ts`](../src/targets/target-registry.ts) | +| Startup registration | [`src/ccs.ts`](../src/ccs.ts) | + +A target change should update metadata, implementation, registration, tests, +help text, and public documentation as applicable. Do not copy the target list +into new guides; link to metadata when maintainers need the current set. + +## Configuration and Test Isolation + +- Treat persisted environment values as strings. +- Route CCS paths through `getCcsDir()` in + [`src/utils/config-manager.ts`](../src/utils/config-manager.ts). +- Never run tests against a contributor's real `~/.ccs/` or `~/.claude/`. + Set `CCS_HOME` to a temporary directory. +- Preserve documented configuration and profile-resolution priority. Add a + focused test when changing precedence or fallback behavior. +- Treat Docker service `ccs` and network `ccs-net` as public contracts; see + [`CONTRIBUTING.md`](../CONTRIBUTING.md#if-you-change-the-docker-network-or-service-name). + +## Dashboard Code + +- Keep page orchestration in `ui/src/pages/`, reusable UI in + `ui/src/components/`, server-state access in `ui/src/hooks/`, and shared + helpers in `ui/src/lib/` when that matches the owning domain. +- Preserve unsaved input across background refreshes. Destructive replacement + of dirty state requires an explicit user action or confirmation. +- Use the existing API and query helpers before adding a new data-access layer. +- Keep accessibility, keyboard interaction, responsive layout, loading, + empty, and error states in scope for user-facing changes. +- Locale codes and normalization are owned by + [`ui/src/lib/locales.ts`](../ui/src/lib/locales.ts); translations are wired + through [`ui/src/lib/i18n.ts`](../ui/src/lib/i18n.ts). + +The UI has its own ESLint, TypeScript, Prettier, and Vitest configuration. +Verify commands against [`ui/package.json`](../ui/package.json). + +## Tests and Quality Gates + +The root TypeScript suites run with Bun's test runner. The dashboard uses +Vitest. Native shell and PowerShell probes cover platform-specific behavior. +See [`tests/README.md`](../tests/README.md) for ownership and commands. + +Run the smallest relevant test first, then the normal gate: ```bash -# Main project bun run format bun run lint:fix bun run validate -bun run validate:ci-parity +``` -# UI project (if changed) +Before review or merge confidence: + +```bash +bun run validate:ci-parity +``` + +For dashboard changes: + +```bash cd ui bun run format -bun run lint:fix bun run validate +bun run test:run ``` -### Validate Runs +Do not weaken or skip a failing check to make a change pass. If a full gate +cannot run, report the exact focused checks completed and the blocker. -| Project | Command | Checks | -|---------|---------|--------| -| Main | `bun run validate` | typecheck + lint + format:check + test:fast | -| UI | `bun run validate` | typecheck + lint + format:check | +## Commits and Releases ---- +Commitlint accepts conventional types defined in +[`commitlint.config.cjs`](../commitlint.config.cjs), with a 100-character header +limit. Use focused subjects such as: -## Conventional Commits - -All commits must follow conventional commit format: - -``` -(): +```text +fix(doctor): handle missing config +feat(cliproxy): add provider quota check +docs(contributing): clarify validation ``` -### Types +Semantic-release owns versions, changelog entries, tags, npm publishing, and +GitHub releases. Do not bump versions, tag, or publish manually. Release effects +for each branch and commit type are defined in +[`.releaserc.cjs`](../.releaserc.cjs). -| Type | When to Use | Version Bump | -|------|-------------|--------------| -| `feat` | New feature | MINOR | -| `fix` | Bug fix | PATCH | -| `perf` | Performance | PATCH | -| `docs` | Documentation | None | -| `style` | Formatting | None | -| `refactor` | Code restructure | None | -| `test` | Tests | None | -| `chore` | Maintenance | None | +## Documentation Triggers -### Examples +Update the owning guide when a change affects behavior, commands, setup, +architecture, security posture, public contracts, or future maintainer +decisions. Start at [`docs/README.md`](./README.md). Public CLI, provider, +configuration, installation, or workflow changes also require the matching page +in the separate `kaitranntt/ccs-docs` repository; see the docs index for +checkout guidance. -```bash -# Correct -git commit -m "feat(cliproxy): add OAuth token refresh" -git commit -m "fix(doctor): handle missing config gracefully" -git commit -m "refactor(ui): split provider-editor into modules" - -# Incorrect - REJECTED -git commit -m "added new feature" -git commit -m "Fixed bug" -``` - ---- - -## Anti-Patterns to Avoid - -### 1. God Files - -```typescript -// BAD: One file doing everything -// src/utils.ts (2000 lines with mixed concerns) - -// GOOD: Split by domain -// src/utils/ui/colors.ts -// src/utils/ui/boxes.ts -// src/utils/shell-executor.ts -// src/utils/config-manager.ts -``` - -### 2. Barrel Import Bypass - -```typescript -// BAD: Direct import bypassing barrel -import { detectPlatform } from '../cliproxy/platform-detector'; - -// GOOD: Import from domain barrel -import { detectPlatform } from '../cliproxy'; -``` - -### 3. Inline Everything - -```typescript -// BAD: Huge inline functions in components -function Component() { - const handleComplexOperation = () => { - // 100 lines of logic... - }; -} - -// GOOD: Extract to hooks or utilities -function Component() { - const { handleComplexOperation } = useComplexOperation(); -} -``` - -### 4. Type Duplication - -```typescript -// BAD: Same types defined in multiple files -// file1.ts -interface Config { ... } -// file2.ts -interface Config { ... } - -// GOOD: Single source of truth -// types/config.ts -export interface Config { ... } -``` - -### 5. Config Priority Pattern - -When resolving configuration from multiple sources, follow this priority order: - -```typescript -// proxy-config-resolver.ts pattern -// Priority: CLI flags > Environment variables > config.yaml > defaults - -const resolved = { - ...DEFAULT_CONFIG, // 4. Defaults (lowest) - ...yamlConfig, // 3. config.yaml - ...envConfig, // 2. Environment variables - ...cliFlags, // 1. CLI flags (highest) -}; -``` - -This pattern is used in: -- `src/cliproxy/proxy-config-resolver.ts` - Remote proxy config -- `src/config/unified-config-loader.ts` - Main config loading - ---- - -## 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 -- [System Architecture](./system-architecture/index.md) - Architecture diagrams -- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance +Prefer source links over copied trees and metrics. Remove stale sections rather +than leaving TODO markers. diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 417a5ec6..07769b16 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,673 +1,173 @@ # CCS Codebase Summary -Last Updated: 2026-05-18 - -Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, native Codex runtime target support, native Codex/Droid usage collectors, and models.dev-backed model pricing metadata. - -## Repository Structure - -``` -ccs/ -├── src/ # CLI TypeScript source -├── dist/ # Compiled JavaScript (npm package) -├── lib/ # Native shell scripts (bash, PowerShell) -├── ui/ # React dashboard application -│ ├── src/ # UI source code -│ └── dist/ # Built UI bundle -├── docker/ # Docker deployment configuration -│ ├── Dockerfile # Multi-stage build (bun 1.2.21, node:20-bookworm-slim) -│ ├── docker-compose.yml # Compose setup with resource limits, healthcheck -│ ├── entrypoint.sh # Entrypoint with privilege dropping, usage help -│ └── README.md # Docker deployment guide -├── tests/ # Test suites -├── docs/ # Documentation -└── assets/ # Static assets (logos, screenshots) -``` - ---- - -## CLI Source (`src/`) - -The main CLI is organized into domain-specific modules with barrel exports. - -### Directory Structure - -``` -src/ -├── ccs.ts # Main entry point & profile execution flow -├── bin/ # Dedicated runtime entrypoints -│ ├── droid-runtime.ts # Forces droid target for ccs-droid / ccsd package bins -│ ├── codex-runtime.ts # Forces codex target for ccs-codex / ccsx package bins -│ └── ccsxp-runtime.ts # Forces codex target + native cliproxy override for ccsxp -├── types/ # TypeScript type definitions -│ ├── index.ts # Barrel export (aggregates all types) -│ ├── cli.ts # CLI types (ParsedArgs, ExitCode) -│ ├── config.ts # Config types (Settings, EnvVars) -│ ├── delegation.ts # Delegation types (sessions, events) -│ ├── glmt.ts # Legacy transformer types (messages, transforms) -│ └── utils.ts # Utility types (ErrorCode, LogLevel) -│ -├── commands/ # CLI command handlers -│ ├── api-command/ # API profile subcommands (split facade + handlers) -│ │ ├── index.ts # API command facade/router -│ │ ├── shared.ts # Shared API arg parsing helpers -│ │ └── [subcommand files...] -│ ├── cliproxy-command.ts # CLIProxy subcommand handling -│ ├── config-command.ts # Config management commands -│ ├── config-image-analysis-command.ts # First-class ImageAnalysis config (NEW v7.34) -│ ├── named-command-router.ts # Reusable named-command dispatcher -│ ├── doctor-command.ts # Health diagnostics -│ ├── env-command.ts # Export shell env vars for third-party tools (v7.39) -│ ├── help-command.ts # Help text generation -│ ├── install-command.ts # Install/uninstall logic -│ ├── root-command-router.ts # Extracted top-level command dispatch from ccs.ts -│ ├── shell-completion-command.ts -│ ├── sync-command.ts # Symlink synchronization -│ ├── update-command.ts # Self-update logic -│ └── version-command.ts # Version display -│ -├── targets/ # Multi-target adapter system (NEW) -│ ├── index.ts # Barrel export -│ ├── target-adapter.ts # TargetAdapter interface contract -│ ├── target-registry.ts # Registry for runtime adapter lookup -│ ├── target-resolver.ts # Resolution logic (flag > runtime entrypoint / argv[0] > config) -│ ├── target-metadata.ts # Runtime vs persisted target metadata and alias lists -│ ├── target-runtime-compatibility.ts # Guardrails for target/profile combinations -│ ├── claude-adapter.ts # Claude Code CLI implementation -│ ├── droid-adapter.ts # Factory Droid CLI implementation -│ ├── codex-adapter.ts # Native Codex CLI implementation -│ ├── codex-detector.ts # Codex binary detection and capability probing -│ ├── droid-detector.ts # Droid binary detection & version checks -│ └── droid-config-manager.ts # ~/.factory/settings.json management -│ -├── auth/ # Authentication module -│ ├── index.ts # Barrel export -│ ├── commands/ # Auth-specific CLI commands -│ │ └── index.ts -│ ├── account-switcher.ts # Account switching logic -│ └── profile-detector.ts # Profile detection (474 lines) -│ -├── config/ # Configuration management -│ ├── index.ts # Barrel export -│ ├── unified-config-loader.ts # Central config loader (546 lines) -│ └── migration-manager.ts # Config migration logic -│ -├── proxy/ # OpenAI-compatible proxy runtime -│ ├── index.ts # Barrel export -│ ├── proxy-daemon-entry.ts # Daemon entrypoint -│ ├── proxy-daemon.ts # Lifecycle, health, and port binding -│ ├── proxy-port-resolver.ts # Adaptive per-profile port selection -│ ├── request-router.ts # Request-time profile/model routing -│ ├── profile-router.ts # Profile resolution helpers -│ ├── proxy-env.ts # Local runtime env construction -│ ├── routing-config.ts # Proxy routing config parsing -│ ├── upstream-url.ts # Upstream endpoint resolution -│ ├── proxy-daemon-state.ts # Persistent running-state metadata -│ ├── server/ # HTTP server and routes -│ └── transformers/ # Request and SSE translation -│ -├── channels/ # Official Claude channel integration -│ ├── official-channels-runtime.ts # Runtime gating, plugin specs, setup guidance -│ └── official-channels-store.ts # Claude channel token/env storage helpers -│ -├── cliproxy/ # CLIProxyAPI integration (heavily modularized) -│ ├── index.ts # Barrel export (137 lines, extensive) -│ ├── auth/ # OAuth handlers, token management -│ │ └── index.ts -│ ├── binary/ # Binary management -│ │ └── index.ts -│ ├── services/ # Service layer -│ │ └── index.ts -│ ├── cliproxy-executor.ts # Main executor (666 lines) -│ ├── config-generator.ts # Config file generation (531 lines) -│ ├── account-manager.ts # Account management (509 lines) -│ ├── quota-manager.ts # Hybrid quota management (NEW v7.14) -│ ├── quota-fetcher.ts # Provider quota API integration (NEW v7.14) -│ ├── platform-detector.ts # OS/arch detection -│ ├── binary-manager.ts # Binary download/update -│ ├── auth-handler.ts # Authentication handling -│ ├── model-catalog.ts # Provider model definitions -│ ├── model-config.ts # Model configuration -│ ├── codex-plan-compatibility.ts # Codex free/paid model fallback guardrails -│ ├── service-manager.ts # Background service -│ ├── proxy-detector.ts # Running proxy detection -│ ├── startup-lock.ts # Race condition prevention -│ ├── remote-proxy-client.ts # Remote proxy health checks (v7.1) -│ ├── proxy-config-resolver.ts # CLI/env/config merging (v7.1) -│ ├── types.ts # ResolvedProxyConfig for local/remote modes -│ └── [more files...] -│ -├── copilot/ # GitHub Copilot integration -│ ├── index.ts # Barrel export -│ └── copilot-package-manager.ts # Package management (515 lines) -│ -├── glmt/ # Legacy transformer internals kept for compatibility -│ ├── index.ts # Barrel export -│ ├── pipeline/ # Processing pipeline -│ │ └── index.ts -│ ├── glmt-proxy.ts # Legacy proxy runtime kept for internal compatibility -│ └── delta-accumulator.ts # Delta processing (484 lines) -│ -├── delegation/ # Task delegation & headless execution -│ ├── index.ts # Barrel export -│ ├── executor/ # Execution engine -│ └── [delegation files...] -│ -├── errors/ # Centralized error handling -│ ├── index.ts # Barrel export -│ ├── error-handler.ts # Main error handler -│ ├── exit-codes.ts # Exit code definitions -│ └── cleanup.ts # Cleanup logic -│ -├── management/ # Doctor diagnostics -│ ├── index.ts # Barrel export -│ ├── checks/ # Diagnostic checks -│ │ ├── index.ts -│ │ └── image-analysis-check.ts # ImageAnalysis runtime validation (NEW v7.34) -│ └── repair/ # Auto-repair logic -│ └── index.ts -│ -├── api/ # API utilities & services -│ ├── index.ts # Barrel export -│ └── services/ # API services -│ ├── index.ts -│ ├── profile-reader.ts -│ └── profile-writer.ts -│ -├── utils/ # Utilities (modularized into subdirs) -│ ├── index.ts # Barrel export -│ ├── ui/ # Terminal UI utilities -│ │ ├── index.ts -│ │ ├── boxes.ts # Box drawing -│ │ ├── colors.ts # Terminal colors -│ │ └── spinners.ts # Progress spinners -│ ├── websearch/ # Search tool integrations -│ │ └── index.ts -│ ├── hooks/ # Claude Code compatibility hooks (NEW v7.34) -│ │ ├── index.ts -│ │ ├── image-analyzer-hook-installer.ts -│ │ ├── image-analyzer-hook-configuration.ts -│ │ ├── image-analyzer-profile-hook-injector.ts -│ │ └── get-image-analysis-hook-env.ts -│ ├── image-analysis/ # ImageAnalysis MCP/runtime utilities (NEW v7.34) -│ │ ├── index.ts -│ │ ├── hook-installer.ts -│ │ ├── mcp-installer.ts -│ │ └── claude-tool-args.ts -│ └── [utility files...] -│ -└── web-server/ # Express web server (heavily modularized) - ├── index.ts # Server entry & barrel export - ├── routes/ # 15+ route handlers - │ ├── index.ts - │ ├── accounts-route.ts - │ ├── auth-route.ts - │ ├── channels-routes.ts - │ ├── cliproxy-route.ts - │ ├── copilot-route.ts - │ ├── doctor-route.ts - │ ├── glmt-route.ts - │ ├── health-route.ts - │ ├── profiles-route.ts - │ └── [more routes...] - ├── health/ # Health check system - │ └── index.ts - ├── usage/ # Usage analytics module (default Claude, CCS instances, native Codex/Droid, CLIProxy snapshots) - │ ├── index.ts - │ ├── handlers.ts # Request handlers (633 lines) - │ ├── aggregator.ts # Data aggregation (538 lines) - │ ├── codex-native-usage-collector.ts # Native Codex rollout JSONL collector - │ ├── droid-native-usage-collector.ts # Native Droid SQLite collector - │ └── data-aggregator.ts - ├── models-dev/ # Cached models.dev metadata/pricing registry integration - │ ├── registry-cache.ts - │ ├── pricing-resolver.ts - │ └── types.ts - ├── services/ # Shared services - │ └── index.ts - └── model-pricing.ts # Static pricing fallback + models.dev resolver -``` - -### Module Categories - -| Category | Directories | Purpose | -|----------|-------------|---------| -| Core | `commands/`, `errors/` | CLI commands, error handling | -| Targets | `bin/`, `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, Codex CLI, extensible) | -| Auth | `auth/`, `cliproxy/auth/` | Authentication across providers | -| Config | `config/`, `types/` | Configuration & type definitions | -| OpenAI Proxy | `proxy/` | Adaptive local OpenAI-compatible proxy runtime, profile routing, and SSE transforms | -| Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations plus retained legacy transformer internals | -| Quota | `cliproxy/quota-*.ts`, `account-manager.ts` | Hybrid quota management (v7.14) | -| Remote Proxy | `cliproxy/remote-*.ts`, `proxy-config-resolver.ts` | Remote CLIProxy support (v7.1) | -| Image Analysis | `utils/image-analysis/`, `utils/hooks/` | Vision model proxying (v7.34) | -| Services | `web-server/`, `api/` | HTTP server, API services | -| Utilities | `utils/`, `management/` | Helpers, diagnostics | - -### Account Context Metadata Flow - -- Source fields: `accounts..context_mode`, `accounts..context_group`, `accounts..continuity_mode` in `~/.ccs/config.yaml`. -- Runtime policy resolver: `src/auth/account-context.ts`. -- Metadata storage normalization: `src/auth/profile-registry.ts`. -- API write validation: `PUT /api/config` in `src/web-server/routes/config-routes.ts`. -- Rules: - - mode is isolation-first (`isolated` default, `shared` opt-in) - - shared mode requires non-empty valid `context_group` - - shared mode continuity depth is `standard` by default, optional `deeper` - - `context_group` is normalized (trim + lowercase + whitespace collapse to `-`) - - API route rejects `context_group`/`continuity_mode` when mode is not `shared` - - registry normalization drops malformed persisted `context_group` values - -### Shared Plugin Layout - -- Shared payload owner: `src/management/shared-manager.ts`. -- Profile entry point: `src/management/instance-manager.ts`. -- `plugins/marketplaces/`, `plugins/cache/`, and `installed_plugins.json` stay shared through the `~/.ccs/shared/` topology. -- `known_marketplaces.json` is now instance-local under `~/.ccs/instances//plugins/` so Claude Code validates `installLocation` against the active `CLAUDE_CONFIG_DIR` instead of a last-writer-wins shared file. - -### Official Claude Channels - -- Runtime contract lives in `src/channels/official-channels-runtime.ts` and is consumed from `src/ccs.ts`, `src/commands/config-channels-command.ts`, and `src/web-server/routes/channels-routes.ts`. -- Canonical config lives under `channels.*` in `~/.ccs/config.yaml`; legacy `discord_channels.*` remains read-compatible only when canonical fields are absent. - -### Native Codex Runtime Target - -- Dedicated runtime entrypoints: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts`, while `ccsxp` resolves through `src/bin/ccsxp-runtime.ts`; all three set `CCS_INTERNAL_ENTRY_TARGET=codex` before delegating to `src/targets/target-resolver.ts`. -- Native Codex passthrough: `ccsx --help`, `ccsx --version`, and known upstream Codex subcommands such as `ccsx exec ...`, `ccsx apply ...`, `ccsx mcp ...`, `ccsx plugin ...`, `ccsx completion ...`, and `ccsx resume ...` short-circuit before CCS profile detection; upstream aliases such as `ccsx e ...` and `ccsx a ...` are included. CCS-owned `ccsx auth`, `ccsx doctor`, and `ccsx update` remain reserved for CCS. -- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, preserves a valid custom `base_url`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process. -- Implicit Codex launches such as `ccs --target codex` and `ccsxp` use native Codex default mode even when the CCS default profile is a Claude account. Explicit unsupported profiles such as `ccs work --target codex` still fail fast with native-vs-pool guidance. -- `argv[0]` alias mapping still exists in `src/targets/target-resolver.ts` for same-binary/custom alias scenarios, but the built-in npm bins above do not depend on that map at runtime. -- Metadata boundary: `src/targets/target-metadata.ts` keeps Codex runtime-only in v1, so persisted default targets remain `claude | droid`. -- Compatibility guardrails: `src/targets/target-runtime-compatibility.ts` centralizes which profile types can execute on Codex. -- Adapter behavior: `src/targets/codex-adapter.ts` and `src/targets/codex-detector.ts` launch native Codex without rewriting `~/.codex/config.toml`; CCS-backed routes use transient `codex -c key=value` overrides and env-key injection. -- Dashboard control center: `src/web-server/services/codex-dashboard-service.ts`, `src/web-server/routes/codex-routes.ts`, `ui/src/pages/codex.tsx`, and `ui/src/components/compatible-cli/codex-*.tsx` expose a split-view Codex dashboard with guided editors for top-level settings, trust, profiles, providers, MCP servers, and feature flags plus a raw TOML fallback. -- Structured-edit boundary: guided Codex saves intentionally reserialize the whole TOML document, so comments/formatting are normalized and the raw editor remains the fidelity-preserving escape hatch. -- Follow-up behavior: structured saves refresh the raw snapshot immediately, refresh discards stale raw drafts, structured controls stay disabled while raw TOML is dirty/invalid/unreadable, project trust paths must be absolute or `~/...`, unsupported upstream top-level shapes are preserved instead of deleted, and feature flags can be reset to default. -- Supported Codex flows in v1: - - `default` - - CLIProxy provider `codex` - - settings/API profiles only when they resolve to a Codex CLIProxy bridge -- Telegram and Discord bot tokens are intentionally written into Claude-managed machine state under `~/.claude/channels//.env`, unless the official `*_STATE_DIR` environment override redirects that channel elsewhere. -- iMessage is tokenless, macOS-only, and still depends on Claude-side plugin install plus OS permissions. -- Auto-enable is gated on Bun availability, verified Claude Code v2.1.80+, verified `claude.ai` auth, native Claude `default/account` sessions, and per-channel setup readiness. -- The dashboard channels section surfaces Bun/version/auth/state-scope status from `/api/channels`, preserves token drafts when save-follow-up refresh fails, and keeps unsupported selected iMessage visible only so it can be turned off. - -### Structured Logging Domain - -- CCS-owned runtime logging now lives in `src/services/logging/`. -- The shared domain owns path resolution, redaction, rotation/pruning, buffered recent-entry reads, and the logger factory used by CLI/server/runtime code. -- Dashboard exposure lives in `src/web-server/routes/logs-routes.ts`, `src/web-server/services/logs-dashboard-service.ts`, and `src/web-server/middleware/request-logging-middleware.ts`. -- The native dashboard viewer lives at `ui/src/pages/logs.tsx` with supporting components under `ui/src/components/logs/` and hooks in `ui/src/hooks/use-logs.ts`. -- Legacy CLIProxy error files still exist under `~/.ccs/cliproxy/logs` and are surfaced as a labeled legacy source rather than the primary CCS logging model. - -### Target Adapter Module - -The targets module provides an extensible interface for dispatching profiles to different CLI implementations. - -**Key components:** - -1. **TargetAdapter Interface** - Contract that each CLI implementation must fulfill: - - binary detection - - credential preparation - - target-specific args/env construction - - process execution - - profile compatibility checks - -2. **Target Resolution** - Priority order: - - `--target ` flag (CLI argument) - - Explicit runtime entrypoint via `CCS_INTERNAL_ENTRY_TARGET` (used by `src/bin/droid-runtime.ts`, `src/bin/codex-runtime.ts`, and `src/bin/ccsxp-runtime.ts`) - - `argv[0]` detection for custom/same-binary runtime aliases - - Per-profile `target` field (from config.yaml) - - Default: `claude` - -3. **Implementations:** - - **ClaudeAdapter** - Wraps existing behavior; delivers credentials via environment variables - - **DroidAdapter** - New; writes to ~/.factory/settings.json and spawns with `-m custom:ccs-` flag - -4. **Registry** - Map-based lookup (O(1)) for registered adapters at runtime - -**Usage flow:** -``` -Profile resolution (existing) - ↓ -Target resolution (via resolver.ts) - ↓ -Get adapter from registry - ↓ -Prepare credentials (adapter.prepareCredentials) - ↓ -Build args & env (adapter.buildArgs, buildEnv) - ↓ -Spawn target CLI (adapter.exec) -``` - ---- - -## UI Source (`ui/src/`) - -The React dashboard organized by domain with barrel exports at every level. - -### Directory Structure - -``` -ui/src/ -├── components/ -│ ├── index.ts # Main barrel (aggregates all domains) -│ │ -│ ├── account/ # Account management -│ │ ├── index.ts # Barrel export -│ │ ├── accounts-table.tsx -│ │ ├── add-account-dialog.tsx -│ │ └── flow-viz/ # Flow visualization (split from 1,144-line file) -│ │ ├── index.tsx # Main component (200 lines) -│ │ ├── account-card.tsx -│ │ ├── account-card-stats.tsx -│ │ ├── connection-timeline.tsx -│ │ ├── flow-paths.tsx -│ │ ├── flow-viz-header.tsx -│ │ ├── provider-card.tsx -│ │ ├── hooks.ts -│ │ ├── types.ts -│ │ ├── utils.ts -│ │ ├── path-utils.ts -│ │ └── zone-utils.ts -│ │ -│ ├── analytics/ # Usage charts, stats cards -│ │ ├── index.ts -│ │ ├── cliproxy-stats-card.tsx -│ │ └── usage-trend-chart.tsx -│ │ -│ ├── cliproxy/ # CLIProxy configuration -│ │ ├── index.ts # Barrel export (30 lines) -│ │ ├── provider-editor/ # Split from 921-line file -│ │ │ ├── index.tsx # Main editor (250 lines) -│ │ │ └── [13 focused modules] -│ │ ├── config/ # YAML editor, file tree -│ │ │ ├── config-split-view.tsx -│ │ │ ├── diff-dialog.tsx -│ │ │ ├── file-tree.tsx -│ │ │ └── yaml-editor.tsx -│ │ ├── overview/ # Health lists, preferences -│ │ │ ├── credential-health-list.tsx -│ │ │ ├── model-preferences-grid.tsx -│ │ │ └── quick-stats-row.tsx -│ │ └── [7 top-level component files] -│ │ -│ ├── copilot/ # Copilot settings -│ │ ├── index.ts -│ │ └── config-form/ # Split from 846-line file -│ │ └── [13 focused modules] -│ │ -│ ├── health/ # System health gauges -│ │ └── index.ts -│ │ -│ ├── layout/ # App structure -│ │ ├── index.ts -│ │ ├── sidebar.tsx -│ │ └── footer.tsx -│ │ -│ ├── monitoring/ # Error logs, auth monitor -│ │ ├── index.ts -│ │ ├── proxy-status-widget.tsx -│ │ ├── auth-monitor/ # Split from 465-line file (8 files) -│ │ │ ├── index.tsx # Main component -│ │ │ ├── types.ts -│ │ │ ├── hooks.ts -│ │ │ ├── utils.ts -│ │ │ └── components/ -│ │ │ ├── live-pulse.tsx -│ │ │ ├── inline-stats-badge.tsx -│ │ │ ├── provider-card.tsx -│ │ │ └── summary-card.tsx -│ │ └── error-logs/ # Split from 617-line file -│ │ └── [6 focused modules] -│ │ -│ ├── profiles/ # Profile management -│ │ ├── index.ts -│ │ ├── profile-dialog.tsx -│ │ ├── profile-create-dialog.tsx -│ │ └── editor/ # Split from 531-line file -│ │ └── [10 focused modules] -│ │ -│ ├── setup/ # Quick setup wizard -│ │ ├── index.ts -│ │ └── wizard/ # Step-based wizard -│ │ ├── index.tsx -│ │ └── steps/ -│ │ -│ ├── shared/ # Reusable components (19 components) -│ │ ├── index.ts -│ │ ├── ccs-logo.tsx -│ │ ├── code-editor.tsx -│ │ ├── confirm-dialog.tsx -│ │ ├── provider-icon.tsx -│ │ ├── settings-dialog.tsx -│ │ ├── stat-card.tsx -│ │ └── [13 more shared components] -│ │ -│ └── ui/ # shadcn/ui primitives -│ ├── button.tsx -│ ├── card.tsx -│ ├── dialog.tsx -│ ├── searchable-select.tsx # Shared searchable combobox for model pickers -│ ├── sidebar.tsx # Custom sidebar (674 lines) -│ └── [UI primitives...] -│ -├── contexts/ # React Contexts -│ ├── privacy-context.tsx -│ ├── theme-context.tsx -│ └── websocket-context.tsx -│ -├── hooks/ # Custom hooks (domain-prefixed) -│ ├── use-accounts.ts -│ ├── use-cliproxy.ts -│ ├── use-health.ts -│ ├── use-profiles.ts -│ ├── use-websocket.ts -│ └── [more hooks...] -│ -├── lib/ # Utilities -│ ├── api.ts # API client -│ ├── model-catalogs.ts # Model definitions -│ └── utils.ts # Helper functions -│ -├── pages/ # Page components (lazy-loaded) -│ ├── analytics/ # Split from 420-line file (8 files) -│ │ ├── index.tsx # Main layout -│ │ ├── types.ts # Analytics types -│ │ ├── hooks.ts # Data fetching hooks -│ │ ├── utils.ts # Utility functions -│ │ └── components/ -│ │ ├── analytics-header.tsx -│ │ ├── analytics-skeleton.tsx -│ │ ├── charts-grid.tsx -│ │ └── cost-by-model-card.tsx -│ ├── settings/ # Split from 1,781-line file (20 files) -│ │ ├── index.tsx # Main layout with lazy loading -│ │ ├── context.tsx # Settings provider wrapper -│ │ ├── settings-context.ts -│ │ ├── types.ts -│ │ ├── hooks.ts # Legacy re-exports -│ │ ├── hooks/ -│ │ │ ├── index.ts -│ │ │ ├── context-hooks.ts -│ │ │ ├── use-official-channels-config.ts -│ │ │ ├── use-settings-tab.ts -│ │ │ ├── use-proxy-config.ts -│ │ │ ├── use-websearch-config.ts -│ │ │ ├── use-globalenv-config.ts -│ │ │ └── use-raw-config.ts -│ │ ├── components/ -│ │ │ ├── section-skeleton.tsx -│ │ │ └── tab-navigation.tsx -│ │ └── sections/ -│ │ ├── channels.tsx -│ │ ├── globalenv-section.tsx -│ │ ├── websearch/ -│ │ │ ├── index.tsx -│ │ │ └── provider-card.tsx -│ │ └── proxy/ -│ │ ├── index.tsx -│ │ ├── local-proxy-card.tsx -│ │ └── remote-proxy-card.tsx -│ ├── api.tsx # API profiles page (350 lines) -│ ├── cliproxy.tsx # CLIProxy page (405 lines) -│ ├── copilot.tsx # Copilot page (295 lines) -│ └── health.tsx # Health page (256 lines) -│ -└── providers/ # Context providers - └── websocket-provider.tsx -``` - -### Component Statistics - -| Domain | Components | Subdirs | Split Files | -|--------|------------|---------|-------------| -| account | 3 | flow-viz (12 files) | 1 monster split | -| analytics | 3 | - | - | -| cliproxy | 10 | provider-editor, config, overview | 1 monster split | -| copilot | 2 | config-form (13 files) | 1 monster split | -| health | 2 | - | - | -| layout | 3 | - | - | -| monitoring | 3 | auth-monitor (8 files), error-logs (6 files) | 2 monster splits | -| profiles | 4 | editor (10 files) | 1 monster split | -| setup | 2 | wizard/steps | - | -| shared | 19 | - | - | -| **Total** | **51+** | **10 subdirs** | **7 splits** | - -### Page Statistics - -| Page | Structure | Files | Notes | -|------|-----------|-------|-------| -| analytics | Directory | 8 | Split 2025-12-21 | -| settings | Directory | 20 | Split 2025-12-21, lazy-loaded sections | -| api | Single file | 1 | 350 lines | -| cliproxy | Single file | 1 | 405 lines | -| copilot | Single file | 1 | 295 lines | -| health | Single file | 1 | 256 lines | - ---- - -## Key File Metrics - -### Largest Files (Acceptable Exceptions) - -**CLI (`src/`):** - -| File | Lines | Status | -|------|-------|--------| -| model-pricing.ts | 920 | Static pricing fallback and resolver entrypoint | -| glmt-proxy.ts | 675 | Legacy internal compatibility path - acceptable for now | -| cliproxy-executor.ts | 666 | Core logic - acceptable | -| cliproxy-command.ts | 634 | Could split if needed | -| usage/handlers.ts | 633 | Could split if needed | -| ccs.ts | 596 | Entry point - acceptable | -| unified-config-loader.ts | 546 | Complex - acceptable | - -**UI (`ui/src/`):** - -| File | Lines | Status | -|------|-------|--------| -| components/ui/sidebar.tsx | 674 | shadcn - acceptable | -| pages/cliproxy.tsx | 405 | Acceptable | -| pages/api.tsx | 350 | Acceptable | -| pages/copilot.tsx | 295 | Acceptable | -| pages/health.tsx | 256 | Acceptable | - -**Split Files (Completed):** - -| Original | Lines | New Location | Files | -|----------|-------|--------------|-------| -| pages/settings.tsx | 1,781 | pages/settings/ | 20 | -| pages/analytics.tsx | 420 | pages/analytics/ | 8 | -| monitoring/auth-monitor.tsx | 465 | monitoring/auth-monitor/ | 8 | - ---- - -## Import Patterns - -### Standard Import Path - -```typescript -// From any file in src/ -import { Config, Settings } from '../types'; -import { execClaudeWithCLIProxy } from '../cliproxy'; -import { handleError } from '../errors'; - -// From any file in ui/src/ -import { AccountsTable, ProviderIcon, StatCard } from '@/components'; -import { useAccounts, useProfiles } from '@/hooks'; -``` - -### Barrel Export Pattern - -Every domain directory has an `index.ts` that aggregates exports: - -```typescript -// ui/src/components/cliproxy/index.ts -export { CategorizedModelSelector } from './categorized-model-selector'; -export { CliproxyDialog } from './cliproxy-dialog'; -// ... - -// From subdirectories -export { ProviderEditor } from './provider-editor'; -export type { ProviderEditorProps } from './provider-editor'; -``` - ---- - -## Test Structure - -``` -tests/ -├── unit/ # Unit tests (7 core test files) -│ ├── data-aggregator.test.ts -│ ├── cliproxy/ -│ │ └── remote-proxy-client.test.ts -│ ├── commands/ -│ │ └── env-command.test.ts -│ ├── jsonl-parser.test.ts -│ ├── model-pricing.test.ts -│ ├── unified-config.test.ts -│ └── mcp-manager.test.ts -├── integration/ # Integration tests -├── native/ # Native install tests -│ ├── linux/ -│ ├── macos/ -│ └── windows/ -├── npm/ # npm package tests -├── shared/ # Shared test utilities -└── README.md -``` - -### Test Metrics - -| Metric | Value | -|--------|-------| -| Total Tests | 1440 | -| Passing | 1440 | -| Skipped | 6 | -| Failed | 0 | -| Coverage Threshold | 90% | -| Test Files | 41 | - ---- - -## Build Outputs - -| Output | Source | Purpose | -|--------|--------|---------| -| `dist/` | `src/` | npm package (CLI) | -| `dist/ui/` | `ui/src/` | Built React app (served by Express) | -| `lib/` | N/A | Native shell scripts | - ---- - -## Related Documentation - -- [Code Standards](./code-standards.md) - Modularization patterns, file size rules -- [System Architecture](./system-architecture/index.md) - High-level architecture diagrams -- [Project Roadmap](./project-roadmap.md) - Modularization phases and future work -- [WebSearch](./websearch.md) - WebSearch feature documentation -- [Image Analysis](./image-analysis.md) - First-class ImageAnalysis runtime documentation -- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance +CCS is a TypeScript/Bun CLI and local React dashboard for selecting profiles, +preparing provider credentials, and launching Claude Code, Codex CLI, Factory +Droid, and compatible proxy-backed workflows. This page maps stable ownership; +source and tests remain the implementation truth. + +## Runtime Surfaces + +| Surface | Entry point | Responsibility | +| --- | --- | --- | +| `ccs` CLI | [`src/ccs.ts`](../src/ccs.ts) | Parse global input, register targets, resolve profiles, dispatch commands or runtimes | +| Runtime aliases | [`package.json`](../package.json) | Expose packaged binaries such as `ccs`, `ccsx`, and target-specific entry points | +| Command handlers | [`src/commands/`](../src/commands/) | Implement CCS-owned command families and help text | +| Local web server | [`src/web-server/index.ts`](../src/web-server/index.ts) | Serve configuration APIs, WebSocket updates, and the built dashboard | +| Dashboard | [`ui/src/main.tsx`](../ui/src/main.tsx) | Mount the React application used by `ccs config` | +| Bootstrap wrappers | [`lib/`](../lib/) | Start the packaged CLI on Unix and Windows | + +Detailed user workflows and command reference live at +[docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca). Do not infer current flags +from this overview; inspect the owning handler and its tests. + +## CLI Domain Ownership + +| Domain | Main source | Owns | +| --- | --- | --- | +| Command routing | [`src/commands/`](../src/commands/) | Command parsing, command-specific help, setup, doctor, config, Docker, and management flows | +| Profile dispatch | [`src/dispatcher/`](../src/dispatcher/) | Resolve a launch into target-specific execution flows | +| Runtime targets | [`src/targets/`](../src/targets/) | Target metadata, resolution, adapters, binary detection, and execution | +| Configuration | [`src/config/`](../src/config/) | Schema validation, loading, and normalized configuration access | +| CLIProxy | [`src/cliproxy/`](../src/cliproxy/) | Provider auth, configuration, routing, quota, lifecycle, and execution | +| Authentication | [`src/auth/`](../src/auth/) and [`src/codex-auth/`](../src/codex-auth/) | Account and OAuth-oriented authentication flows | +| Channels | [`src/channels/`](../src/channels/) | Official Claude channel readiness and configuration | +| Local proxy | [`src/proxy/`](../src/proxy/) | Anthropic-compatible proxy server and request/response transformers | +| Web API | [`src/api/`](../src/api/) and [`src/web-server/`](../src/web-server/) | Local dashboard services, routes, middleware, health, usage, and live updates | +| Shared services | [`src/services/`](../src/services/) | Cross-cutting runtime services, including structured logging | +| Errors | [`src/errors/`](../src/errors/) | Typed error taxonomy, handling, and exit behavior | +| Utilities | [`src/utils/`](../src/utils/) | CCS path handling and bounded shared helpers for browser, hooks, web search, image analysis, and UI support | +| Compatibility | [`src/glmt/`](../src/glmt/), [`src/copilot/`](../src/copilot/), [`src/cursor/`](../src/cursor/) | Legacy translation and integration-specific behavior | +| Packaging/runtime bins | [`src/bin/`](../src/bin/) | Target-specific packaged entry points | + +The table is intentionally domain-level. Use repository search and nearby tests +to find the current implementation instead of relying on a recursive file tree. + +## Profile and Target Dispatch + +Profile resolution follows the repository contract in +[`CLAUDE.md`](../CLAUDE.md): + +1. built-in CLIProxy providers +2. user-defined `config.cliproxy` providers +3. settings-based `config.profiles` +4. account-based `profiles.json` entries with isolated `CLAUDE_CONFIG_DIR` + +Target selection is a separate layer: + +| Concern | Source | +| --- | --- | +| Target names, aliases, and persistence | [`src/targets/target-metadata.ts`](../src/targets/target-metadata.ts) | +| Selection priority and `--target` parsing | [`src/targets/target-resolver.ts`](../src/targets/target-resolver.ts) | +| Adapter interface | [`src/targets/target-adapter.ts`](../src/targets/target-adapter.ts) | +| Adapter registry | [`src/targets/target-registry.ts`](../src/targets/target-registry.ts) | +| Claude implementation | [`src/targets/claude-adapter.ts`](../src/targets/claude-adapter.ts) | +| Droid implementation | [`src/targets/droid-adapter.ts`](../src/targets/droid-adapter.ts) | +| Codex implementation | [`src/targets/codex-adapter.ts`](../src/targets/codex-adapter.ts) | + +All targets currently marked `persistedTarget` in target metadata are valid +profile targets. Runtime aliases are also derived from that metadata. Link to +the source instead of maintaining a second target list here. + +At startup, [`src/ccs.ts`](../src/ccs.ts) registers adapters. The dispatcher +resolves the profile and target, asks the adapter to prepare credentials and +arguments, then executes the selected CLI. Target-specific behavior belongs in +the adapter or its supporting target module, not in generic command routing. + +## Configuration and Local State + +[`src/utils/config-manager.ts`](../src/utils/config-manager.ts) owns CCS home +resolution and honors `CCS_HOME`. Tests must point `CCS_HOME` at a temporary +directory and must not touch a contributor's real `~/.ccs/` or `~/.claude/`. + +Configuration schemas and loaders live under [`src/config/`](../src/config/). +Provider- and CLIProxy-specific persistence stays under +[`src/cliproxy/config/`](../src/cliproxy/config/). Values written to settings +environment maps must remain strings. + +Some integrations intentionally write state owned by the launched runtime, such +as Claude channel configuration or Codex configuration. Verify those boundaries +in the owning module and tests before changing paths, permissions, or cleanup. + +## Dashboard Ownership + +The dashboard is a separate TypeScript package under [`ui/`](../ui/): + +| Area | Path | Responsibility | +| --- | --- | --- | +| Pages | [`ui/src/pages/`](../ui/src/pages/) | Route-level orchestration and settings sections | +| Components | [`ui/src/components/`](../ui/src/components/) | Domain UI and shared primitives | +| Hooks | [`ui/src/hooks/`](../ui/src/hooks/) | Server-state access and reusable UI behavior | +| Contexts/providers | [`ui/src/contexts/`](../ui/src/contexts/) and [`ui/src/providers/`](../ui/src/providers/) | Cross-page client state | +| Libraries | [`ui/src/lib/`](../ui/src/lib/) | API client, localization, catalogs, formatting, and helpers | + +The browser communicates with routes and services under +[`src/web-server/`](../src/web-server/). When a configuration feature supports +both surfaces, keep CLI and dashboard behavior aligned. + +Localization codes, normalization, persistence, and fallback are owned by +[`ui/src/lib/locales.ts`](../ui/src/lib/locales.ts). Translation resources and +i18next wiring live in [`ui/src/lib/i18n.ts`](../ui/src/lib/i18n.ts). + +## Logging and Operational Data + +Structured CCS logging lives in +[`src/services/logging/`](../src/services/logging/). Dashboard log routes and +services live under [`src/web-server/`](../src/web-server/), with UI consumers +under [`ui/src/components/logs/`](../ui/src/components/logs/) and the matching +page and hooks. + +Keep secrets and raw credentials out of logs. Treat legacy CLIProxy log files as +a distinct source rather than folding them into CCS-owned structured logs. + +## Tests + +Root TypeScript tests run with Bun's test runner. Bucket selection is implemented +by [`scripts/run-test-bucket.js`](../scripts/run-test-bucket.js). The dashboard +uses Vitest as configured in [`ui/package.json`](../ui/package.json). + +| Coverage area | Location | +| --- | --- | +| Focused module behavior | [`tests/unit/`](../tests/unit/) and colocated `src/**/__tests__/` | +| Cross-module behavior | [`tests/integration/`](../tests/integration/) | +| CLI end-to-end behavior | [`tests/e2e/`](../tests/e2e/) | +| Package installation and exports | [`tests/npm/`](../tests/npm/) | +| Shell and platform behavior | [`tests/native/`](../tests/native/) | +| Docker public contracts | [`tests/docker/`](../tests/docker/) | +| Documentation checks | [`tests/docs/`](../tests/docs/) | +| Dashboard behavior | [`ui/tests/`](../ui/tests/) and colocated UI tests | + +Commands and bucket behavior are documented in +[`tests/README.md`](../tests/README.md) and defined in +[`package.json`](../package.json). Avoid copying test counts or pass totals into +evergreen documentation. + +## Build and Release + +| Output or process | Source of truth | +| --- | --- | +| CLI compilation into `dist/` | root scripts in [`package.json`](../package.json) | +| Dashboard build into `dist/ui/` | UI and root build scripts | +| Bundle verification | [`scripts/verify-bundle.js`](../scripts/verify-bundle.js) | +| Local CI-equivalent gate | [`scripts/ci-parity-gate.sh`](../scripts/ci-parity-gate.sh) | +| Commit policy | [`commitlint.config.cjs`](../commitlint.config.cjs) | +| Branch-aware releases | [`.releaserc.cjs`](../.releaserc.cjs) | +| GitHub automation | [`.github/workflows/`](../.github/workflows/) | + +Semantic-release owns versions, changelog updates, tags, npm publication, and +GitHub releases. Generated `dist/` contents and release artifacts are outputs, +not architectural source. + +## Documentation Map + +- [Maintainer docs index](./README.md) +- [Code standards](./code-standards.md) +- [System architecture](./system-architecture/index.md) +- [Project roadmap](./project-roadmap.md) +- [Dashboard i18n](./i18n-dashboard.md) +- [OpenAI-compatible provider routing](./openai-compatible-providers.md) +- [Image analysis user guide](https://docs.ccs.kaitran.ca/features/ai/image-analysis) +- [AI agent guide](../CLAUDE.md) +- [Contributor guide](../CONTRIBUTING.md) + +Update this summary only when domain ownership, stable entry points, build +boundaries, or truth sources change. diff --git a/docs/codex-auth.md b/docs/codex-auth.md index d25420ba..827493c4 100644 --- a/docs/codex-auth.md +++ b/docs/codex-auth.md @@ -1,217 +1,73 @@ -# Codex Auth Profile Isolation (`ccsx auth`) +# Codex Auth Developer Contract -Run two Codex accounts simultaneously — one per terminal — with full auth isolation. +The canonical [Codex Adapter guide](https://docs.ccs.kaitran.ca/features/workflow/codex-adapter) +owns user setup. This local contract documents active `ccsx auth` invariants +that contributors and operators must preserve. -## Why +## Command Surface -Codex stores its OAuth credentials in a single directory (`~/.codex/`). When you run two -`codex` sessions in separate terminals, they both write to the same `auth.json`. A token -refresh in one session overwrites the other's credentials. +`ccsx auth` owns `create`, `login`, `switch`, `use`, `show`, `remove`, and +`import-default`. Keep syntax and option changes sourced from +[`src/codex-auth/codex-auth-help.ts`](../src/codex-auth/codex-auth-help.ts) +rather than copying a long command reference here. -`ccsx auth` solves this by giving each account its own profile directory under -`~/.ccs/codex-instances//`. Each profile holds its own `auth.json` and -`history.jsonl`, plus its own session data. Shared `config.toml`, `agents/`, `skills/`, -and plugin cache resources come from `~/.codex/` so configuration and installed plugin -skills stay in sync across profiles. +- `create ` is idempotent and starts native `codex login` for new + profiles. `--force` repairs shared resources without replacing `auth.json`. +- `switch ` changes the persistent registry default. +- `use ` emits only shell-evaluable `CODEX_HOME` and + `CCS_CODEX_PROFILE` assignments to stdout. It affects the current shell after + `eval`/`source`; diagnostics stay on stderr. +- `ccsx ` launches a named profile directly without changing the + persistent default. -## Quick start (4 commands) +## Import Safety -```bash -# Create and authenticate two profiles -ccsx auth create work # creates ~/.ccs/codex-instances/work/ and prompts for login -ccsx auth create personal # same for personal account +`import-default ` imports native `~/.codex/auth.json` without deleting the +source. The implementation in +[`import-default-command.ts`](../src/codex-auth/commands/import-default-command.ts) +must continue to: -# Activate per terminal (ephemeral — only this shell) -# Terminal A: -eval "$(ccsx auth use work)" -codex +- refuse import while a current-user Codex process may be refreshing tokens, + unless the operator explicitly accepts the race with + `--force-while-running`; +- retry and validate JSON/JWT shape, reject CLIProxy auth-file formats, and fail + without registering a profile when a torn write persists; +- write the destination atomically with private permissions; +- omit history and sessions unless `--with-history` is requested; +- refuse an existing profile unless `--force` is used, and preserve its current + `auth.json` as `auth.json.bak-` before overwrite. -# Terminal B: -eval "$(ccsx auth use personal)" -codex +## Storage And Cross-Platform Fallback -# Or launch a named profile directly through ccsx -ccsx work -``` - -## Two-terminal example - -```bash -# Terminal A — work account -eval "$(ccsx auth use work)" -codex # runs with CODEX_HOME=~/.ccs/codex-instances/work - -# Terminal B — personal account (simultaneously) -eval "$(ccsx auth use personal)" -codex # runs with CODEX_HOME=~/.ccs/codex-instances/personal - -# No token clobbering. Each session refreshes its own auth.json only. -``` - -## Command reference - -| Command | Description | -|---------|-------------| -| `ccsx auth create ` | Create profile dir + auto-login | -| `ccsx ` | Launch a named Codex auth profile | -| `ccsx auth login ` | (Re-)authenticate an existing profile | -| `ccsx auth switch ` | Set the persistent default profile for future `ccsx` launches | -| `ccsx auth use ` | Emit shell exports for this shell only (use with `eval`) | -| `ccsx auth show [name]` | List all profiles or show details for one | -| `ccsx auth remove ` | Delete profile dir + registry entry | -| `ccsx auth import-default ` | Migrate legacy `~/.codex/auth.json` into a new profile | - -## Persistent vs ephemeral switching - -| Method | Scope | How | -|--------|-------|-----| -| `ccsx ` | One launch | Resolves `` from the Codex profile registry | -| `ccsx auth switch ` | Future `ccsx` launches | Writes to `~/.ccs/codex-profiles.yaml` | -| `eval "$(ccsx auth use )"` | Current shell only | Sets `CODEX_HOME` + `CCS_CODEX_PROFILE` in your shell | - -Native `codex` shells only see the persistent default when launched through the `ccsx` -Codex runtime. For an already-open shell or a plain native `codex` binary, use `auth use`. - -Do not use `ccs persist codex` for Claude Code or the Claude Code Extension. That path -would persist Claude settings that send Claude traffic through the Codex translator. CCS -blocks Codex CLIProxy profiles from Claude extension setup; use `ccsxp` or -`ccs codex --target codex` for ChatGPT/Codex subscriptions. If old settings were already -persisted, clear them with: - -```bash -ccs persist default --yes -``` - -The command prints a config receipt after writing settings: cleared managed keys, -written managed keys, whether and where any `/api/provider/codex` translator URL -remains, and the native Codex targets to use next. - -Shell syntax for `use`: - -```bash -# bash / zsh -eval "$(ccsx auth use work)" - -# fish -ccsx auth use work | source - -# PowerShell -ccsx auth use work | Invoke-Expression -``` - -## Migration from `~/.codex` - -If you already have a logged-in session in `~/.codex/auth.json`, import it without -disturbing the original: - -```bash -# Auth only (default — recommended) -ccsx auth import-default legacy - -# Auth + history + sessions (opt-in) -ccsx auth import-default legacy --with-history - -# Make it the default -ccsx auth switch legacy -``` - -The source `~/.codex/` directory is **never modified**. If `import-default` is not run, -`codex` continues to work exactly as before. - -### Torn-write safety - -Codex writes `auth.json` with truncate+write (not atomic rename). Running -`import-default` while a token refresh is in flight can produce a corrupt copy. -The command detects a running `codex` process via `pgrep` and refuses unless you -pass `--force-while-running`. The safest approach is to quit Codex before -importing. - -## Dashboard - -The CCS dashboard shows active profile metadata at the **Auth Profiles** tab on the -Codex page: - -- Profile name and whether it is the current default -- Decoded email address (from `id_token` — no signature verification; display only) -- Plan tier (Plus, Pro, Free) when present in the token -- Last-used timestamp - -No OAuth tokens are ever returned by the API endpoint or shown in the UI. - -## Profile disk layout - -``` +```text ~/.ccs/ -├── codex-profiles.yaml # Registry: version, default, profiles metadata -└── codex-instances/ - └── / - ├── auth.json # OAuth credentials (Codex writes here) - ├── history.jsonl # Per-profile prompt history (optional) - ├── sessions/ # Per-profile chat session dirs (optional) - ├── config.toml -> ~/.codex/config.toml (symlink — shared) - ├── agents/ -> ~/.codex/agents/ (symlink — shared) - ├── skills/ -> ~/.codex/skills/ (symlink — shared) - └── plugins/ # Profile-local parent; may hold local metadata - └── cache/ -> ~/.codex/plugins/cache/ (symlink — shared) - -~/.codex/ -├── config.toml # Single shared model/provider config -├── agents/ # Shared Codex agent role config files -├── skills/ # Shared Codex skills -└── plugins/ - └── cache/ # Shared installed plugin payloads +├── codex-profiles.yaml +└── codex-instances// + ├── auth.json, history.jsonl, sessions/ # profile-local + ├── config.toml -> ~/.codex/config.toml + ├── agents/ -> ~/.codex/agents/ + ├── skills/ -> ~/.codex/skills/ + └── plugins/ + └── cache/ -> ~/.codex/plugins/cache/ ``` -Only `plugins/cache/` is shared. The profile's parent `plugins/` directory remains a -real local directory so Codex can keep profile-specific plugin metadata beside the -shared cache. +The `plugins/` parent stays profile-local. Shared config, resources, and plugin +cache repair must preserve existing profile-local content. On Windows or other +systems where symlinks are unavailable, CCS copies missing shared content into +the profile and warns that later upstream edits will not propagate +automatically. See +[`codex-config-symlink.ts`](../src/codex-auth/codex-config-symlink.ts), +[`codex-profile-resources.ts`](../src/codex-auth/codex-profile-resources.ts), +and +[`codex-profile-plugin-cache.ts`](../src/codex-auth/codex-profile-plugin-cache.ts). -`ccsx auth create ` and direct `ccsx ` launches repair these links -idempotently before Codex starts. This keeps relative entries such as -`agents/foo.toml` valid and prevents stale first-launch skill warnings after a plugin -install or update changes the cache. +## `ccsx` And `ccsxp` Isolation -## Caveats +`ccsx auth` applies only to native Codex profiles. `ccsxp` ignores +`CCS_CODEX_PROFILE`, uses native `~/.codex` history by default, and routes +through its separate CLIProxy Codex pool. `CCSXP_CODEX_HOME` is its explicit +home override. Never make a `ccsx auth switch` silently redirect `ccsxp`, merge +their auth stores, or consume `ccsx` import backups as pool credentials. -### Windows symlinks - -On Windows, creating symlinks requires Developer Mode or elevated privileges. -If symlink creation fails, CCS falls back to copying `config.toml`, `agents/`, -`skills/`, and the current `plugins/cache/` snapshot. Copies do not update live with -`~/.codex/`; after a plugin update, another profile launch or -`ccsx auth create --force` repair copies newly missing cache entries. Existing -profile-local cache files are preserved. - -### Native Codex project-local config warnings - -`ccsx` preserves your current working directory. If you launch from your home directory, -native Codex can also see `~/.codex/config.toml` as `./.codex/config.toml`, a -project-local config file. Codex rejects user-level-only keys such as `model_providers` -and `notify` in project-local config. That warning comes from native Codex config -layering, not from the `ccsx auth` profile resource links. Launch from a project -directory or move project-local Codex config out of `$HOME/.codex/config.toml` if the -warning is noisy. - -### `ccsx` vs `ccsxp` - -`ccsx auth` profiles apply only to the **native `codex`** CLI. They have no effect on -`ccsxp` (the CLIProxy round-robin pool). `ccsxp` unconditionally sets its own -`CODEX_HOME` on startup and ignores `CCS_CODEX_PROFILE`. - -If you run `eval "$(ccsx auth use work)"` and then invoke `ccsxp`, a notice is emitted -to stderr: - -``` -[i] CCS_CODEX_PROFILE is ignored by ccsxp; profile applies to native 'codex' only -``` - -### cmd.exe - -`ccsx auth use` emits `set FOO=bar` syntax for cmd.exe. Native `eval` is not available -in legacy cmd — use PowerShell (`Invoke-Expression`) instead. - -### Backup files from `--force` - -When re-importing with `--force`, the existing `auth.json` is backed up as -`auth.json.bak-` in the profile directory. These accumulate over time; remove -them manually when no longer needed. +Behavior locks live under `tests/unit/codex-auth/` and +`tests/integration/codex-auth/`. diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md deleted file mode 100644 index b8cdd0c8..00000000 --- a/docs/cursor-integration.md +++ /dev/null @@ -1,165 +0,0 @@ -# Cursor IDE Integration - -This guide covers the deprecated CCS-owned Cursor IDE bridge, including auth import, local daemon lifecycle, live probe checks, and dashboard controls. - -`ccs cursor` now belongs to the CLIProxy-backed Cursor provider path. -Use `ccs legacy cursor` for the deprecated local bridge documented here. - -## What It Provides - -- OpenAI-compatible local endpoint powered by Cursor credentials. -- Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients. -- Cursor model list and chat completions via the local CCS daemon. -- Dedicated dashboard page: `ccs config` -> `Deprecated` -> `Cursor IDE (Legacy)`. - -## What This Runtime Actually Does - -`ccs legacy cursor` does not launch Cursor IDE itself. - -The current workflow is: -1. import Cursor credentials from local SQLite or manual input -2. run a local CCS daemon on `127.0.0.1:` -3. launch Claude Code against that daemon -4. have CCS translate requests to Cursor upstream - -Treat this as a CCS-managed Cursor bridge, not a generic CLIProxy-backed provider path. - -## Prerequisites - -- Cursor IDE installed and logged in. -- CCS installed and configured (`ccs config` works). -- For auto-detect auth on macOS/Linux: `sqlite3` available in PATH. - -## CLI Workflow - -### 1) Enable integration - -```bash -ccs legacy cursor enable -``` - -### 2) Import credentials - -Auto-detect from Cursor local SQLite state: - -```bash -ccs legacy cursor auth -``` - -Manual fallback: - -```bash -ccs legacy cursor auth --manual --token --machine-id -``` - -### 3) Start daemon - -```bash -ccs legacy cursor start -``` - -### 4) Run a live probe - -```bash -ccs legacy cursor probe -``` - -Use this to verify that the current build can complete one real authenticated request through the local daemon. - -### 5) Run Cursor-backed Claude - -```bash -ccs legacy cursor "explain this repo" -``` - -### 6) Verify status - -```bash -ccs legacy cursor status -``` - -Use `ccs legacy cursor` with bare or normal Claude args to run through the local Cursor proxy. -The admin namespace remains available for setup and inspection: - -```bash -ccs legacy cursor help -``` - -### 7) Stop daemon - -```bash -ccs legacy cursor stop -``` - -## Supported Cursor Provider Path - -For the supported CLIProxy-backed Cursor provider, use: - -```bash -ccs cursor --auth -ccs cursor --accounts -ccs cursor --config -ccs cursor "task" -``` - -## Runtime Defaults - -- Default port: `20129` -- `ghost_mode`: enabled -- `auto_start`: disabled -- Model list resolution: authenticated live fetch when available, with cached/default fallback. -- Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model. -- Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`. -- Live verification: `ccs legacy cursor probe` or `POST /api/cursor/probe` - -These values are managed in unified config and can be updated from CLI or dashboard. - -## Dashboard Usage - -Open dashboard: - -```bash -ccs config -``` - -Then navigate to `Cursor IDE (Legacy)` in the `Deprecated` section. - -Available controls: - -- Integration toggle (`enabled`) -- Auth actions (auto-detect, manual import) -- Daemon actions (start/stop) -- Runtime config (port, auto-start, ghost mode) -- Models list with searchable combobox filtering for large catalogs -- Raw editor for `~/.ccs/cursor.settings.json` - -## Raw Settings and Unified Config Sync - -Raw settings are stored in: - -`~/.ccs/cursor.settings.json` - -When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchronizes that port back into unified config so CLI and dashboard remain consistent. - -## Troubleshooting - -### `Not authenticated` or `expired` in `ccs cursor status` - -- Re-run `ccs legacy cursor auth` (or manual auth command). - -### `ccs legacy cursor probe` fails even though status is green - -- `status` proves local config/auth/daemon readiness only. -- `probe` proves the live runtime path. -- If `probe` fails with upstream protocol errors, inspect the current CCS build first rather than assuming the local daemon is healthy. - -### Auto-detect fails - -- Ensure Cursor is logged in. -- Confirm `sqlite3` is installed or use manual import. -- Use manual auth import if needed. - -### Daemon fails to start - -- Check if port `20129` is in use. -- Change port in dashboard config tab, then retry `ccs legacy cursor start`. diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md deleted file mode 100644 index 891a42ee..00000000 --- a/docs/dashboard-auth-cli.md +++ /dev/null @@ -1,267 +0,0 @@ -# Dashboard Authentication CLI - -Last Updated: 2026-05-05 - -CLI commands for managing CCS dashboard authentication. - -## Overview - -The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, such as when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`. - -Authentication is **disabled by default** for backward compatibility, while `ccs config` binds to `localhost` by default. Use the CLI to configure and enable auth before exposing the dashboard to other devices. - -CCS does **not** ship a default dashboard username or password. When someone opens the dashboard from a non-loopback/IP address before auth is enabled, the UI now shows a setup state instead of an ambiguous login form. The host owner must run `ccs config auth setup`, or the user should switch back to the localhost URL if they are on the same machine. - -Docker note: the integrated `ccs docker` stack stores its config inside the running container volume, not in the outer shell's `~/.ccs`. For Docker deployments, run auth setup inside the container: - -```bash -docker exec -it ccs-cliproxy ccs config auth setup -``` - -When auth stays disabled, CCS now applies a localhost-only fallback on sensitive management endpoints. Remote devices can still open the dashboard UI when you intentionally bind it beyond loopback, but write-capable routes such as AI Provider management and CLIProxy auth/status helpers reject non-loopback requests until you enable dashboard auth. - -## Account Context Modes (Related Feature) - -Dashboard auth and account context metadata are separate: - -- `dashboard_auth`: protects dashboard access with username/password -- `accounts..context_mode/context_group`: controls isolated vs shared account context -- `accounts..shared_resource_mode`: controls plugins/commands/skills/agents/settings.json sharing - -Account context is isolation-first. The recommended two-account route is: - -```bash -ccs auth create work -ccs auth create personal -ccs work -ccs personal -``` - -Only enable history sync when both accounts should share local continuity while tokens stay separate: - -| Mode | Default | Requirement | -|------|---------|-------------| -| `isolated` | Yes | No `context_group` required | -| `shared` | No (opt-in) | Valid non-empty `context_group` | - -Shared continuity depth: - -- `standard` (default): shares project workspace context only -- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos` - -`ccs auth show ` reports credential isolation, shared resource mode, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane. - -Non-bare account profiles share Claude-local resources with native Claude: - -```text -~/.ccs/instances//settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json -``` - -This keeps ordinary Claude settings, plugins, commands, skills, and agents in sync without copying account tokens. Existing accounts can opt out or back in: - -```bash -ccs auth resources work --mode profile-local -ccs auth resources work --mode shared -``` - -Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`. - -`context_group` normalization and validation: - -- trim + lowercase + collapse internal whitespace to `-` -- allowed characters: lowercase letters, numbers, `_`, `-` -- must start with a letter -- max length: 64 -- shared mode requires non-empty value after normalization -- `continuity_mode` is only valid when mode is `shared` - -`PUT /api/config` behavior for account context: - -- rejects invalid unified payloads -- rejects explicit `context_mode: shared` with invalid/empty `context_group` -- rejects invalid `continuity_mode` values -- normalizes valid shared `context_group` before save -- defaults missing shared `continuity_mode` to `standard` -- rejects `context_group` when mode is not `shared` -- rejects `continuity_mode` when mode is not `shared` - -Dashboard accounts context editing: - -- `PUT /api/accounts/:name/context` updates context mode/group/continuity for existing auth accounts -- rejects CLIProxy OAuth account keys for this route -- applies normalization/validation rules above - -Shared resource editing: - -- `PUT /api/accounts/:name/shared-resources` updates `shared_resource_mode` for existing auth accounts -- accepts only `shared` or `profile-local` -- rejects CLIProxy OAuth account keys for this route -- reconciles the account instance after metadata is updated -- Dashboard -> Accounts exposes this as a separate Resources action so it is not confused with History Sync. -- Dashboard -> Shared Resources shows the shared hub inventory for commands, skills, agents, plugins, and `settings.json`. -- The Plugins tab is registry-oriented: installed plugin entries come from `installed_plugins.json`, while internal cache/data/marketplace folders stay hidden unless a real plugin entry exists. -- Shared `settings.json` is read-only in the Shared Resources page and still edited through the settings surfaces that own those values. - -## Commands - -### `ccs config auth setup` - -Interactive wizard to configure dashboard login. - -```bash -$ ccs config auth setup - -╭─────────────────────────────────╮ -│ Dashboard Auth Setup │ -╰─────────────────────────────────╯ - -[i] Configure username and password for dashboard access. - Password will be hashed with bcrypt before storage. - -Username -Enter username: admin - -Password - Minimum 8 characters -Enter password: ******** -Confirm password: ******** - -[i] Hashing password... - -[OK] Dashboard authentication configured - -[i] Settings saved to ~/.ccs/config.yaml -[i] Username: admin -[i] Session timeout: 24 hours - - Start dashboard: ccs config - Show status: ccs config auth show - Disable auth: ccs config auth disable -``` - -### `ccs config auth show` - -Display current authentication status. - -```bash -$ ccs config auth show - -╭─────────────────────────────────╮ -│ Dashboard Auth Status │ -╰─────────────────────────────────╯ - -Configuration -[OK] Authentication: Enabled -[OK] Username: admin -[i] Session timeout: 24 hours - -Commands - ccs config auth setup Configure authentication - ccs config auth disable Disable authentication - ccs config Open dashboard -``` - -### `ccs config auth disable` - -Disable dashboard authentication with confirmation. - -```bash -$ ccs config auth disable - -╭─────────────────────────────────╮ -│ Disable Dashboard Auth │ -╰─────────────────────────────────╯ - -[!] This will disable login protection for the dashboard. -[i] Anyone with network access will be able to view the dashboard. - -Disable authentication? [y/N]: y - -[OK] Dashboard authentication disabled - -[i] Credentials preserved - re-enable with: ccs config auth setup -``` - -### `ccs config auth --help` - -Display usage information. - -## Environment Variables - -Environment variables override `config.yaml` values: - -| Variable | Description | -|----------|-------------| -| `CCS_DASHBOARD_AUTH_ENABLED` | Enable/disable auth (`true`/`false`) | -| `CCS_DASHBOARD_USERNAME` | Username | -| `CCS_DASHBOARD_PASSWORD_HASH` | Bcrypt password hash | - -### Generating a Password Hash - -Use bcrypt to generate a hash: - -```bash -# Using Node.js -node -e "console.log(require('bcrypt').hashSync('your-password', 10))" - -# Using npx -npx bcrypt-cli hash "your-password" -``` - -## Configuration - -Settings are stored in `~/.ccs/config.yaml`: - -```yaml -# Dashboard Auth: Optional login protection for CCS dashboard -# Generate password hash: npx bcrypt-cli hash "your-password" -# ENV override: CCS_DASHBOARD_AUTH_ENABLED, CCS_DASHBOARD_USERNAME, CCS_DASHBOARD_PASSWORD_HASH -dashboard_auth: - enabled: true - username: "admin" - password_hash: "$2b$10$..." - session_timeout_hours: 24 -``` - -## Security Notes - -1. **Bcrypt hashing**: Passwords are hashed with bcrypt (10 rounds) before storage -2. **Session cookies**: Sessions use HTTP-only cookies (not accessible via JavaScript) -3. **Rate limiting**: Login attempts are rate-limited (5 per 15 minutes) -4. **Fail-closed remote writes**: When auth is disabled, sensitive management routes allow localhost only -5. **File permissions**: Config file is created with 0o600 permissions - -## Troubleshooting - -### "Authentication not configured" - -Run `ccs config auth setup` to configure credentials. - -If you are using the integrated Docker stack, run that command inside `ccs-cliproxy`. Running it on the outer host shell updates a different config directory and will not unlock the running dashboard. - -### Forgot password - -Run `ccs config auth setup` again to set a new password. - -### ENV override not working - -Ensure the variable is exported: - -```bash -export CCS_DASHBOARD_AUTH_ENABLED=true -export CCS_DASHBOARD_USERNAME=admin -export CCS_DASHBOARD_PASSWORD_HASH='$2b$10$...' -``` - -### Session expired immediately - -Check `session_timeout_hours` in config. Default is 24 hours. - -### "Invalid ... context_group ..." - -This error comes from `PUT /api/config` when an account explicitly sets shared mode with an invalid group. Use a canonical group value (for example: `team-alpha`). - -## See Also - -- [Dashboard Auth Feature](https://ccs.kaitran.ca/features/dashboard-auth) - Full documentation -- [Config Schema](https://ccs.kaitran.ca/reference/config-schema) - All config options diff --git a/docs/hardening-debt-burndown.md b/docs/hardening-debt-burndown.md index e2cbfe5b..ae975fa4 100644 --- a/docs/hardening-debt-burndown.md +++ b/docs/hardening-debt-burndown.md @@ -1,112 +1,51 @@ -# Hardening Debt Burndown Tracker +# Hardening Debt Burndown -Last Updated: 2026-06-18 -Owner: Stream D (`#542`); maintainability epic owner TBD (open Q5) +This document defines the live measurement method. Completed phase narratives +remain available in Git history and must not be copied forward as current state. ## Scope -Maintainability hardening groundwork with low-risk changes: +The generated inventory measures: -- Inventory legacy shims/compatibility markers -- Inventory sync filesystem usage, especially runtime hotpaths -- Incrementally migrate hotpath sync I/O to async I/O with tests +- synchronous filesystem calls across source and in runtime hotpaths; +- legacy, shim, and compatibility markers; +- typed-error adoption; +- direct `console.error` and `console.warn` use outside CLI-UX exemptions; +- structured logger coverage; +- source files above maintainability size thresholds. -## How to Measure +Runtime-hotpath metrics exclude test, fixture, and mock files. Repository-wide +totals may include them when the metric explicitly says `all`. -Run: +## Generate And Verify ```bash bun run report:hardening +node scripts/hardening-inventory.js --check ``` Generated artifacts: -- `docs/reports/hardening-inventory.json` -- `docs/reports/hardening-inventory.md` +- `docs/reports/hardening-inventory.json` for tooling; +- `docs/reports/hardening-inventory.md` for review. -## Kickoff Baseline (Issue #542 Stream D) +`--check` rebuilds the expected content in memory and compares both tracked +artifacts byte-for-byte. Any source change that affects a metric requires +regeneration in the same commit. -The current baseline is sourced from `docs/reports/hardening-inventory.json` after running `bun run report:hardening`. -Baseline captured: `2026-02-12`. +## Interpretation -| Metric | Baseline | -|---|---:| -| 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 | +- Treat rankings as investigation queues, not automatic refactor mandates. +- Characterize behavior before splitting large or compatibility-sensitive code. +- Separate user-facing terminal output from operational diagnostics before + migrating console calls. +- Preserve intentional shims until their compatibility window and callers are + verified. +- Prefer trend comparisons from Git history when historical context is needed; + do not turn this live method into a dated progress ledger. -## Initial Async I/O Migration Log +## Review Contract -| 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. +A hardening change is complete only when focused tests pass, both inventory +artifacts match the current source tree, and the diff explains any material +metric movement. diff --git a/docs/i18n-dashboard.md b/docs/i18n-dashboard.md index 618e18ba..01812081 100644 --- a/docs/i18n-dashboard.md +++ b/docs/i18n-dashboard.md @@ -1,7 +1,5 @@ # Dashboard i18n Guide -Last Updated: 2026-04-14 - This document describes the internationalization (i18n) architecture used by the CCS Dashboard (`ui/`), how locale selection works, and how to add new languages safely. --- @@ -12,9 +10,11 @@ Dashboard i18n currently covers UI text rendered by React components. - Supported locales today: - `en` (English) + - `pt-BR` (Brazilian Portuguese) - `zh-CN` (Simplified Chinese) - `vi` (Vietnamese) - `ja` (Japanese) + - `ko` (Korean) - Locale state is persisted in browser localStorage using `ccs-ui-locale`. - Fallback language is `en`. @@ -37,9 +37,11 @@ Out of scope: - File: `ui/src/lib/i18n.ts` - Contains `resources` object with locale blocks: - `en.translation` + - `pt-BR.translation` - `zh-CN.translation` - `vi.translation` - `ja.translation` + - `ko.translation` - Uses `initReactI18next` for React integration. ### Locale utilities @@ -59,8 +61,11 @@ Out of scope: ### Test bootstrap - File: `ui/tests/setup/vitest-setup.ts` -- Test setup must import `ui/src/lib/i18n.ts` so direct `useTranslation()` consumers resolve the same singleton instance as the app. -- If a test mocks `useTranslation()`, keep the mocked key surface aligned with the component output or the assertions will drift. +- Test setup must import `ui/src/lib/i18n.ts` so components using the + react-i18next translation hook resolve the same singleton instance as the + app. +- If a test mocks the translation hook, keep the mocked key surface aligned + with the component output or the assertions will drift. --- @@ -104,11 +109,8 @@ When adding a locale such as Thai (`th`): 5. Run UI validation and i18n tests: - `cd ui && bun run validate` - `cd ui && bun run test:run tests/unit/ui/i18n/language-switcher.test.tsx` -6. Add or update key-parity tests to catch locale drift. - -Current issue driving the Vietnamese rollout: - -- https://github.com/kaitranntt/ccs/issues/659 +6. Keep the key-parity test passing. It compares every non-English resource + against English and verifies interpolation placeholders match. --- @@ -121,3 +123,14 @@ Before opening a PR that touches i18n: - [ ] No unsafe HTML injection path introduced for translated content. - [ ] `ui` validate/test commands pass. - [ ] This document is updated if architecture or conventions changed. + +## References + +- [`ui/src/lib/locales.ts`](../ui/src/lib/locales.ts) - supported locale ids, + normalization, persistence, and formatting locale +- [`ui/src/lib/i18n.ts`](../ui/src/lib/i18n.ts) - translation resources and + i18next initialization +- [`ui/src/components/layout/language-switcher.tsx`](../ui/src/components/layout/language-switcher.tsx) + - locale selection UI +- [`ui/tests/unit/ui/i18n/language-switcher.test.tsx`](../ui/tests/unit/ui/i18n/language-switcher.test.tsx) + - selection, persistence, key-parity, and placeholder coverage diff --git a/docs/image-analysis.md b/docs/image-analysis.md index 0e5b525d..718f2f18 100644 --- a/docs/image-analysis.md +++ b/docs/image-analysis.md @@ -1,119 +1,8 @@ -# Image Analysis Configuration Guide +# Image Analysis -CCS provides first-class image and PDF analysis for third-party Claude launches that do not have reliable native vision support. +User setup, routing behavior, and troubleshooting live in the canonical +[Image Analysis guide](https://docs.ccs.kaitran.ca/features/ai/image-analysis). -## How Image Analysis Works - -Native Claude accounts keep Anthropic's own vision flow. - -Third-party profiles now use a CCS-managed local MCP tool named `ImageAnalysis` when the runtime is available. CCS also appends a short steering hint so Claude prefers that tool over `Read` for local image and PDF files. - -Healthy Claude-target launches suppress the legacy CCS `Read` hook so MCP stays authoritative. If the managed runtime cannot be provisioned, CCS keeps the old `Read` hook available only as a compatibility fallback when that path is still viable. If runtime/auth/proxy readiness is degraded beyond that, CCS falls back to native `Read` instead of failing the whole launch. - -## Routing Model - -ImageAnalysis requests go straight to the CCS-managed provider route: - -```text -Claude -> ccs-image-analysis MCP -> CCS provider route -> /api/provider//v1/messages -``` - -Important: -- CCS does not relay image analysis through Claude Code, another CLI, or a second model wrapper. -- For bridge-backed settings profiles, CCS resolves the backend and provider path before launch. -- CCS avoids leaking a profile's ordinary third-party `ANTHROPIC_BASE_URL` or token into image analysis unless that profile is explicitly using a CLIProxy bridge. - -## Profile Behavior - -| Profile Type | Image Method | -|--------------|--------------| -| Claude `default` / `account` | Native Claude vision / native `Read` | -| Third-party settings / CLIProxy / Copilot | CCS local `ImageAnalysis` MCP tool when ready | -| Third-party when MCP provisioning fails but provider-backed analysis is still viable | Legacy CCS `Read` hook fallback | -| Third-party when runtime/auth/proxy is unavailable | Native `Read` fallback | - -## Configuration - -Configure via dashboard (`Settings -> Image`) or `~/.ccs/config.yaml`: - -```yaml -image_analysis: - enabled: true - timeout: 60 - fallback_backend: agy - provider_models: - agy: gemini-3-1-flash-preview - codex: gpt-5.1-codex-mini - ghcp: claude-haiku-4.5 -``` - -Useful commands: - -```bash -ccs config image-analysis -ccs config image-analysis --enable -ccs config image-analysis --disable -ccs config image-analysis --set-fallback agy -ccs config image-analysis --set-profile-backend glm agy -ccs config image-analysis --clear-profile-backend glm -``` - -## Prompt Templates - -CCS installs editable prompt templates at: - -```text -~/.ccs/prompts/image-analysis/ -``` - -Templates: -- `default.txt` -- `screenshot.txt` -- `document.txt` - -CCS automatically selects `screenshot` for screenshot-like filenames, `document` for PDFs, and `default` otherwise. - -## Runtime Environment - -Key runtime env vars: - -| Variable | Purpose | -|----------|---------| -| `CCS_IMAGE_ANALYSIS_SKIP` | Disable image analysis for the current launch | -| `CCS_IMAGE_ANALYSIS_SKIP_HOOK` | Suppress only the legacy CCS `Read` hook while keeping MCP ImageAnalysis available | -| `CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL` | Explicit CCS runtime base URL | -| `CCS_IMAGE_ANALYSIS_RUNTIME_PATH` | Provider route such as `/api/provider/agy` | -| `CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY` | Explicit CCS runtime auth key | -| `CCS_IMAGE_ANALYSIS_MODEL` | Force a single image-analysis model | -| `CCS_DEBUG` | Verbose runtime logging | - -## Self-Heal - -CCS now auto-heals stale managed image-analysis state in three places: - -- Healthy Claude launches remove stale CCS-managed image `Read` hooks from the active profile settings before launch. -- `Settings -> Image` save/provisioning repairs managed MCP runtime files, syncs managed MCP entries into isolated Claude config dirs, and cleans stale CCS-managed image hooks from `~/.ccs/*.settings.json`. -- `ccs doctor --fix` repairs invalid image-analysis config, removes stale CCS-managed image hooks, and resyncs managed `ccs-image-analysis` MCP entries into isolated configs. - -## Troubleshooting - -### Claude still uses `Read` - -- Confirm `ccs config image-analysis` shows `enabled: true` -- Check the active profile resolves to a configured backend -- Run `ccs doctor --fix` to repair stale managed hooks or missing managed MCP sync -- Run with `CCS_DEBUG=1` to see runtime preparation details - -### ImageAnalysis is not exposed - -- Verify CLIProxy auth for the resolved backend -- Verify the local or remote CLIProxy target is reachable -- Check `~/.claude.json` and inherited account configs for `ccs-image-analysis` - -### I need to prove requests are going directly to the provider route - -Run with `CCS_DEBUG=1` and inspect the resolved runtime path. The request target should be provider-scoped, for example: - -```text -/api/provider/agy/v1/messages -``` +Contributor implementation contracts are enforced by the source and focused +tests under `src/utils/image-analysis/`, `src/utils/hooks/`, and +`src/web-server/routes/`. diff --git a/docs/logging-contract.md b/docs/logging-contract.md index b51c68a9..99c05a1b 100644 --- a/docs/logging-contract.md +++ b/docs/logging-contract.md @@ -1,178 +1,139 @@ # Logging Contract -Single source of truth for structured backend logging in CCS CLI. Companion to GitHub issues #1138 (umbrella) and #1141 (backend instrumentation). +CCS structured logs are a machine-readable JSONL channel for backend and +runtime events. They are separate from terminal UX output and must never be +used as a substitute for user-facing recovery messages. -## Overview +The canonical schema is +[`src/services/logging/log-types.ts`](../src/services/logging/log-types.ts). +Defaults are defined in +[`src/config/schemas/logging.ts`](../src/config/schemas/logging.ts). -CCS emits structured JSONL log entries for backend behavior (proxy daemons, OAuth flows, target spawn lifecycle, executor errors, etc.). This document defines the canonical schema, request-correlation pattern, lifecycle stages, and redaction policy. +## Entry Schema -> CLI text output (`ok / info / warn / fail` from `src/utils/ui.ts`) is **NOT** affected by this contract. Logs are a separate channel — never printed to stdout/stderr. +| Field | Type | Required | Contract | +| --- | --- | --- | --- | +| `id` | `string` | yes | Unique entry id. | +| `timestamp` | `string` | yes | ISO 8601 emission time. | +| `level` | `error`, `warn`, `info`, or `debug` | yes | Severity. | +| `source` | `string` | yes | Stable module-scoped producer id. | +| `event` | `string` | yes | Stable machine-readable event name. | +| `message` | `string` | yes | Short human-readable summary. | +| `processId` | `number` | yes | Emitting process id. | +| `runId` | `string` | yes | Stable for the current process. | +| `context` | object | no | Event fields after configured redaction. | +| `requestId` | `string` | no | Cross-stage correlation id. | +| `stage` | `LogStage` | no | Canonical lifecycle stage. | +| `latencyMs` | `number` | no | Elapsed milliseconds, normally at completion. | +| `error` | `LogErrorInfo` | no | Structured error metadata. | -## Schema (`LogEntry`) - -Defined in `src/services/logging/log-types.ts`. - -| Field | Type | Required | Notes | -|-------|------|----------|-------| -| `id` | `string` | yes | UUID per entry. | -| `timestamp` | `string` | yes | ISO 8601. | -| `level` | `'error'\|'warn'\|'info'\|'debug'` | yes | | -| `source` | `string` | yes | Module-scoped identifier (e.g. `proxy:openai-compat:messages`). | -| `event` | `string` | yes | Dotted machine-readable event name (e.g. `request.received`). | -| `message` | `string` | yes | Human-readable summary. | -| `processId` | `number` | yes | `process.pid`. | -| `runId` | `string` | yes | Stable per-process id. | -| `context` | `object` | no | Free-form structured fields (redacted). | -| `requestId` | `string` | no | Correlates entries belonging to one inbound request across stages. | -| `stage` | `LogStage` | no | Lifecycle stage tag. | -| `latencyMs` | `number` | no | Elapsed ms (typically on `respond` / `cleanup`). | -| `error` | `{name, message, code?, stack?}` | no | Structured error metadata; never raw token strings. | - -Old free-form entries (no `requestId` / `stage`) are still valid; new fields are additive. - -### Example - -```jsonl -{"id":"...","timestamp":"2026-04-30T12:34:56.000Z","level":"info","source":"proxy:openai-compat:messages","event":"request.received","message":"Proxy /v1/messages request received","processId":42,"runId":"r1","requestId":"a1b2...","stage":"intake","context":{"method":"POST"}} -``` +Additive optional fields preserve compatibility with older readers. Consumers +must not assume every event has a stage, request id, latency, or structured +error. ## Lifecycle Stages -`LogStage` is one of: +Use `logger.stage()` for events that map to the canonical lifecycle: -| Stage | When to emit | -|-------|--------------| -| `intake` | Inbound request received at an entry edge (HTTP handler, CLI dispatch). | -| `route` | Destination/profile/target resolution. | -| `auth` | Authentication / authorization (token exchange, profile auth). | -| `dispatch` | Outbound request prepared / child process spawned. | -| `upstream` | Upstream call in flight (provider HTTP / spawned child running). | -| `transform` | Payload translation (request/response shape conversion). | -| `respond` | Response written / dispatched (`latencyMs` typically populated). | -| `cleanup` | Error path, abort, teardown. | +| Stage | Meaning | +| --- | --- | +| `intake` | Request or command entered a CCS boundary. | +| `route` | Profile, provider, target, or destination resolution. | +| `auth` | Authentication or authorization work. | +| `dispatch` | Outbound request or child launch prepared. | +| `upstream` | Provider request or child operation in flight. | +| `transform` | Request or response translation. | +| `respond` | Result dispatched to the caller. | +| `cleanup` | Failure, abort, or teardown path. | -Stages may be skipped or repeated. Streaming responses tag `upstream` only at start/end (NOT per chunk). +Stages can be skipped or repeated. Use `logger.info()`, `warn()`, or `error()` +for events that do not represent a lifecycle stage. High-volume details, +including streaming chunk metrics, belong at `debug`. -## RequestId Propagation (AsyncLocalStorage) - -`requestId` is propagated implicitly via Node `AsyncLocalStorage`. Entry edges wrap their handler in `withRequestContext`; every `createLogger`-emitted entry inside the context auto-merges `requestId` from the active store. +Example: ```ts -import { withRequestContext, createLogger } from './services/logging'; - -const logger = createLogger('proxy:my-edge'); - -http.createServer((req, res) => { - const requestId = req.headers['x-ccs-request-id'] ?? randomUUID(); - res.setHeader('x-ccs-request-id', requestId); - withRequestContext({ requestId }, async () => { - logger.stage('intake', 'request.received', 'inbound'); - // ... downstream work emits with the same requestId - }); +logger.stage('respond', 'request.completed', 'Request completed', { + statusCode: 200, +}, { + latencyMs: 42, }); ``` -### Cross-daemon header +See [`src/services/logging/logger.ts`](../src/services/logging/logger.ts) for the +compiler-checked signature. -`x-ccs-request-id` round-trips across the proxy edge: -- Inbound: if the header is present and matches the UUID-ish guard (`/^[A-Za-z0-9._-]{8,128}$/`), it is reused; otherwise a fresh UUID is minted. -- Outbound (response): the resolved id is echoed back via `res.setHeader('x-ccs-request-id', ...)`. -- When CCS calls another daemon (copilot, cursor, glmt), forward the active id in the same header so that daemon can correlate. +## Request Correlation -### Ordering guarantee +[`src/services/logging/log-context.ts`](../src/services/logging/log-context.ts) +uses Node async-local storage within a process. Entry edges establish a +context; loggers created downstream read its `requestId` automatically. -Emit-time ordering of entries within a single `requestId` is monotonic — the active context is single-threaded relative to the request, so `timestamp` ordering reflects emit order. The UI layer (#1142) consumes this guarantee. +Async-local context does not cross child processes or worker threads. A child +process can inherit the active id through `CCS_REQUEST_ID` and establish a new +local context. HTTP edges may use `x-ccs-request-id`; each edge owns whether it +accepts an incoming id or mints a new one. -### What NOT to put in the context +Only the characters and length accepted by `REQUEST_ID_PATTERN` are valid for +forwarded ids. Do not treat request ids as authentication or authorization +material. -The ALS context object is mixed into every downstream entry. Never store: -- Raw tokens, API keys, refresh tokens, OAuth codes -- Raw request/response bodies -- User-supplied secrets +There is no global ordering guarantee across concurrent async work or processes. +Use `timestamp`, stage, event, and process/run identifiers together when +reconstructing a request. -Only benign correlation metadata: `requestId`, `method`, `path`, `command`, `profile`. +## Redaction and Data Minimization -### Worker threads / spawned children +[`src/services/logging/log-redaction.ts`](../src/services/logging/log-redaction.ts) +is the implementation source of truth. With the default `logging.redact: true`, +the logger: -ALS context is **not** inherited by worker threads or `child_process.spawn` stdio pipes. At those boundaries, mint a fresh `requestId` at the child entry and pass the parent id explicitly via env var or header for correlation. +- replaces values under known credential-bearing keys; +- masks common authorization schemes and credential token shapes in strings; +- redacts sensitive CLI flag values, including inline assignments; +- limits string length and nested object depth; +- strips an `Error` object to safe structured fields. -## Redaction +The matcher evolves as credential surfaces change. Link to the implementation +instead of copying its complete key or token-pattern list into other docs. -`src/services/logging/log-redaction.ts` is the single source of truth. +Redaction is defense in depth, not permission to log sensitive data. Never log: -### Sensitive key matcher +- tokens, passwords, cookies, OAuth codes, or authorization values; +- raw request or response bodies; +- raw prompts or prompt-bearing CLI arguments; +- personal identifiers when a non-identifying account or profile label works. -`SENSITIVE_KEY_PATTERN` matches (case-insensitive, with `_` / `-` / camelCase variants): -`authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `password`, `password_hash`, `secret`, `client_secret`, `token`, `auth_token`, `access_token`, `refresh_token`, `id_token`, `bearer`, `assertion`, `api_key`, `x-api-key`, `x-goog-api-key`, `management_key`, `copilot_token`, `cursor_session_key`, `oauth_code`, `auth_code`. +When a new secret-bearing field or flag is introduced, update the redactor and +its tests under +[`tests/unit/services/logging/`](../tests/unit/services/logging/). -String/object values for matching keys are replaced with `[redacted]`. Numeric/boolean values pass through (e.g., `expires_at` epoch numbers stay readable). +## Error Codes and Process Exit Codes -### Auth-scheme value masking +These are separate contracts: -Raw string values whose prefix matches `^(Bearer|Basic|Token)\s+\S+` are rewritten to ` [redacted]` even when nested under non-sensitive keys. +- `LogEntry.error.code` is an optional **string** supplied as part of structured + error metadata. It can carry a runtime, system, or provider error identifier. +- `CCSError.code` is a numeric `ExitCode`. The centralized CLI error handler + writes it as `context.exitCode` and passes it to `process.exit`. -### Argv redaction +Do not put a numeric process exit code in `LogEntry.error.code`, and do not make +log consumers derive process status from that string field. -`redactArgv(argv)` redacts the value following any sensitive flag (`--token`, `--api-key`, `--auth`, `--bearer`, `--secret`, `--client-secret`, `--access-token`, `--refresh-token`, `--id-token`, `--password`). +The numeric mapping lives in +[`src/errors/exit-codes.ts`](../src/errors/exit-codes.ts). Typed error +assignments live in +[`src/errors/error-types.ts`](../src/errors/error-types.ts), and propagation is +implemented by +[`src/errors/error-handler.ts`](../src/errors/error-handler.ts). -### Adding new sensitive keys +## Configuration -1. Extend `SENSITIVE_KEY_PATTERN` in `src/services/logging/log-redaction.ts`. -2. Add a unit test in `tests/unit/services/logging/log-redaction-extended.test.ts`. -3. Verify regex stays O(1) per key (no catastrophic backtracking). +CCS-owned logging uses the `logging` section in the unified config. Its defaults +enable logging and redaction at `info` level with bounded rotation, retention, +and live-buffer settings. This is distinct from `cliproxy.logging`, which +controls CLIProxy runtime logging. -## Contributor Guide - -### When to use `logger.stage()` vs `logger.info()` - -Use `stage()` whenever the entry corresponds to one of the canonical lifecycle stages — this is what observability tooling and the dashboard rely on. Use `info()` / `warn()` / `error()` for one-off events that don't fit a stage. - -### What NOT to log - -- Token values (use metadata: `expires_at`, `scopes`, account display name). -- Request/response bodies (sample lengths only). -- Authorization headers (log header *names* present, not values). - -### Level guidance - -| Level | Use for | -|-------|---------| -| `error` | Failures requiring action (cleanup stage). | -| `warn` | Recoverable issues (auth rejected, route fallback). | -| `info` | Lifecycle stage entries by default. | -| `debug` | High-volume detail (per-chunk stream metrics, lock acquire/release). | - -### Level config - -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. -- Existing `console.*` UX prints in `src/commands/`, `src/utils/ui.ts`, and similar user-facing paths are intentionally **not** converted to logger. -- `/api/logs` reader unchanged in this PR; UI surfacing of new fields tracked under #1142. - -## Future Work - -- UI surfacing of `requestId` / `stage` / `latencyMs` in the dashboard (#1142). -- `ccs logs` CLI improvements (filter by `requestId` / `stage`). -- Per-stage performance budgets (see #1071). +When adding a logging setting, update the schema/defaults, configuration loader, +dashboard surface if applicable, and focused logging tests. Do not document a +default that is not present in `DEFAULT_LOGGING_CONFIG`. diff --git a/docs/openai-compatible-providers.md b/docs/openai-compatible-providers.md index 3a507bbe..6c776eda 100644 --- a/docs/openai-compatible-providers.md +++ b/docs/openai-compatible-providers.md @@ -1,378 +1,44 @@ -# OpenAI-Compatible Provider Routing +# OpenAI-Compatible Proxy Developer Contract -CCS can route Claude Code traffic through a local Anthropic-compatible proxy when -your API profile points at an OpenAI-compatible chat completions endpoint. +The canonical +[OpenAI-Compatible Provider Routing guide](https://docs.ccs.kaitran.ca/features/proxy/openai-compatible-providers) +owns user setup and workflows. This local contract retains runtime and +configuration invariants used by source, tests, and operators. -This is useful for providers such as: +## Profile-Scoped Insecure TLS -- Hugging Face Inference Providers -- Tuning Engines -- OpenRouter -- Ollama -- llama.cpp servers -- OpenAI-compatible self-hosted gateways +`CCS_OPENAI_PROXY_INSECURE` is read from an OpenAI-compatible profile env. +Truthy values are `1`, `true`, `yes`, and `on` (case-insensitive). When enabled, +the local proxy disables upstream certificate verification for that profile, +including request-time routing to another insecure profile. -## Related Project: claude-code-router +This flag weakens TLS verification. Keep it explicit and profile-scoped; never +make it a global default or infer it from a failed certificate check. The live +resolution and dispatcher contracts are in +[`profile-router.ts`](../src/proxy/profile-router.ts) and +[`proxy-server.ts`](../src/proxy/server/proxy-server.ts). -[claude-code-router](https://github.com/musistudio/claude-code-router) is the -main external reference that informed this CCS work. Their Anthropic/OpenAI -transformer design helped shape the routing approach here. +## Request Timeout -When to use CCR: +`CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS` controls the upstream request timeout in +milliseconds: -- you want a standalone router without CCS profile integration -- you do not need CCS account/runtime management around the request flow +- default: `600000` (10 minutes); +- accepted: values whose `Number.parseInt` result is positive; +- missing, invalid, zero, or negative values: fall back to the default. -When to use CCS: +Upstream Undici header/body timeouts must stay above the request timeout so they +do not terminate slow self-hosted inference first. The current implementation +adds a 30-second grace ceiling. Source of truth: +[`messages-route.ts`](../src/proxy/server/messages-route.ts). -- you already use CCS API profiles or runtime bridges -- you want the proxy flow available through `ccs ` and `ccs proxy ...` -- you want the routing behavior documented and tested inside the CCS workflow +## Compatibility Boundary -## What CCS Does +These variables configure the local Anthropic-to-OpenAI proxy. They do not +convert a non-compatible profile into a compatible one, bypass local proxy +authentication, or authorize remote binding. Keep profile detection, adaptive +port selection, passthrough mode, request-time routing, and scenario routing +documented in the public guide. -When you launch a compatible settings profile with the Claude target, CCS now: - -1. Starts a local proxy on `127.0.0.1` using the resolved local port for that profile -2. Accepts Anthropic `/v1/messages` traffic from Claude Code -3. Translates requests into OpenAI chat-completions format -4. Forwards them to your configured upstream provider -5. Translates streaming responses back into Anthropic SSE - -You do not need to rewrite your profile by hand each time. - -## Quick Start - -Create or reuse an API profile that points at an OpenAI-compatible endpoint: - -```bash -ccs api create --preset hf -``` - -For Tuning Engines: - -```bash -ccs api create --preset te -``` - -Then you can use the profile directly: - -```bash -ccs hf -# or -ccs te -``` - -CCS detects that the profile is OpenAI-compatible and auto-routes Claude Code -through the local proxy. - -## Manual Proxy Lifecycle - -If you want to manage the proxy explicitly: - -```bash -ccs proxy start hf -eval "$(ccs proxy activate)" -ccs proxy status -ccs proxy stop -``` - -Useful variants: - -```bash -ccs proxy start hf --host 127.0.0.1 -ccs proxy start hf --port 3460 -ccs proxy activate hf -ccs proxy activate --fish -ccs proxy status hf -ccs proxy stop hf -``` - -Port selection precedence is: - -1. CLI `--port` for an exact one-off pin -2. `proxy.profile_ports[profile]` for an exact per-profile pin -3. `proxy.port` for a shared preferred starting port -4. adaptive per-profile fallback when nothing is pinned - -Legacy shared `proxy.port: 3456` values are treated as unset so older configs -move onto the adaptive path instead of staying on the hot legacy default. If -you need an exact `3456` binding now, pin it via `--port` or `proxy.profile_ports`. - -`ccs proxy activate` now prints the full local runtime contract: - -- `ANTHROPIC_BASE_URL` -- `ANTHROPIC_AUTH_TOKEN` -- `ANTHROPIC_MODEL` plus tier defaults when present -- `DISABLE_TELEMETRY` -- `DISABLE_COST_WARNINGS` -- `API_TIMEOUT_MS` -- `NO_PROXY` - -## Multiple Active Proxy Profiles - -CCS now stores OpenAI-compatible proxy state per profile instead of treating the -runtime as a singleton. - -- Different compatible profiles can run at the same time on separate local ports -- `ccs proxy activate` without a profile stays convenient when only one proxy is - running -- When multiple proxies are running, pass the profile explicitly to - `activate`, `status`, or `stop` -- `status` and `activate` always reflect the actual running port instead of an - assumed default - -If you want to pin or guide ports explicitly, configure them in `~/.ccs/config.yaml`: - -```yaml -proxy: - port: 45000 - profile_ports: - hf: 3460 - openai: 3461 -``` - -## Request-Time Routing - -The proxy is no longer limited to the startup profile's default model. - -Supported request-time selectors: - -- `profile:model` - Example: `deepseek:deepseek-reasoner` -- `profile` - Example: `openrouter` -- plain model ids - Example: `deepseek-chat` - -Plain model ids use exact string equality against the configured profile model -slots (`model`, `opusModel`, `sonnetModel`, `haikuModel`). CCS does not apply -fuzzy matching or prefix matching here. If no exact match is found, the request -stays on the active profile with the requested model id unchanged. - -Routing behavior: - -1. `profile:model` wins immediately. -2. Scenario routing may override the active profile when configured. -3. Plain model ids are matched against the configured OpenAI-compatible - profiles before falling back to the active profile. - -This means a Claude session launched through one compatible profile can still -request another compatible profile/model when the proxy can resolve it safely. - -## Scenario Routing - -Scenario routing is now supported through `proxy.routing` in your CCS config. - -Example `~/.ccs/config.yaml`: - -```yaml -proxy: - routing: - default: "deepseek:deepseek-chat" - background: "ollama:qwen2.5-coder:0.5b" - think: "deepseek:deepseek-reasoner" - longContext: "openrouter:google/gemini-2.5-pro" - longContextThreshold: 60000 - webSearch: "openrouter:perplexity/sonar-pro" -``` - -Current scenario detection: - -- `background`: requested model contains `haiku` -- `think`: Anthropic `thinking` is enabled -- `longContext`: estimated request tokens exceed `longContextThreshold` -- `webSearch`: tool list includes `web_search` -- `default`: fallback selector when the above do not apply - -Routing decisions are logged through CCS structured logs. - -`longContextThreshold` uses an intentionally approximate token estimate based on -message characters, tool payload size, and a `chars / 4` heuristic. Tune the -threshold conservatively if your routing decision needs a sharper cutoff near -the boundary. - -## How Profile Detection Works - -CCS keeps these profiles in the normal API/settings-profile flow. - -Anthropic-compatible endpoints such as: - -- `https://api.anthropic.com` -- `https://api.z.ai/api/anthropic` -- `https://api.deepseek.com/anthropic` - -continue to launch directly. - -OpenAI-compatible endpoints such as: - -- `https://router.huggingface.co/v1` -- `https://api.openai.com/v1` -- `http://localhost:11434` - -are routed through the local proxy for Claude-target launches. - -## Provider Setup - -### DeepSeek - -Use a settings profile whose env looks like: - -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1", - "ANTHROPIC_AUTH_TOKEN": "sk-...", - "ANTHROPIC_MODEL": "deepseek-chat", - "CCS_DROID_PROVIDER": "generic-chat-completion-api" - } -} -``` - -Typical override target: - -- `deepseek:deepseek-reasoner` - -### OpenRouter - -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1", - "ANTHROPIC_AUTH_TOKEN": "sk-or-...", - "ANTHROPIC_MODEL": "openai/gpt-4.1-mini", - "CCS_DROID_PROVIDER": "generic-chat-completion-api" - } -} -``` - -Useful when you want: - -- model fan-out behind one provider profile -- long-context or web-search scenario targets - -### Ollama / Local Gateways - -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "http://127.0.0.1:11434", - "ANTHROPIC_AUTH_TOKEN": "ollama", - "ANTHROPIC_MODEL": "qwen3-coder", - "CCS_DROID_PROVIDER": "generic-chat-completion-api" - } -} -``` - -For self-signed HTTPS gateways, add `CCS_OPENAI_PROXY_INSECURE=1`. - -### DashScope / Qwen Compatible Mode - -DashScope's compatible endpoint works even when older settings files still -carry a stale Anthropic-style provider hint: - -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "https://dashscope-us.aliyuncs.com/compatible-mode/v1", - "ANTHROPIC_AUTH_TOKEN": "sk-...", - "ANTHROPIC_MODEL": "qwen3.6-plus", - "CCS_DROID_PROVIDER": "anthropic" - } -} -``` - -CCS now infers the OpenAI-compatible route from the base URL and does not let -that stale provider hint block proxy routing. - -## Self-Signed TLS - -If your upstream gateway uses a self-signed or privately issued certificate, -set this in the profile settings JSON: - -```json -{ - "env": { - "CCS_OPENAI_PROXY_INSECURE": "1" - } -} -``` - -That flag is respected by both: - -- `ccs ` auto-routing -- `ccs proxy start ` - -## Supported Runtime Paths - -- `ccs ` with Claude target: auto-starts the local proxy when needed -- `ccs proxy start `: starts the proxy explicitly -- `GET /`: proxy info and bound profile details -- `GET /health`: proxy liveness check -- `GET /v1/models`: local view of the configured model mapping -- `POST /v1/messages`: Anthropic-compatible request entrypoint - -## Troubleshooting - -### Missing or invalid local proxy token - -- Re-run `eval "$(ccs proxy activate)"` -- Check `ccs proxy status` and confirm the expected profile is running - -### Self-signed or private CA upstream - -- Add `CCS_OPENAI_PROXY_INSECURE=1` to the profile settings -- Restart the proxy after changing the setting - -### Need to pin or verify the local port - -- Check the active binding with `ccs proxy status hf` -- Pin a one-off port with `ccs proxy start hf --port 3460` -- Reserve a stable profile port with `proxy.profile_ports` -- Re-run `ccs proxy activate hf` after changing the port - -### Provider returns `429` or empty upstream output - -- 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` -- Review `proxy.routing` if scenario routing is enabled -- Check CCS structured logs in `~/.ccs/logs/current.jsonl` for routing decisions - -## Validation - -The shipped coverage includes: - -- unit tests for OpenAI-compatible profile detection -- unit tests for Anthropic -> OpenAI request translation -- unit tests for request-time profile/model routing and scenario routing -- unit tests for multi-line SSE parsing -- integration tests for `/v1/messages` request/response translation -- integration tests for rate limits, empty upstream responses, timeout handling, - thinking/tool-call chunk streaming, and request-time routing -- integration tests for daemon lifecycle and `/health` / `/v1/models` -- e2e tests for `ccs proxy` lifecycle -- e2e tests for `ccs ` auto-routing through a mock upstream - -Focused verification command: - -```bash -bun test tests/e2e/proxy-command.e2e.test.ts tests/integration/proxy/request-routing.test.ts --coverage -``` - -Pre-merge gate: - -```bash -bun run validate -``` +Focused behavior locks live in `tests/unit/proxy/` and +`tests/integration/proxy/`. diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index de9d4f86..b8a7dafa 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -1,374 +1,254 @@ -# CCS Product Development Requirements (PDR) - -Last Updated: 2026-05-07 +# CCS Product Development Requirements ## Product Overview -**Product Name**: CCS (Claude Code Switch) +**Product name:** CCS (Claude Codex Switch) -**Tagline**: The multi-provider profile and runtime manager for Claude Code and compatible CLIs +**Purpose:** Provide one profile and runtime-management surface for Claude Code, +Codex CLI, Factory Droid, CLIProxy-backed OAuth providers, and compatible API +profiles. -**Description**: Multi-provider CLI/runtime manager enabling seamless switching between multiple Claude accounts, OAuth/API providers, and alternate targets such as Claude Code, Factory Droid, and Codex CLI. Includes a React-based dashboard for configuration management, plus support for local and remote CLIProxyAPI instances, hybrid quota management, and official Claude channel runtime setup for Telegram, Discord, and iMessage. +CCS includes: -**Current Version**: v7.34.x+ (First-class ImageAnalysis MCP tooling, WebSearch MCP, performance improvements) +- a TypeScript CLI and local server; +- a React dashboard for configuration, account, health, and usage workflows; +- isolated account contexts and per-profile settings; +- local or remote CLIProxy routing; +- optional managed tools such as WebSearch and image analysis; and +- an integrated Docker image containing CCS, CLIProxy, and the dashboard. ---- +Release versions and completed-release inventories belong in +[`CHANGELOG.md`](../CHANGELOG.md), not this evergreen requirements document. -## Problem Statement +## Problem -Developers using Claude Code face these challenges: +Developers need to switch between accounts, providers, and compatible CLIs +without repeatedly editing credentials or allowing one session's configuration +to leak into another. They also need a visible, reversible way to manage local +proxy state and diagnose provider readiness. -1. **Single Account Limitation**: Cannot run multiple Claude subscriptions simultaneously -2. **Provider Lock-in**: Stuck with Anthropic's API, cannot use alternatives -3. **No Concurrent Sessions**: Cannot work on different projects with different accounts -4. **Complex Configuration**: Manual env var and config file management -5. **No Usage Analytics**: Lack visibility into token usage and costs across providers +## Product Principles ---- +1. **CLI first:** Core configuration and launch behavior remains scriptable. +2. **Explicit state:** Users can inspect which profile, provider, target, and + proxy mode CCS selected. +3. **Isolation:** Account sessions use separate configuration roots where the + target supports them. +4. **Compatibility:** CCS adapts provider credentials to a target without + redefining the provider or target protocol. +5. **Reversibility:** Persistent writes are explicit and recoverable. +6. **Local ownership:** Credentials and profile state remain on infrastructure + selected by the user. -## Solution +## Users -CCS provides: - -1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR` -2. **OAuth Providers**: Zero-config Gemini, Codex, xAI/Grok, Antigravity, Kiro, and other active OAuth integrations, with deprecated Copilot compatibility for existing setups -3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families -4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API -5. **Visual Dashboard**: React SPA for configuration management -6. **Automatic WebSearch**: First-class local WebSearch tool with deterministic provider chain for third-party providers -7. **Automatic Image Analysis**: First-class local ImageAnalysis tool with direct provider routing for third-party profiles -8. **Usage Analytics**: Token tracking, cost analysis, model breakdown -9. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage -10. **Routing Strategy Guidance**: First-class `round-robin` vs `fill-first` controls in CLI and dashboard, with explicit opt-in changes and no account-based guessing - ---- - -## Target Users - -| User Type | Use Case | Primary Features | -|-----------|----------|------------------| -| Individual Developer | Work/personal separation | Multi-account Claude | -| Agency/Contractor | Client account isolation | Profile switching | -| Cost-conscious Dev | GLM for bulk operations | API profiles, analytics | -| Enterprise | Custom LLM integration | OpenAI-compatible endpoints | -| Power User | Multiple providers | OpenRouter 300+ models | - ---- +| User | Primary need | +| --- | --- | +| Individual developer | Separate accounts, projects, and provider profiles | +| Consultant or agency | Isolate client contexts | +| API consumer | Reuse Anthropic-compatible and OpenAI-compatible providers | +| Power user | Manage OAuth accounts, routing, health, and quota state | +| Team operator | Run a shared remote CLIProxy or integrated Docker service | ## Functional Requirements -### FR-001: Profile Switching -- Switch between profiles with `ccs ` command -- Support default profile when no argument provided -- Pass through all Claude CLI arguments +### FR-001: Profile resolution and launch -### FR-002: Multi-Account Claude -- Create isolated Claude instances -- Maintain separate sessions, todolists, logs per account -- Share commands, skills, agents across accounts +- Launch the default profile with `ccs`. +- Launch named settings, account, and CLIProxy profiles. +- Pass target-specific arguments without losing CCS-owned routing constraints. +- Resolve provider and target aliases through canonical registries. -### FR-003: OAuth Provider Integration -- Support Gemini, Codex, xAI/Grok, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows -- Browser-based authentication with provider-specific Authorization Code, Device Code, or polling flows -- Token caching and refresh +### FR-002: Account isolation -### FR-004: API Profile Management -- Configure custom API endpoints -- Support Anthropic-compatible APIs -- Model mapping and configuration -- OpenRouter integration with 300+ models +- Maintain an account registry. +- Use isolated `CLAUDE_CONFIG_DIR` roots for Claude account profiles. +- Keep shared resources and instance-owned state distinguishable. +- Prevent one account's provider overrides from leaking into another launch. -### FR-004A: CLIProxy AI Provider Management -- Configure CLIProxy-managed Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key entries -- Keep provider authoring separate from CCS API Profile creation -- Support local config editing and remote CLIProxy management parity where available +### FR-003: Provider integration -### FR-005: Dashboard UI -- Visual profile management -- Real-time health monitoring -- Usage analytics with cost tracking -- Modular page architecture (settings, analytics, auth-monitor) +- Support API-key profiles and OAuth-backed CLIProxy providers. +- Support local and remote CLIProxy operation. +- Keep original and Plus CLIProxy backends explicit; do not silently substitute + one when a requested provider requires the other. +- Treat provider capability metadata as code-owned, not prose-owned. See + [`src/cliproxy/provider-capabilities.ts`](../src/cliproxy/provider-capabilities.ts) + and + [`src/cliproxy/types/provider-types.ts`](../src/cliproxy/types/provider-types.ts). -### FR-006: Health Diagnostics -- Verify Claude CLI installation -- Check config file integrity -- Validate symlinks and permissions +### FR-004: Target adapters -### FR-007: WebSearch Fallback -- Expose a CCS-managed local WebSearch tool for third-party profiles that cannot reach Anthropic's native tool -- Suppress native `WebSearch` on third-party launches and steer Claude toward the CCS-owned path when it is available -- Support Exa, Tavily, Brave, and DuckDuckGo real search backends -- Keep Gemini CLI, OpenCode, and Grok as optional legacy fallback -- Graceful fallback chain +- Support Claude Code, Factory Droid, and Codex CLI through target adapters. +- Deliver credentials in the form owned by each target: + environment variables for Claude launches, managed custom-model state for + Droid, and transient configuration overrides for CCS-routed Codex launches. +- Preserve user-owned target configuration outside the explicitly managed + fields. -### FR-007A: First-Class Image Analysis -- Expose a CCS-managed local `ImageAnalysis` MCP tool for third-party profiles that need provider-backed vision -- Resolve the provider route before launch and send requests directly to `/api/provider//v1/messages` -- Use editable prompt templates for `default`, `screenshot`, and `document` analysis modes -- Suppress the old CCS-managed `Read` hook during healthy MCP launches so it cannot compete with the primary path -- Keep the old `Read` hook as compatibility fallback only when MCP provisioning fails but provider-backed analysis is still viable -- Auto-heal stale CCS-managed image hooks and missing isolated MCP sync through launch-time cleanup, dashboard provisioning, and `ccs doctor --fix` -- Fall back to native `Read` without failing the whole launch when managed runtime, auth, or proxy readiness is unavailable +### FR-005: Configuration management -### FR-008: Remote CLIProxy Support -- Connect to remote CLIProxyAPI instances -- CLI flags for proxy configuration (--proxy-host, --proxy-port, etc.) -- Environment variable configuration (CCS_PROXY_HOST, etc.) -- Fallback to local proxy when remote unreachable -- Protocol-based default ports (443 for HTTPS, 8317 for HTTP) -- Dashboard UI for remote server configuration and testing +- Store CCS configuration under the directory resolved by + [`getCcsDir()`](../src/utils/config-manager.ts). +- Store API profile launch settings in `.settings.json` files under + that directory. +- Require all environment values written to settings files to be strings. +- Keep shared Claude settings unchanged during normal profile launches. +- Allow the explicit `ccs persist` workflow to merge a profile into + `~/.claude/settings.json`; back up the existing file and write atomically. +- Reject unsafe settings-file targets such as symlinks. -### FR-009: Quota Management (v7.14) -- Pause/resume individual accounts via `ccs cliproxy pause/resume ` -- Check quota status via `ccs cliproxy status [account]` -- Inspect the current proxy-wide routing strategy via `ccs cliproxy routing` -- Explicitly switch `round-robin` vs `fill-first` from CLI or dashboard -- Keep `round-robin` as the default until the user explicitly changes it -- Never infer routing strategy from account count, tier mix, or paused/default account state -- Auto-failover when account exhausted -- Tier detection: free/pro/ultra/unknown -- Distinguish entitlement failures from temporary capacity exhaustion -- Pre-flight quota checks before session start -- Dashboard UI with pause/resume toggles, tier badges, and quota-detail guidance +### FR-006: Dashboard and diagnostics -### FR-010: Docker Deployment -- Multi-stage Dockerfile with bun 1.2.21 and node:20-bookworm-slim -- Docker Compose setup with resource limits and healthcheck -- Persistent volumes for config, credentials, and CLI tools -- Pre-installed CLIs: claude, gemini, grok, opencode, ccs -- Ports: 3000 (Dashboard), 8317 (CLIProxy) -- Entrypoint with privilege dropping and usage help -- Environment variable configuration support +- Provide local APIs and a React UI for supported configuration workflows. +- Surface health, authentication, provider, routing, and usage state without + exposing credentials. +- Keep dashboard changes aligned with the same configuration contracts used by + the CLI. -### FR-011: Third-Party Tool Integration -- Export shell-evaluable env vars via `ccs env` command -- Support OpenAI, Anthropic, raw output formats -- Auto-detect shell (bash/zsh, fish, PowerShell) from $SHELL -- Security: single-quoted output, key sanitization, shell-specific escaping -- Cross-platform compatibility (macOS, Linux, Windows) +### FR-007: Managed tools -### FR-012: Official Claude Channels -- Support Telegram, Discord, and iMessage selection via `ccs config channels` and the dashboard -- Auto-inject `--channels` only for native Claude `default` and `account` sessions -- Store Telegram/Discord bot tokens in Claude's own `~/.claude/channels//.env` state or the official `*_STATE_DIR` override path when one is configured -- Treat iMessage as macOS-only, tokenless, and dependent on Claude-side install plus OS permissions -- Require Bun, Claude Code v2.1.80+, and verified `claude.ai` auth before runtime auto-enable -- Keep `--dangerously-skip-permissions` optional and never add it when the user already made an explicit permission choice -- Surface platform/auth/version/setup blockers clearly in both CLI and dashboard flows -- Preserve dashboard token drafts when save/refresh fails, and let already-selected unsupported iMessage entries be turned off without allowing re-enable on unsupported platforms +- Provide WebSearch and image-analysis integration for profiles that need + CCS-managed alternatives. +- Prefer explicit provider routes. +- Fail closed when enabled third-party WebSearch cannot prepare its constrained + MCP replacement. +- Allow image analysis to use compatible native behavior when its managed route + is unavailable. ---- +### FR-008: Remote proxy + +- Resolve remote proxy settings from supported CLI flags, environment + variables, and CCS configuration. +- Verify reachability before use. +- Fall back to a local proxy only when fallback is enabled. +- Fail instead of falling back when remote-only mode is selected. + +### FR-009: Quota and account-pool management + +- Display supported quota and account health data. +- Let users pause and resume accounts. +- Keep routing-strategy changes explicit. +- Temporarily remove exhausted accounts from rotation only under the + CCS-managed cooldown contract and restore only CCS-created pauses. + +### FR-010: Docker deployment + +- Publish an integrated multi-architecture image containing CCS, CLIProxy, and + the dashboard. +- Persist CCS state and service logs in declared volumes. +- Expose dashboard and CLIProxy service ports. +- Health-check both services. +- Do not bundle target AI CLIs into the integrated image; consumers that need + them run sibling containers or install them separately. + +### FR-011: Shell and editor integration + +- Export shell-safe environment values through `ccs env`. +- Support the documented shell output formats. +- Keep persistent shared settings behind `ccs persist`. +- Keep editor-specific writes scoped to the selected editor and user layer. ## Non-Functional Requirements -### NFR-001: Performance -- CLI startup < 100ms -- Dashboard load < 2s -- Minimal memory footprint +### NFR-001: Security + +- Do not print or log credentials. +- Bind local proxy services to loopback unless the user explicitly selects a + deployment that exposes them. +- Validate file types and use safe replacement semantics for managed sensitive + configuration. +- Keep remote transport and authentication choices explicit. ### NFR-002: Reliability -- Idempotent operations -- Graceful error handling -- Automatic recovery where possible -### NFR-003: Security -- Local-only proxy binding (127.0.0.1) -- No credential exposure in logs -- Secure token storage +- Make setup and repair operations idempotent where practical. +- Preserve existing user state when merging managed configuration. +- Report recovery actions in actionable error messages. +- Clean up child processes and temporary runtime state on exit. -### NFR-004: Cross-Platform -- Support Linux, macOS, Windows -- Bash 3.2+, PowerShell 5.1+, Node.js 14+ -- Identical behavior across platforms +### NFR-003: Portability -### NFR-005: Maintainability -- Files < 200 lines (with documented exceptions) -- Domain-based organization -- Barrel exports for clean imports -- 90%+ test coverage +- Support macOS, Linux, and Windows for the host CLI where target dependencies + allow. +- Support Node.js 18 or newer, as declared by + [`package.json`](../package.json). +- Support Bun 1.0 or newer for development and supported runtime workflows. +- Keep terminal output ASCII-only and respect `NO_COLOR` and TTY detection. ---- +### NFR-004: Maintainability -## Technical Requirements +- Keep provider metadata centralized. +- Use target adapters instead of target checks scattered through dispatch code. +- Validate CLI, server, and dashboard contracts with focused tests. +- Keep generated or volatile inventories out of evergreen architecture prose. -### TR-001: Runtime Dependencies -- Node.js 14+ or Bun 1.0+ -- Claude Code CLI installed -- Internet access for OAuth/API calls +## Runtime and Deployment Requirements -### TR-002: Optional Dependencies -- CLIProxyAPI binary (auto-managed) -- Exa/Tavily/Brave API keys for higher-quality WebSearch -- Gemini CLI for legacy WebSearch fallback -- Bun plus Claude Code v2.1.80+ with `claude.ai` auth for Official Channels auto-enable - -### TR-003: Configuration -- YAML-based config (`~/.ccs/config.yaml`) -- JSON settings per profile -- Environment variable overrides -- Official channel bot tokens stored in Claude-managed `~/.claude/channels//.env` - ---- +| Surface | Requirement | +| --- | --- | +| Host npm install | Node.js 18+ | +| Development and repository gates | Bun 1.0+ and Node.js 18+ | +| Claude launches | Claude Code installed and authenticated as required by the selected profile | +| Droid launches | Factory Droid installed | +| Codex launches | Codex CLI installed | +| Local OAuth proxy | CCS-managed CLIProxy binary | +| Integrated Docker | Docker or compatible container runtime; target CLIs are not bundled | ## Architecture Constraints -### AC-001: CLI-First Design -- All features accessible via CLI -- Dashboard is convenience layer, not required -- Scriptable and automatable +### AC-001: Profile state and shared settings are separate -### AC-002: Non-Invasive -- Never modify `~/.claude/settings.json` -- Use environment variables for configuration -- Reversible changes only +Normal launches consume CCS-owned profile files and environment state. +`~/.claude/settings.json` is written only through an explicit persistence or +approved settings-management workflow. -### AC-003: Proxy Pattern -- Use local proxy for provider routing -- Claude CLI communicates with localhost -- Proxy handles upstream API calls +### AC-002: Provider and target are independent axes ---- +A provider supplies credentials and routing. A target adapter determines how a +compatible CLI receives them. Unsupported combinations must fail clearly. -## Success Metrics +### AC-003: Proxy trust boundary is visible -| Metric | Target | Current | -|--------|--------|---------| -| Startup time | < 100ms | Achieved | -| Dashboard load | < 2s | Achieved | -| Error rate | < 1% | Achieved | -| Test coverage | > 90% | 90% (1440 tests, 6 skipped) | -| File size compliance | 100% < 200 lines | 95% | +Local CLIProxy, remote CLIProxy, and direct API profiles have different +transport and credential boundaries. CCS must not present them as equivalent or +silently cross those boundaries. ---- +### AC-004: Source owns volatile capability data -## Release Criteria +Provider IDs, aliases, OAuth flow types, callback ports, refresh ownership, +backend restrictions, and quota support must be read from the provider +registries and tests. Documentation describes how to find them rather than +copying a second mutable table. -### v1.0 Release (Complete) -- [x] Multi-account Claude support -- [x] OAuth provider integration (Gemini, Codex, AGY) -- [x] API profile management -- [x] Dashboard UI -- [x] Health diagnostics -- [x] WebSearch fallback -- [x] Cross-platform support +## Acceptance Criteria -### v7.0 Release (Complete) -- [x] OpenRouter integration with 300+ models -- [x] Interactive model picker -- [x] Dynamic model discovery -- [x] Tier mapping (opus/sonnet/haiku) -- [x] Settings page modularization (20 files) -- [x] Analytics page modularization (8 files) -- [x] Auth monitor modularization (8 files) -- [x] Comprehensive test infrastructure (539 CLI + 99 UI tests) - -### v7.1 Release (Complete) -- [x] Remote CLIProxy routing support -- [x] CLI flags for remote proxy (--proxy-host, --proxy-port, etc.) -- [x] Environment variables for proxy config (CCS_PROXY_*) -- [x] Dashboard remote proxy configuration UI -- [x] Connection testing with latency display -- [x] Fallback to local when remote unreachable -- [x] Protocol-based default ports (HTTPS:443, HTTP:8317) - -### v7.2 Release (Complete) -- [x] Kiro (AWS) OAuth provider support via CLIProxyAPIPlus -- [x] GitHub Copilot (ghcp) OAuth provider via Device Code flow (deprecated compatibility) -- [x] Authorization Code flow for Kiro (port 9876) -- [x] Device Code flow for ghcp (no local port needed) - -### v7.14 Release (Complete) -- [x] Hybrid quota management with auto-failover -- [x] `ccs cliproxy pause/resume/status` commands -- [x] API tier detection (free/pro/ultra/unknown) -- [x] Dashboard pause/resume toggles and tier badges -- [x] Pre-flight quota checks before session start - -### v7.23 Release (Complete) -- [x] Docker deployment support (PR #345) -- [x] Multi-stage Dockerfile with bun 1.2.21 -- [x] Docker Compose with resource limits and healthcheck -- [x] Persistent volumes for config and credentials -- [x] Pre-installed AI CLI tools (claude, gemini, grok, opencode) -- [x] Entrypoint with privilege dropping - -### v7.34 Release (Complete) -- [x] First-class `ImageAnalysis` MCP tool for third-party launches -- [x] Direct provider-scoped routing for image analysis requests -- [x] Prompt template selection for default / screenshot / document flows -- [x] Hook fallback retained only for compatibility -- [x] Non-fatal native `Read` fallback when managed runtime is unavailable -- [x] `ccs config image-analysis` CLI command -- [x] Doctor integration for hook validation -- [x] 791-line E2E test suite for image analysis -- [x] Performance: Replace busy-wait with Atomics.wait in config lock -- [x] Network error handling with noRetryPatterns -- [x] Quota 429 rate limit handling improvements -- [x] WebSocket maxPayload limit (DoS prevention) - -### v7.39 Release (Complete) -- [x] `ccs env` command for third-party tool integration (OpenCode, Cursor, Continue) -- [x] Multi-format output: openai, anthropic, raw -- [x] Multi-shell support: bash/zsh, fish, PowerShell (auto-detected) -- [x] CLIProxy profile support (gemini, codex, agy, qwen) -- [x] Settings profile support (glm, kimi, custom API) -- [x] Security: single-quoted output, key sanitization, shell-specific escaping -- [x] Shell completion updated (bash, zsh, fish, PowerShell) -- [x] 34 unit tests for env command - -### v8.0 Release (Planned - Q1 2026) -- [ ] Multiple CLIProxyAPI instances (load balancing, failover) -- [ ] Native git worktree support -- [ ] Critical bug fixes (#158, #155, #124) - -### v9.0 Release (Future - Q2 2026) -- [ ] Team collaboration features -- [ ] Cloud sync for profiles -- [ ] Plugin system -- [ ] CLI extension framework - ---- - -## Dependencies - -### External Services -- Anthropic Claude API -- Google Gemini API -- GitHub Codex API -- GitHub Copilot (ghcp - deprecated Device Code OAuth compatibility) -- AWS Kiro (Authorization Code OAuth) -- Z.AI GLM API -- OpenRouter API -- Moonshot Kimi API -- DeepSeek API -- Alibaba Qwen API -- Minimax API -- Azure Foundry API - -### Third-Party Libraries -- Express.js (web server) -- React (dashboard) -- Vite (build tool) -- shadcn/ui (UI components) -- CLIProxyAPI (proxy binary) -- Vitest (testing) - ---- +- A user can create or select a supported profile and launch it on a compatible + target without manual credential-file editing. +- Concurrent account profiles do not share target session state accidentally. +- Local and remote proxy failures follow the configured fallback policy. +- Persistent settings writes preserve unrelated keys and create a recovery + path. +- Dashboard operations produce configuration compatible with CLI operations. +- Release automation publishes only from the documented branches and lanes. +- Documentation links resolve and architecture claims are traceable to source. ## Risks and Mitigations -| Risk | Probability | Impact | Mitigation | -|------|-------------|--------|------------| -| Claude CLI API changes | Medium | High | Version pinning, compatibility layer | -| Provider API deprecation | Low | High | Fallback chain, multiple providers | -| OAuth token expiry | Medium | Medium | Auto-refresh, clear error messages | -| Binary compatibility | Low | Medium | Multi-platform builds, fallback | - ---- +| Risk | Mitigation | +| --- | --- | +| Provider auth contracts change | Central capability registry plus provider-specific tests | +| Target CLI configuration changes | Adapter boundary and compatibility checks | +| Credential leakage | Local storage, redaction, safe file handling, no secret logging | +| Remote proxy outage | Reachability checks and explicit fallback policy | +| Configuration corruption | Validation, backup, locking, and atomic replacement where supported | +| Documentation drift | Link volatile details to code; keep this document version-neutral | ## Related Documentation -- [Codebase Summary](./codebase-summary.md) - Technical structure -- [Code Standards](./code-standards.md) - Development conventions -- [System Architecture](./system-architecture/index.md) - Architecture diagrams -- [Project Roadmap](./project-roadmap.md) - Development phases and GitHub issues +- [Codebase Summary](./codebase-summary.md) +- [Code Standards](./code-standards.md) +- [System Architecture](./system-architecture/index.md) +- [Provider Flows](./system-architecture/provider-flows.md) +- [Release Process](./release-process.md) +- [Project Roadmap](./project-roadmap.md) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 2a212ea8..d96a528d 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,284 +1,51 @@ # CCS Project Roadmap -Last Updated: 2026-05-22 +This repository does not maintain a hand-copied backlog snapshot. Current work +lives in GitHub so status, ownership, and dependencies stay live. -Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. +## Find Current Work ---- +- [Open issues](https://github.com/kaitranntt/ccs/issues) +- [Project boards](https://github.com/users/kaitranntt/projects) +- [Open pull requests](https://github.com/kaitranntt/ccs/pulls) +- [Releases](https://github.com/kaitranntt/ccs/releases) -## Completed Modularization Summary +Filter issues by area and type labels before selecting work. Verify the issue +body, recent comments, and linked pull requests rather than relying on an old +roadmap copy. -All major modularization work is complete. The codebase evolved from monolithic files to a well-structured modular architecture. +## Planning Guidance -| Phase | Description | Key Result | -|-------|-------------|------------| -| 1 | Type System | `src/types/` with barrel exports | -| 2 | CLI Commands | `src/commands/` (8 handlers extracted) | -| 3 | CLIProxy | `src/cliproxy/` with auth/, binary/, services/ subdirs | -| 4 | Utils/Errors | `src/utils/ui/`, `src/errors/`, `src/management/` | -| 5 | UI Components | 5 monster files split into modular dirs (54+ modules) | -| 6 | Settings Page | `pages/settings/` (1,781->20 files) | -| 7 | Analytics Page | `pages/analytics/` (420->8 files) | -| 8 | Auth Monitor | `monitoring/auth-monitor/` (465->8 files) | -| 9 | Test Infrastructure | 1407 tests, 90% coverage | -| 10 | Remote CLIProxy | `proxy-config-resolver.ts`, `remote-proxy-client.ts` | -| 11 | Kiro + legacy ghcp Providers | OAuth support via CLIProxyAPIPlus (v7.2) | -| 12 | Hybrid Quota Management | `quota-manager.ts`, `quota-fetcher.ts` (v7.14) | -| 13 | Docker Support | `docker/` directory with Dockerfile, Compose, entrypoint | -| 14 | Image Analysis Hook | Vision proxying via CLIProxy transformers (v7.34) | -| 15 | Third-Party Tool Integration | `ccs env` command with multi-format export (v7.39) | +1. Confirm the issue still reproduces against `origin/dev`. +2. Read `CLAUDE.md` and the affected source, tests, help, and public docs. +3. Keep the implementation and compatibility boundary explicit. +4. Run focused validation, then the repository quality gate appropriate to the + change. +5. State public-doc impact before handoff. -**Metrics Achieved**: -- Files >500 lines: 12 -> 5 (-58%) -- UI files >200 lines: 28 -> 8 (-71%) -- Barrel exports: 5 -> 39 (+680%) -- Test coverage: 0% -> 90% -- Total tests: 1440 (6 skipped) +Plans are workspace-only and belong under ignored `plans/`; they are not product +roadmap truth. ---- +## Maintainability Work -## Current Status +Use [Hardening Debt Burndown](./hardening-debt-burndown.md) for measurement +methodology. Its generated inventory is an exact source-tree snapshot, not a +historical progress log. -### Recent Fixes +## Stable Product Direction -- **2026-05-22**: **#1337** CLIProxy quota preflight now reconciles the entire - active provider account pool before local proxy startup. Known-exhausted - non-default accounts move out of live `auth/` rotation when another healthy - account exists, preventing round-robin requests from stalling on already - exhausted accounts while preserving manual pause/resume and cooldown - auto-resume behavior. -- **2026-05-22**: **#1332** `ccsx auth` profiles now link native Codex - `agents/` and `skills/` resources alongside `config.toml`, and `ccsx ` - repairs missing resource links at launch so relative agent role `config_file` - entries do not warn on existing profiles. -- **2026-05-20**: **#1285** Codex model picker aliases now include `minimal` and `low` reasoning effort suffixes alongside `medium`, `high`, and `xhigh`, and the dashboard separates base recommendations, reasoning variants, and fast variants so low-effort selections such as `gpt-5.5-low` are visible. -- **2026-05-19**: **#1252** Claude extension persistence now rejects Codex CLIProxy profiles instead of writing `ANTHROPIC_BASE_URL=/api/provider/codex` into Claude Code settings. Native Codex subscription use stays on `ccsxp` or `ccs codex --target codex`, and `ccs doctor` warns when stale Claude settings still point at the Codex translator. -- **2026-05-18**: **#1287** `ccsx resume` now passes through to the native Codex `resume` subcommand before CCS profile detection, preserving Codex session continuation while keeping `ccsx auth` on the managed Codex profile namespace. -- **2026-05-09**: **#1199** Existing Claude auth accounts now have dashboard-visible Shared Resources controls separate from History Sync. Accounts exposes a dedicated Resources action for `shared` vs `profile-local`, `/shared` now inventories commands, skills, agents, plugins, and `settings.json`, plugin directories without docs show factual directory contents, and shared settings content is inspectable read-only through the localhost-gated shared-content API. -- **2026-05-07**: **#760** Codex GPT fast mode is now a first-class CLIProxy model tuning suffix. CCS accepts `gpt-5.4-fast`, `gpt-5.4-high-fast`, and equivalent canonicalized forms in raw env configs, CLI variant creation, and the dashboard model picker; runtime requests now send the base upstream model with `reasoning.effort` plus `service_tier: "priority"` instead of leaking the suffixed alias to CLIProxy upstream routing. -- **2026-05-07**: **#1103** GitHub Copilot is now treated as a deprecated compatibility bridge. The dashboard moves Copilot out of the active Identity & Access section and into Deprecated, quick setup no longer offers `ghcp` for new onboarding, CLI/help/config copy marks Copilot as deprecated, and existing `ccs copilot` / `ghcp` compatibility paths remain available for current setups. -- **2026-05-07**: **#1189** Headless settings-profile delegation now preserves native Claude passthrough args without a Claude flag allowlist. Explicit `--channels` values reach Claude Code, future native flags can carry multiple adjacent values, malformed CCS-owned flags no longer swallow the next native flag, and `--prompt=` routes through headless delegation consistently with `--prompt `. -- **2026-05-03**: **#1172** Local CLIProxy config generation now keeps the CPAMC management dashboard aligned with backend selection. `backend: original` points to the upstream dashboard, `backend: plus` points to the CCS-maintained CPAMC fork, `cliproxy.management_panel_repository` lets advanced users override the panel repository, and stale generated configs are regenerated when the expected panel source changes. -- **2026-04-30**: **#1153** Native Claude launches now accept session-scoped `--effort low|medium|high|xhigh|max` overrides through CCS without mutating global Claude settings. CCS validates invalid or missing effort values before spawning Claude, normalizes accepted values, keeps default headless `-p/--prompt` launches on native Claude instead of delegation parsing, and preserves CLIProxy/Codex/Droid effort aliases. -- **2026-04-28**: **#1123** CLIProxy quota failover now uses the dashboard/manual pause mechanism for all quota-visible OAuth providers with CCS quota fetchers: Antigravity, Claude, Codex, Gemini CLI, and GitHub Copilot. When a healthy fallback exists, CCS moves the exhausted account token out of the live `auth/` folder into `auth-paused/`, marks the account paused for dashboard visibility, persists the cooldown for auto-resume, and still avoids self-pausing the last usable account. -- **2026-04-28**: **#1115** CCS now exposes upstream CLIProxy session affinity as a first-class local managed setting. Users can inspect and toggle local `session-affinity` plus TTL from `ccs cliproxy routing affinity`, from the `/cliproxy` dashboard routing card, and through the local dashboard API. The generated local CLIProxy config now persists `routing.session-affinity` and `routing.session-affinity-ttl`, help/copy explains that CLIProxy prefers explicit session or thread identifiers before falling back to prompt-history hashing, and remote session-affinity management stays explicitly unsupported until upstream management APIs expose more than `routing.strategy`. -- **2026-04-24**: **#1065** Local CLIProxy Plus is available again as an explicit opt-in backend through the community-maintained `kaitranntt/CLIProxyAPIPlus` fork. CCS keeps `original` as the default backend, no longer downgrades saved `backend: plus` configs to `original`, updates Plus release lookups to the maintained fork, and documents Plus as a targeted path for plus-only providers. -- **2026-04-21**: CLIProxy quota failover now quarantines exhausted Claude and Antigravity accounts out of live rotation when a healthy fallback exists. CCS persists those quota-triggered pauses across launches, automatically resumes them after the configured cooldown window, and deliberately avoids auto-pausing the last available account so single-account setups still degrade gracefully instead of hard-locking themselves. -- **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in. -- **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser..policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config. -- **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default. -- **2026-04-18**: **#1038** Legacy OpenAI-compatible provider writes no longer self-destruct on the next `ccs cliproxy restart`. CCS now preserves AI-provider-managed top-level sections such as `openai-compatibility` during CLIProxy config regeneration, and the legacy `openai-compat` manager now rewrites only its own YAML section instead of dumping the whole file and stripping the generated version header. Regression coverage now proves the legacy helper keeps the generated header intact and that OpenAI-compatible connectors survive regeneration. -- **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor. -- **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads. -- **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell. -- **2026-04-14**: **#991** CCS now auto-routes Claude-target settings profiles that use OpenAI-compatible endpoints through a local Anthropic-compatible proxy instead of sending raw Anthropic `/v1/messages` traffic directly to chat-completions backends. The `ccs proxy` command now supports `start`, `status`, `activate`, and `stop` with explicit host binding, shell-aware activation helpers, and a fuller local runtime env contract. The proxy surface now exposes `GET /`, `/health`, `/v1/models`, and `/v1/messages`, logs routing decisions into CCS structured logs, supports Anthropic image blocks plus request-time `profile:model` overrides, and adds config-driven scenario routing (`background`, `think`, `longContext`, `webSearch`) on top of the compatible-profile path. Coverage now includes request routing, rate-limit/timeout/empty-upstream failures, chunked tool-call streaming, and disconnect cleanup alongside the existing unit, integration, and e2e suites. -- **2026-04-10**: **#765** `/providers` now includes a first-class Hugging Face preset for API Profiles. CCS exposes Hugging Face Inference Providers through the existing OpenAI-compatible profile flow with the official router endpoint `https://router.huggingface.co/v1`, a short `hf` default profile name, and `hf` preset alias support for both the dashboard chooser and `ccs api create --preset hf`. -- **2026-04-10**: **#944** Image Analysis auth readiness no longer collapses to native Read when merged runtime-status dependency overrides include a missing initializer value. CCS now preserves default dependency functions when override entries are `undefined`, still reads token-backed auth status directly in the local readiness path, and includes regression coverage for the missing-initializer case that previously surfaced as `deps.initializeAccounts is not a function`. -- **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity. -- **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings. -- **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank. -- **2026-04-08**: **#929** Image Analysis hardening now makes the managed `ccs-image-analysis` MCP path authoritative on healthy Claude-target launches, suppresses stale CCS-managed image `Read` hooks instead of letting them compete with MCP, keeps the legacy hook available only as compatibility fallback when MCP provisioning fails, and extends self-heal to dashboard provisioning plus `ccs doctor --fix` so stale hook files and missing isolated MCP sync are repaired automatically. -- **2026-04-07**: CLIProxy routing strategy is now a first-class CCS surface. Users can inspect and explicitly change `round-robin` vs `fill-first` from `ccs cliproxy routing` and from a native `/cliproxy` dashboard card. Local mode now persists the chosen startup default into CCS-managed CLIProxy config generation, while untouched installs remain on `round-robin`. CCS deliberately does not infer strategy from account composition. -- **2026-04-07**: **#926** CCS now has a first-class structured logging layer under `src/services/logging/`, a bounded top-level `logging` config section in `~/.ccs/config.yaml`, automatic rotation/retention for CCS-owned logs under `~/.ccs/logs/`, native `/api/logs` dashboard endpoints, request tracing for the dashboard backend, and a dedicated `System -> Logs` dashboard route for browsing recent entries and editing retention settings. Legacy CLIProxy error files remain available as a labeled legacy source instead of acting as the primary logging model. -- **2026-04-06**: The dashboard login surface now distinguishes a real sign-in from a host-setup requirement. Remote/IP visitors no longer see a misleading blank credential form when dashboard auth is disabled or incomplete; they now get explicit guidance that CCS has no default credentials, should be enabled on the host with `ccs config auth setup`, or should be reopened via localhost when used on the same machine. The password field now includes a show/hide toggle, and the page exposes an explicit light/dark theme switch before sign-in. -- **2026-04-04**: The GitHub README was reduced from a wall-of-text reference dump into a shorter conversion surface that keeps the hero, proof screenshots, and fast-start commands while delegating deeper installation, provider, feature, and CLI-reference content to `docs.ccs.kaitran.ca`. The docs site now includes a dedicated `Product Tour` page for the screenshot-led walkthrough. -- **2026-04-05**: **#912 #913 #914** Kiro auth is now aligned with the current CLIProxyAPIPlus contract. CCS auto-selects the Builder ID path for the default `ccs kiro --auth` flow instead of stalling on the upstream Builder ID vs IDC chooser, callback-based Kiro auth methods can use `--paste-callback` by replaying the pasted redirect URL back into the local callback server, and the CLI now supports IDC auth via `--kiro-auth-method idc` plus `--kiro-idc-start-url`, `--kiro-idc-region`, and `--kiro-idc-flow`. -- **2026-04-03**: CCS CLI help and completion UX was refreshed. Root help is now shorter and task-oriented, `ccs help ` routes to topic-aware help, and shell completions now delegate to the hidden `ccs __complete` backend. -- **2026-04-02**: Third-party image and PDF analysis now follows the same first-class local-tool model as WebSearch. CCS provisions `ccs-image-analysis` as a managed MCP tool, routes requests directly to provider-scoped CCS endpoints such as `/api/provider/agy/v1/messages`, keeps editable prompt templates under `~/.ccs/prompts/image-analysis/`, and demotes the old `Read` hook to a best-effort compatibility fallback. Launches now stay non-fatal and fall back to native `Read` when the managed runtime cannot be prepared. -- **2026-04-01**: The `Compatible -> Codex CLI` dashboard now exposes manual long-context controls for `model_context_window` and `model_auto_compact_token_limit`. CCS reads and patches those upstream Codex config keys directly, adds official guidance that GPT-5.4 long context is experimental and opt-in, and keeps the behavior manual-only so the dashboard never auto-fills or auto-saves long-context values for the user. -- **2026-03-30**: **#862** Third-party WebSearch now uses a first-class CCS-managed MCP tool path instead of relying on a denied native Anthropic `WebSearch` call as the normal UX. CCS provisions `ccs-websearch` into `~/.claude.json`, syncs it into isolated account configs when needed, suppresses native `WebSearch` on third-party launches, preserves the provider order `Exa -> Tavily -> Brave -> DuckDuckGo -> legacy CLI fallback`, and keeps the old hook runtime only as shared provider plumbing plus compatibility fallback. Uninstall cleanup now also removes the managed WebSearch MCP runtime. -- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, refresh/discard recovery for stale raw drafts, dirty raw-editor guarding for structured controls, project-trust path validation, read-only handling for unreadable config files, preservation of unsupported upstream values such as granular `approval_policy`, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file. -- **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow. -- **2026-03-27**: **#812** CCS now includes a first-class `ccs docker` command suite for self-hosting the integrated Dashboard + CLIProxy stack. The CLI can stage bundled Docker assets locally or to a remote `--host` over SSH, report compose/supervisor status, stream CCS or CLIProxy logs, and run in-container update flows without relying on ad-hoc deployment scripts. -- **2026-03-24**: Official Claude Channels now follow Anthropic's actual runtime contract. CCS blocks auto-enable unless Bun is available, Claude Code is verified at v2.1.80+, and `claude.ai` auth is verified; treats `--allow-dangerously-skip-permissions` as an explicit override; keeps Telegram/Discord bot tokens in Claude's shared `~/.claude/channels/` state (or official `*_STATE_DIR` overrides); and upgrades the dashboard/CLI status flow with Bun/version/auth/state-scope guidance, safer token draft retention on refresh failures, and a non-macOS iMessage toggle that can still be turned off when already selected. -- **2026-03-23**: CLIProxy providers that do not expose an email no longer require a user-supplied nickname on first auth. CCS now derives a stable internal account identifier for Kiro/Copilot-style flows, preserves later rename support, hardens account discovery/registry sync around that identifier, and updates AI Provider CRUD to use stable entry IDs instead of dashboard list indexes. -- **2026-03-23**: Sensitive dashboard management routes now fail closed to localhost-only access whenever dashboard auth is disabled. Remote access remains available after `ccs config auth setup`, but AI Provider management, CLIProxy auth/status helpers, and other write-capable settings endpoints no longer trust unauthenticated non-loopback requests. -- **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles. -- **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback. -- **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint. -- **#748**: API profile creation now keeps provider selection compact by collapsing advanced presets behind an explicit toggle, shrinking chooser cards so the form fields stay visually primary, and giving `llama.cpp` a dedicated provider logo. -- **#744**: API profile creation now keeps featured providers in a horizontal rail with scroll fallback, moves Anthropic Direct API to the end, reuses the shared Claude logo, and separates the custom-endpoint entry point from advanced template discovery. -- **#724**: Codex startup is now free-plan safe. CCS defaults new Codex sessions to a cross-plan model and uses runtime fallback handling for unsupported paid-only models without rewriting the saved dashboard settings. -- **#737**: Dashboard model pickers in Cursor, Copilot, and CLIProxy now use a searchable combobox with autofocus and explicit no-results states for large model catalogs. -- **#736**: `ccs config` now supports explicit dashboard bind hosts via `--host`, and surfaces remote-access warnings plus reachable URLs when the effective bind is non-loopback. -- **#1121**: Usage analytics pricing now refreshes cached models.dev metadata before cost derivation, keeps CCS static pricing as the offline fallback, and carries provider identity through CLIProxy/native runtime breakdowns so subscription-backed providers do not inherit paid API pricing. +CCS remains a CLI-first profile and runtime manager with dashboard parity for +configuration. New work should preserve: -### Maintainability Hardening Kickoff - -- Issue owner: Stream D for **#542** -- Automated inventory command: `bun run report:hardening` -- Generated report artifacts: - - `docs/reports/hardening-inventory.json` - - `docs/reports/hardening-inventory.md` -- Debt burndown tracker: [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md) - -### Remaining Large Files (Acceptable) - -**CLI** (complex core logic): -- `model-pricing.ts` (676 lines) - Data file -- `glmt-proxy.ts` (675 lines) - Legacy internal compatibility proxy -- `cliproxy-executor.ts` (666 lines) - Core execution -- `ccs.ts` (596 lines) - Entry point - -**UI** (external/shadcn): -- `components/ui/sidebar.tsx` (674 lines) - shadcn component - ---- - -## GitHub Issues Backlog - -### Critical (Blocking Users) - -| Issue | Title | Type | -|-------|-------|------| -| #158 | AGY not working - Missing API Key - Run /login | bug | -| #155 | Invalid JSON payload error with Gemini/Antigravity | bug | -| #124 | Incorrect model ID for Claude 3.5 Sonnet (Thinking) | bug | - -### High Priority (Features) - -| Issue | Title | Type | Status | -|-------|-------|------|--------| -| #142 | Configure with available CLIProxyAPI | enhancement | **COMPLETE** (v7.1) | -| #157 | Support for Kiro auth from CLIProxyAPIPlus | enhancement | **COMPLETE** (v7.2) | -| #123 | Add More Models | enhancement | Ongoing | -| #114 | OpenCode Zen Free model + Auto Rotation API Key | enhancement | - | - -### Medium Priority - -| Issue | Title | Type | -|-------|-------|------| -| #137 | CCS Cannot Connect to IDE, but Native Claude Works | support | -| #89 | Add Claude Code CLI flag passthrough for delegation | enhancement | -| #659 | Comprehensive Vietnamese i18n for dashboard | enhancement | - -### Low Priority / Questions - -| Issue | Title | Type | -|-------|-------|------| -| #156 | Configure API for Zed IDE | docs | -| #140 | Do we support ampcode? | question | -| #111 | Factory droid CLI support | enhancement | -| #103 | /context command returns incorrect context | invalid | - ---- - -## Future Roadmap - -### Priority 1: Multiple CLIProxyAPI Instances - -Support connecting to multiple CLIProxyAPI servers simultaneously. - -**Use Cases**: -- Load balancing across multiple proxy servers -- Failover when primary server unavailable -- Geographic distribution for latency optimization -- Separate proxies for different provider groups - -**Proposed Config**: -```yaml -cliproxy: - instances: - primary: - url: http://localhost:8000 - providers: [gemini, codex] - weight: 80 - secondary: - url: http://192.168.1.100:8000 - providers: [agy] - weight: 20 - failover: - url: http://backup.example.com:8000 - priority: 2 # Only if primary/secondary fail - strategy: weighted-round-robin -``` - -### Priority 2: Native Git Worktree Support - -Opt-in automatic git worktree management for features/issues. - -**Use Cases**: -- Automatic worktree creation when starting issue -- Isolation of feature development -- Easy cleanup after merge -- Integration with GitHub issues - -**Proposed Settings**: -```yaml -worktrees: - enabled: true - base_path: ~/.ccs/worktrees - auto_create: true - auto_cleanup: true - naming: "{issue-number}-{short-title}" -``` - -### Priority 3: Enhanced Model Support - -- **#123**: Expand model catalog with new releases -- **#124**: Fix Claude 3.5 Sonnet (Thinking) model ID -- **#114**: OpenCode Zen free model + API key rotation - -### Priority 4: IDE Integration - -- **#137**: Debug CCS-to-IDE connection issues -- **#156**: Zed IDE configuration documentation -- **#140**: Investigate ampcode compatibility -- **#111**: Factory droid CLI support assessment - -### Priority 5: Authentication Enhancements - -- **#158**: Fix AGY OAuth flow -- **#157**: ~~Add Kiro auth support from CLIProxyAPIPlus~~ **COMPLETE** (v7.2) -- GitHub Copilot (ghcp) Device Code flow **COMPLETE** (v7.2, now deprecated compatibility) -- Hybrid quota management **COMPLETE** (v7.14) - ---- - -## Milestones - -| Milestone | Status | Target | -|-----------|--------|--------| -| Modularization (Phases 1-9) | COMPLETE | - | -| Remote CLIProxy Support (#142) | COMPLETE | v7.1 | -| Kiro + GitHub Copilot OAuth (#157) | COMPLETE, Copilot now deprecated compatibility | v7.2 | -| Hybrid Quota Management | COMPLETE | v7.14 | -| Docker Support (PR #345) | COMPLETE | v7.23 | -| Image Analysis Hook | COMPLETE | v7.34 | -| Third-Party Tool Integration | COMPLETE | v7.39 | -| Critical Bug Fixes (#158, #155, #124) | PLANNED | Q1 2026 | -| Multiple CLIProxyAPI Instances | PLANNED | Q1 2026 | -| Git Worktree Support | PLANNED | Q2 2026 | -| Enhanced Model Support | PLANNED | Q2 2026 | - ---- - -## Success Criteria - -All criteria achieved: - -- [x] Files under 200 lines (except documented exceptions) -- [x] Every directory has barrel export -- [x] No circular dependencies -- [x] TypeScript strict mode passing -- [x] 90%+ test coverage -- [x] Clear domain boundaries -- [x] Consistent naming conventions - -## Historical Maintainability Gate (Retired) - -This section is preserved as historical context from the original Issue `#539` work. - -- The maintainability baseline gate is no longer part of the active CCS workflow. -- Current contributor and CI gates are documented in `CLAUDE.md`, `CONTRIBUTING.md`, and the GitHub workflow files. -- Do not assume `maintainability:baseline` or `maintainability:check` exist unless they are reintroduced in a future follow-up. - ---- +- isolated account/profile state; +- explicit provider and runtime selection; +- actionable, ASCII-safe terminal errors; +- compatible CLI, dashboard, and public-doc contracts; +- safe local defaults for credentials, browser access, and proxy routing. ## Related Documentation -- [Codebase Summary](./codebase-summary.md) - Current structure -- [Code Standards](./code-standards.md) - Patterns and conventions -- [System Architecture](./system-architecture/index.md) - Architecture diagrams -- [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md) - Legacy shim + sync-fs debt tracking -- [CLAUDE.md](../CLAUDE.md) - AI development guidance +- [Codebase Summary](./codebase-summary.md) +- [Code Standards](./code-standards.md) +- [System Architecture](./system-architecture/index.md) +- [CLAUDE.md](../CLAUDE.md) diff --git a/docs/release-process.md b/docs/release-process.md index bdbd7ba2..f83a5e58 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,106 +1,117 @@ # CCS Release Process -CCS uses a decoupled release model: every merge to `main` immediately publishes -a stable npm `@latest` release and an immutable Docker `:` tag. Docker -mutable tags (`:latest`, `:`, `:`) require a separate manual -promote step after an operator-verified soak window. This decouples the npm -ecosystem from the Docker stability gate. +CCS has separate development, stable npm, and Docker promotion lanes. A branch +push starts the relevant workflow. Eligible `dev` pushes publish the next custom +development prerelease after their gates pass; `main` publishes only when +semantic-release finds release-worthy commits. -## Phase 1 — Automatic stable release (on every merge to `main`) +## Release lanes -1. A PR is merged into `main` with a conventional commit (`feat:`, `fix:`, etc.). -2. `release.yml` triggers semantic-release, which reads `.releaserc.cjs`. -3. Because `main` is a stable channel, semantic-release cuts a GitHub release - tagged `vX.Y.Z` and publishes the npm package to the `@latest` dist-tag - immediately. No rc channel, no soak delay on npm. -4. `docker-release.yml` triggers on the `release: published` event and: - - Validates the tag as stable semver (`vX.Y.Z`). - - Builds the integrated image for `linux/amd64` and `linux/arm64`. - - Pushes **only the immutable** `ghcr.io/kaitranntt/ccs:X.Y.Z` tag. - - Signs the image with cosign (keyless OIDC). - - Runs smoke tests (`smoke-test` job). - - Mutable tags (`:latest`, `:`, `:`) are **not** added at - this stage — `promote-mutable-tags` only runs on explicit - `workflow_dispatch` with `promote_to_latest=true`. +| Source | Workflow | Result | +| --- | --- | --- | +| Push to `dev` | [`dev-release.yml`](../.github/workflows/dev-release.yml) | Custom development prerelease and npm `@dev` publication | +| Push to `main` | [`release.yml`](../.github/workflows/release.yml) | Semantic-release stable version, npm `@latest`, tag, and GitHub release when commits require a release | +| Published stable or `rc` GitHub release | [`docker-release.yml`](../.github/workflows/docker-release.yml) | Immutable integrated Docker version tag, signature, and smoke test | +| Manual stable promotion | [`promote-release.yml`](../.github/workflows/promote-release.yml) | Docker `:latest`, major, and minor aliases | +| Stable GitHub release | [`sync-dev-after-release.yml`](../.github/workflows/sync-dev-after-release.yml) | Merge released `main` state back into `dev` | -## Phase 2 — Manual promotion to Docker mutable tags (rc.1 soak window) +## Development prereleases -After the immutable `:` Docker image has soaked (typically 24 h with no -reported issues), the operator promotes mutable tags: +`Dev Release` runs on pushes to `dev` and can also be dispatched manually. +After build and validation gates, it calls +[`scripts/dev-release.sh`](../scripts/dev-release.sh). That script owns the +`-dev.` version sequence and npm `@dev` publication. It is +intentionally separate from the production semantic-release configuration. -1. Verify the immutable image is healthy: +Generated `chore(release): ...` pushes are skipped by the workflow guard to +prevent release recursion. - ```bash - docker pull ghcr.io/kaitranntt/ccs:X.Y.Z - docker run --rm -p 3000:3000 -p 8317:8317 ghcr.io/kaitranntt/ccs:X.Y.Z - # check http://localhost:3000 and http://localhost:8317 - ``` +## Stable npm and GitHub releases -2. Optionally verify the cosign signature: +`Release` runs on `main`. It builds the CLI and dashboard, runs the fast, slow, +and end-to-end gates, then invokes semantic-release with +[`.releaserc.cjs`](../.releaserc.cjs). - ```bash - cosign verify \ - --certificate-identity-regexp "https://github.com/kaitranntt/ccs/.github/workflows/docker-release.yml" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - ghcr.io/kaitranntt/ccs:X.Y.Z - ``` +Semantic-release analyzes commits since the previous stable release: -3. Run the `promote-release` workflow via GitHub Actions UI or CLI: +- `feat` produces at least a minor release; +- `fix`, `hotfix`, `refactor`, and `style` produce patch releases under the + repository rules; +- breaking-change notation produces the appropriate major release; and +- commits without a matching release rule may produce no release. - ```bash - gh workflow run promote-release.yml \ - --field tag=vX.Y.Z - ``` +When a release is required, the lane updates `CHANGELOG.md` and `package.json`, +publishes npm `@latest`, creates the stable Git tag and GitHub release, and +pushes the generated release commit to `main`. Do not bump versions or create +release tags manually. - This dispatches `docker-release.yml` with `promote_to_latest=true`, which - triggers the `promote-mutable-tags` job to add `:latest`, `:`, and - `:` via `docker buildx imagetools create`. +## Docker publication and promotion - Alternatively, dispatch `docker-release.yml` directly: +The supported integrated image is `ghcr.io/kaitranntt/ccs`. - ```bash - gh workflow run "Publish Docker Image" \ - --field tag=vX.Y.Z \ - --field promote_to_latest=true - ``` +On a published stable `vX.Y.Z` or release-candidate `vX.Y.Z-rc.N` GitHub +release, `Publish Docker Image`: -## Why npm and Docker have different soak windows +1. validates the release tag; +2. checks out that tag; +3. builds the integrated image for `linux/amd64` and `linux/arm64`; +4. publishes only the matching immutable version tag; +5. signs the image digest with keyless cosign; and +6. smoke-tests the published image. -- **npm `@latest`**: Published immediately on every `main` merge. npm users who - pin a version are unaffected; users who run `npm install -g @kaitranntt/ccs` - get the latest immediately. Rollback is `npm install -g @kaitranntt/ccs@X.Y.Z`. -- **Docker `:latest`**: Promoted only after operator confirmation. Users who - pull `:latest` or run `docker pull` without a pinned tag are shielded from - a bad image. The immutable `:` tag is always available for pinned usage - from the moment of release. - -## Verifying the promotion +Mutable aliases are a separate operator decision. After verifying the immutable +image and allowing the desired soak period, dispatch `promote-release.yml`: ```bash -# Confirm :latest points to the promoted digest -docker buildx imagetools inspect ghcr.io/kaitranntt/ccs:latest +gh workflow run promote-release.yml --field tag=vX.Y.Z +``` -# Confirm npm @latest updated (happens automatically at Phase 1) +The promotion workflow verifies that the stable GitHub release and immutable +image exist, then dispatches `docker-release.yml` with +`promote_to_latest=true`. The promotion job creates `:latest`, `:X`, and +`:X.Y` aliases from the immutable image digest. + +The deprecated `ccs-dashboard` image has its own sunset compatibility job. +Do not use its tag behavior as the contract for the supported integrated image. + +## Post-release development sync + +A published, non-prerelease `vX.Y.Z` release targeting `main` triggers +`Sync Dev After Main Release`. The workflow merges `main` into `dev`, resolves +known generated version-file conflicts in favor of the released `main` state, +and pushes `dev`. That push intentionally triggers the normal `Push CI` and +development-release lanes. + +## Verification + +```bash +# npm channels npm view @kaitranntt/ccs dist-tags + +# immutable integrated image +docker buildx imagetools inspect ghcr.io/kaitranntt/ccs:X.Y.Z + +# mutable alias after promotion +docker buildx imagetools inspect ghcr.io/kaitranntt/ccs:latest ``` -## Rollback +Verify the GitHub Actions run and tag point to the expected commit before +announcing a release. -If a promoted release is found to be bad: +## Recovery -```bash -# Repoint :latest to the previous known-good immutable tag -docker buildx imagetools create \ - --tag ghcr.io/kaitranntt/ccs:latest \ - ghcr.io/kaitranntt/ccs:PREVIOUS.VERSION +- **Bad npm release:** publish a corrected patch. Do not unpublish a version + used by downstream consumers. +- **Bad immutable Docker image:** leave the immutable tag unchanged and publish + a corrected version. +- **Bad mutable Docker promotion:** promote a known-good immutable digest back + to the mutable aliases through the controlled workflow. +- **Failed `dev` sync:** repair the merge against current `main` and `dev`; + never overwrite branch history. -# For npm, publish a fix as a new patch release (do not unpublish) -# Unpublishing npm packages causes downstream breakage for pinned consumers. -``` +## Branch and tag summary -## Branch / tag taxonomy - -| Branch | Semantic-release channel | npm dist-tag | Docker tag (on release event) | Docker mutable (on promote) | -|--------|--------------------------|--------------|-------------------------------|------------------------------| -| `main` | stable | `@latest` | `:` (immutable, immediate) | `:latest`, `:`, `:` (after soak) | -| `dev` | `dev` prerelease | `@dev` | not published | not published | +| Branch | Package channel | npm dist-tag | Integrated Docker | +| --- | --- | --- | --- | +| `dev` | Development prerelease | `@dev` | None | +| `main` | Stable semantic release | `@latest` | Immutable tag on GitHub release; mutable aliases after manual promotion | diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index e614b57a..1a238299 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,113 +1,25 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2301, - "filesAffected": 247, - "hotpathOccurrences": 1946, - "hotpathFilesAffected": 197, + "totalOccurrences": 2471, + "filesAffected": 261, + "hotpathOccurrences": 1186, + "hotpathFilesAffected": 155, "topHotpathFiles": [ { - "file": "src/cliproxy/__tests__/pool-routing-phase3.test.ts", - "count": 96, + "file": "src/management/shared-manager/diverged-file-adopter.ts", + "count": 39, "calls": [ - "existsSync", - "mkdirSync", - "mkdtempSync", + "closeSync", + "fsyncSync", + "linkSync", + "lstatSync", + "openSync", + "readdirSync", "readFileSync", - "rmSync", - "writeFileSync" - ], - "markers": [] - }, - { - "file": "src/cliproxy/config/__tests__/config-generator.test.js", - "count": 88, - "calls": [ - "existsSync", - "mkdirSync", - "mkdtempSync", - "readFileSync", - "rmSync", - "writeFileSync" - ], - "markers": [] - }, - { - "file": "src/cliproxy/config/__tests__/claude-model-neutral.test.ts", - "count": 63, - "calls": [ - "existsSync", - "mkdirSync", - "mkdtempSync", - "readFileSync", - "rmSync", + "readlinkSync", + "renameSync", "statSync", - "writeFileSync" - ], - "markers": [] - }, - { - "file": "src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts", - "count": 48, - "calls": [ - "existsSync", - "mkdirSync", - "mkdtempSync", - "readFileSync", - "rmSync", - "writeFileSync" - ], - "markers": [] - }, - { - "file": "src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts", - "count": 45, - "calls": [ - "existsSync", - "mkdirSync", - "mkdtempSync", - "readdirSync", - "readFileSync", - "rmSync", - "writeFileSync" - ], - "markers": [] - }, - { - "file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js", - "count": 36, - "calls": [ - "existsSync", - "mkdirSync", - "readdirSync", - "readFileSync", - "rmSync", - "unlinkSync", - "writeFileSync" - ], - "markers": [] - }, - { - "file": "src/cliproxy/executor/__tests__/composite-variant-service.test.ts", - "count": 33, - "calls": [ - "existsSync", - "mkdirSync", - "mkdtempSync", - "readFileSync", - "rmSync", - "writeFileSync" - ], - "markers": [] - }, - { - "file": "src/cliproxy/executor/__tests__/variant-port-edge-cases.test.js", - "count": 33, - "calls": [ - "existsSync", - "mkdirSync", - "readdirSync", - "rmSync", "unlinkSync", "writeFileSync" ], @@ -130,18 +42,122 @@ "markers": [] }, { - "file": "src/cliproxy/__tests__/session-tracker-port.test.js", - "count": 31, + "file": "src/utils/image-analysis/mcp-installer.ts", + "count": 30, + "calls": [ + "chmodSync", + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "renameSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/utils/claude-symlink-manager.ts", + "count": 27, + "calls": [ + "copyFileSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "readlinkSync", + "renameSync", + "rmSync", + "statSync", + "symlinkSync", + "unlinkSync" + ], + "markers": [] + }, + { + "file": "src/cliproxy/config/env-builder.ts", + "count": 25, "calls": [ "existsSync", "mkdirSync", - "readdirSync", "readFileSync", - "rmSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/management/shared-manager/migrations.ts", + "count": 25, + "calls": [ + "copyFileSync", + "cpSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "symlinkSync", "unlinkSync", "writeFileSync" ], "markers": [] + }, + { + "file": "src/utils/websearch/mcp-installer.ts", + "count": 25, + "calls": [ + "chmodSync", + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "renameSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/cliproxy/services/variant-settings.ts", + "count": 23, + "calls": [ + "existsSync", + "mkdirSync", + "readFileSync", + "renameSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/management/recovery-manager.ts", + "count": 23, + "calls": [ + "copyFileSync", + "existsSync", + "lstatSync", + "mkdirSync", + "renameSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/utils/shell-completion.ts", + "count": 23, + "calls": [ + "appendFileSync", + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "statSync" + ], + "markers": [] } ], "topFilesOverall": [ @@ -212,6 +228,25 @@ ], "markers": [] }, + { + "file": "src/management/shared-manager/diverged-file-adopter.ts", + "count": 39, + "calls": [ + "closeSync", + "fsyncSync", + "linkSync", + "lstatSync", + "openSync", + "readdirSync", + "readFileSync", + "readlinkSync", + "renameSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, { "file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js", "count": 36, @@ -267,26 +302,12 @@ "writeFileSync" ], "markers": [] - }, - { - "file": "src/cliproxy/__tests__/session-tracker-port.test.js", - "count": 31, - "calls": [ - "existsSync", - "mkdirSync", - "readdirSync", - "readFileSync", - "rmSync", - "unlinkSync", - "writeFileSync" - ], - "markers": [] } ] }, "legacyShim": { - "totalMarkers": 427, - "filesAffected": 164, + "totalMarkers": 465, + "filesAffected": 176, "topFiles": [ { "file": "src/auth/profile-detector.ts", @@ -312,6 +333,28 @@ "// Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK" ] }, + { + "file": "src/web-server/usage/native-quota-collector.ts", + "count": 15, + "calls": [], + "markers": [ + "* are used for backward compatibility.", + "* Kept for backward compatibility with existing tests.", + "* Legacy Codex collector — uses the old getDefaultCodexAccountId dep.", + "* List all Claude profiles from the profile registry (merged legacy + unified).", + "* profile name. Kept for backward compatibility with existing tests that stub", + "* When no enumeration deps are injected (legacy mode / old tests that stub only", + "/** Legacy Codex network row builder. */", + "/** Legacy Codex row builder (local quota). */", + "/** Legacy row builder — no surface/profile/is_subscription fields. */", + "// everything except the legacy harness takes the multi-profile path below.", + "// For the network fallback: the legacy getDefaultCodexAccountId is the", + "// Legacy path: backward-compatible with old tests that only inject", + "// Legacy single-profile collectors (unchanged; used by old tests + back-compat)", + "// Multi-profile enumeration is the DEFAULT (production) behavior. The legacy", + "// Use the legacy single-state approach via profile key '__legacy__' to avoid" + ] + }, { "file": "src/utils/config-manager.ts", "count": 13, @@ -373,14 +416,14 @@ "count": 10, "calls": [], "markers": [ - "* - Legacy CLI fallbacks: Gemini, Grok, OpenCode", "* Legacy AI CLI fallbacks remain available for compatibility only.", "* Uses deterministic search backends first, with optional legacy CLI fallback.", "/** Enable Gemini CLI legacy fallback (default: false) */", "/** Enable Grok CLI legacy fallback (default: false - requires GROK_API_KEY) */", "/** Enable OpenCode CLI legacy fallback (default: false) */", - "/** Gemini CLI - optional legacy LLM fallback */", + "/** Gemini CLI - deprecated legacy LLM fallback (retired upstream) */", "/** Grok CLI - optional legacy LLM fallback */", + "/** Model to use (default: gemini-2.5-flash; accepts legacy gemini ids) */", "/** OpenCode - optional legacy LLM fallback */", "// Legacy fields (deprecated, kept for backwards compatibility)" ] @@ -440,20 +483,6 @@ "it('legacy variant does not block port allocation', function () {", "legacy: {" ] - }, - { - "file": "src/cliproxy/config/__tests__/config-generator.test.js", - "count": 7, - "calls": [], - "markers": [ - "'Should preserve lone manual high-version aliases during legacy cleanup'", - "'Should preserve manual aliases after legacy migration has already run'", - "'Should preserve the manual 3.2 compatibility cluster during legacy cleanup'", - "'Should preserve the pruned legacy alias in the migration backup'", - "assert(fs.existsSync(backupPath), 'Should keep a backup of the legacy config before pruning');", - "it('prunes partially retained broad guessed ranges during legacy cleanup', () => {", - "it('writes a one-time backup before pruning legacy Gemini aliases during migration', () => {" - ] } ], "explicitShimFiles": [ @@ -468,11 +497,11 @@ }, "maintainability": { "typedErrors": { - "totalThrows": 431, - "typedThrows": 37, - "plainThrows": 336, - "otherThrows": 58, - "adoptionRatio": 0.0858, + "totalThrows": 454, + "typedThrows": 83, + "plainThrows": 311, + "otherThrows": 60, + "adoptionRatio": 0.1828, "topSubdomainsByThrows": [ { "subdomain": "web-server", @@ -480,6 +509,12 @@ "typed": 16, "plain": 45 }, + { + "subdomain": "commands", + "count": 38, + "typed": 6, + "plain": 30 + }, { "subdomain": "proxy", "count": 38, @@ -487,10 +522,10 @@ "plain": 34 }, { - "subdomain": "commands", - "count": 33, - "typed": 0, - "plain": 32 + "subdomain": "codex-auth", + "count": 35, + "typed": 4, + "plain": 27 }, { "subdomain": "utils", @@ -498,23 +533,23 @@ "typed": 0, "plain": 33 }, - { - "subdomain": "codex-auth", - "count": 31, - "typed": 0, - "plain": 27 - }, { "subdomain": "targets", "count": 27, - "typed": 0, - "plain": 25 + "typed": 9, + "plain": 16 }, { "subdomain": "cursor", "count": 23, - "typed": 0, - "plain": 21 + "typed": 4, + "plain": 17 + }, + { + "subdomain": "cliproxy/auth", + "count": 16, + "typed": 16, + "plain": 0 }, { "subdomain": "cliproxy/accounts", @@ -527,12 +562,6 @@ "count": 12, "typed": 12, "plain": 0 - }, - { - "subdomain": "cliproxy/ai-providers", - "count": 12, - "typed": 0, - "plain": 12 } ] }, @@ -543,15 +572,15 @@ "web-server/routes", "auth" ], - "numerator": 21, - "denominator": 23, - "ratio": 0.913, + "numerator": 28, + "denominator": 30, + "ratio": 0.9333, "targetRatio": 0.4 }, "loggerCoverage": { - "filesWithCreateLogger": 64, - "totalSourceFiles": 745, - "coverageRatio": 0.0859, + "filesWithCreateLogger": 65, + "totalSourceFiles": 764, + "coverageRatio": 0.0851, "subdomainsWithZeroCreateLogger": [ "api", "bin", @@ -572,21 +601,26 @@ "topSubdomainsByFiles": [ { "subdomain": "web-server", - "count": 111, + "count": 112, "withLogger": 10 }, { "subdomain": "commands", - "count": 107, + "count": 110, "withLogger": 2 }, { "subdomain": "utils", - "count": 87, + "count": 90, "withLogger": 1 }, { "subdomain": "management", + "count": 35, + "withLogger": 1 + }, + { + "subdomain": "cliproxy/auth", "count": 32, "withLogger": 1 }, @@ -595,26 +629,21 @@ "count": 31, "withLogger": 5 }, - { - "subdomain": "cliproxy/auth", - "count": 30, - "withLogger": 1 - }, { "subdomain": "config", "count": 27, "withLogger": 0 }, + { + "subdomain": "codex-auth", + "count": 24, + "withLogger": 9 + }, { "subdomain": "cursor", "count": 24, "withLogger": 2 }, - { - "subdomain": "codex-auth", - "count": 23, - "withLogger": 8 - }, { "subdomain": "auth", "count": 20, @@ -623,10 +652,10 @@ ] }, "hotpathConsoleErrors": { - "totalOccurrences": 569, - "exemptOccurrences": 302, - "hotpathOccurrences": 267, - "filesAffected": 82, + "totalOccurrences": 592, + "exemptOccurrences": 326, + "hotpathOccurrences": 266, + "filesAffected": 81, "topFiles": [ { "file": "src/errors/error-handler.ts", @@ -691,16 +720,20 @@ ] }, "largeFiles": { - "countOver400": 89, - "countOver600": 39, + "countOver400": 91, + "countOver600": 42, "topOver400": [ + { + "file": "src/web-server/usage/native-quota-collector.ts", + "loc": 1758 + }, { "file": "src/web-server/routes/cliproxy-auth-routes.ts", - "loc": 1515 + "loc": 1531 }, { "file": "src/cliproxy/auth/oauth-handler.ts", - "loc": 1455 + "loc": 1510 }, { "file": "src/cursor/cursor-executor.ts", @@ -708,31 +741,31 @@ }, { "file": "src/web-server/model-pricing.ts", - "loc": 1070 + "loc": 1127 }, { - "file": "src/web-server/routes/settings-routes.ts", - "loc": 1041 + "file": "src/cliproxy/config/generator.ts", + "loc": 1109 + }, + { + "file": "src/cliproxy/auth/oauth-process.ts", + "loc": 1048 }, { "file": "src/cliproxy/config/env-builder.ts", - "loc": 1037 + "loc": 1045 + }, + { + "file": "src/web-server/routes/settings-routes.ts", + "loc": 1042 }, { "file": "src/cliproxy/proxy/tool-sanitization-proxy.ts", "loc": 1020 }, - { - "file": "src/cliproxy/auth/oauth-process.ts", - "loc": 1018 - }, - { - "file": "src/cliproxy/config/generator.ts", - "loc": 1012 - }, { "file": "src/commands/cliproxy/variant-subcommand.ts", - "loc": 978 + "loc": 997 }, { "file": "src/cliproxy/quota/quota-manager.ts", @@ -749,10 +782,6 @@ { "file": "src/cliproxy/accounts/registry.ts", "loc": 871 - }, - { - "file": "src/channels/official-channels-runtime.ts", - "loc": 867 } ] } diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index e3f129b8..6928164e 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,33 +6,34 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| 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 | +| Sync fs occurrences (all) | 2471 | +| Sync fs files affected (all) | 261 | +| Sync fs occurrences (runtime hotpaths) | 1186 | +| Sync fs files affected (runtime hotpaths) | 155 | +| Legacy shim markers | 465 | +| Legacy shim files affected | 176 | ## Top Runtime Hotpath Sync fs Files | File | Sync Calls | API Names | |---|---:|---| -| `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/management/shared-manager/diverged-file-adopter.ts` | 39 | closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, 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 | +| `src/utils/image-analysis/mcp-installer.ts` | 30 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | +| `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync | +| `src/cliproxy/config/env-builder.ts` | 25 | existsSync, mkdirSync, readFileSync, writeFileSync | +| `src/management/shared-manager/migrations.ts` | 25 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync, unlinkSync, writeFileSync | +| `src/utils/websearch/mcp-installer.ts` | 25 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | +| `src/cliproxy/services/variant-settings.ts` | 23 | existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync | +| `src/management/recovery-manager.ts` | 23 | copyFileSync, existsSync, lstatSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync | +| `src/utils/shell-completion.ts` | 23 | appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync | ## Top Legacy Shim Marker Files | File | Marker Count | |---|---:| | `src/auth/profile-detector.ts` | 18 | +| `src/web-server/usage/native-quota-collector.ts` | 15 | | `src/utils/config-manager.ts` | 13 | | `src/cliproxy/__tests__/pool-onboarding-phase5.test.ts` | 12 | | `src/cliproxy/executor/__tests__/variant-port-allocation.test.js` | 12 | @@ -41,7 +42,6 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `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 @@ -56,14 +56,14 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | 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 | +| typed-error adoption (typed/total throws) | 18.3% (83/454) | +| typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% | +| hotpath console.error/warn occurrences | 266 (592 total, 326 CLI-UX exempt) | +| hotpath console.error/warn files | 81 | +| files with createLogger | 65/764 | | 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 | +| files > 400 LOC | 91 | +| files > 600 LOC | 42 | ### Top Hotpath console.error/warn Files @@ -89,19 +89,19 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | File | LOC | |---|---:| -| `src/web-server/routes/cliproxy-auth-routes.ts` | 1515 | -| `src/cliproxy/auth/oauth-handler.ts` | 1455 | +| `src/web-server/usage/native-quota-collector.ts` | 1758 | +| `src/web-server/routes/cliproxy-auth-routes.ts` | 1531 | +| `src/cliproxy/auth/oauth-handler.ts` | 1510 | | `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/web-server/model-pricing.ts` | 1127 | +| `src/cliproxy/config/generator.ts` | 1109 | +| `src/cliproxy/auth/oauth-process.ts` | 1048 | +| `src/cliproxy/config/env-builder.ts` | 1045 | +| `src/web-server/routes/settings-routes.ts` | 1042 | | `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/commands/cliproxy/variant-subcommand.ts` | 997 | | `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 | diff --git a/docs/reports/typed-error-exit-code-compat-audit.md b/docs/reports/typed-error-exit-code-compat-audit.md index 9a1af171..6159751c 100644 --- a/docs/reports/typed-error-exit-code-compat-audit.md +++ b/docs/reports/typed-error-exit-code-compat-audit.md @@ -1,26 +1,36 @@ -# Typed-Error Exit-Code Compat Audit (P4) +# Typed-Error Exit-Code Compatibility Audit -Date: 2026-06-18. Phase 4 of the maintainability/traceability epic. +Status: resolved contract. -## Question (open Q1) +## Question 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. +Typed exit codes are wired end-to-end. `handleError` calls the local +`getExitCode` helper and passes the resulting code to `process.exit` in +`src/errors/error-handler.ts`. The CLI entry point delegates uncaught errors to +`handleError` in `src/ccs.ts`. `src/errors/exit-codes.ts` and +`src/errors/error-types.ts` define the stable mapping. -**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. +`ccs doctor` separately documents exit codes 0 (healthy) and 1 (unhealthy) in +`src/commands/doctor-command.ts`. Preserve that command contract. -**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. +The typed taxonomy is enforced by +`src/errors/__tests__/typed-error-migration-exit-codes.test.ts`; do not treat +the mapping as an untested migration note. ## 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. +Use the most specific typed error in `cliproxy/quota`, `cliproxy/auth`, +`web-server/routes`, and `auth`. Preserve `GENERAL_ERROR(1)` only where no clear +subclass applies. Update the behavior-lock test with any intentional taxonomy +change. ## Exit-code mapping (the contract this audit locks) -| Typed class | ExitCode | Value | Used for (P4 sites) | +| Typed class | ExitCode | Value | Typical use | |---|---|---:|---| | `ProfileError` | `PROFILE_ERROR` | 7 | profile/account/variant not found, already exists | | `AuthError` | `AUTH_ERROR` | 4 | OAuth/token/Kiro/GitLab auth flow failures, refresh ownership | @@ -31,15 +41,5 @@ Typed exit codes are **already wired end-to-end**. `handleError` -> `getExitCode | `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. +Current adoption counts belong in the generated hardening inventory. Git history +contains the completed migration narrative. diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md deleted file mode 100644 index f3aa0966..00000000 --- a/docs/session-sharing-technical-analysis.md +++ /dev/null @@ -1,216 +0,0 @@ -# Session Sharing Technical Analysis - -Last Updated: 2026-05-05 - -## Summary - -CCS supports practical cross-account continuity by sharing workspace context files between selected accounts, while keeping credentials isolated per account. - -This is implemented as a context policy per account: - -- `isolated` (default): account keeps its own workspace context -- `shared` + `standard` (default): account workspace context is linked to a shared context group -- `shared` + `deeper` (advanced opt-in): account also shares continuity artifacts - -## Recommended Two-Account Route - -Use `ccs auth` account profiles when you want two real Claude accounts and want to choose which one runs each session: - -```bash -ccs auth create work -ccs auth create personal - -ccs work -ccs personal -``` - -This keeps usage and credentials isolated. Each account owns its own Claude config directory, login state, and `.anthropic` credentials. - -Shared Resources are separate from History Sync. By default, non-bare account profiles inherit Claude-local resources from native Claude: - -```text -~/.ccs/instances//settings.json - -> ~/.ccs/shared/settings.json - -> ~/.claude/settings.json -``` - -This covers ordinary Claude Code `settings.json`, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show ` reports `Resources`, `Settings`, `History`, and `Plain ccs` lanes so users can see whether shared resources and resume history are aligned. - -For existing accounts, change Shared Resources from the CLI: - -```bash -ccs auth resources work --mode profile-local -ccs auth resources work --mode shared -``` - -- `shared`: link plugins, commands, skills, agents, and `settings.json` from the shared Claude resource layout. -- `profile-local`: detach those shared resources for the account. This is the existing `--bare` behavior exposed as an existing-account setting. - -Only opt in to shared history when both accounts should see the same local continuity: - -```bash -ccs auth create work2 --share-context --context-group daily --deeper-continuity -``` - -For existing History Sync, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context. History Sync does not control plugins or `settings.json`; use `ccs auth resources` for that. - -## Why This Is Safe Enough - -CCS only shares workspace context paths (project/session context files). It does **not** merge or copy authentication credentials between accounts. - -Credential storage remains per account instance. - -## Implementation Model - -Account metadata is stored in `~/.ccs/config.yaml`: - -```yaml -accounts: - work: - created: "2026-02-24T00:00:00.000Z" - last_used: null - shared_resource_mode: "shared" - context_mode: "shared" - context_group: "team-alpha" - continuity_mode: "deeper" -``` - -Rules: - -- `shared_resource_mode` controls commands, skills, agents, plugins, and `settings.json` (`shared` or `profile-local`) -- `context_mode` must be `isolated` or `shared` -- `context_group` is required when `context_mode=shared` -- `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`) -- group normalization: trim, lowercase, internal spaces -> `-` -- group must start with a letter and only include `[a-zA-Z0-9_-]` -- max length: `64` - -Deeper continuity links these directories per context group: - -- `session-env` -- `file-history` -- `shell-snapshots` -- `todos` - -`.anthropic` and account credentials remain isolated. - -## Cross-Profile Inheritance (API / CLIProxy / Copilot) - -You can explicitly map non-account profiles (including `default`) to reuse continuity artifacts from an account profile: - -```yaml -continuity: - inherit_from_account: - glm: pro - gemini: pro - copilot: pro -``` - -Behavior: - -- Applies only when running Claude target (`ccs ` or `--target claude`) -- Does not change provider credentials or API routing -- Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution -- Invalid/missing mapped accounts are skipped safely - -### Resume Lane Note - -Resume follows the active `CLAUDE_CONFIG_DIR`, not just the continuity group: - -- plain `ccs -r` resumes the lane plain `ccs` is using right now -- `ccs -r` resumes only that account lane -- those two commands can point at different continuity inventories - -That means `shared + deeper` on an account does **not** automatically make old plain-`ccs` resume history appear inside `ccs -r`. - -If you want future plain `ccs` sessions to use an account lane, either: - -```bash -ccs auth default work -``` - -or map the default profile explicitly: - -```yaml -continuity: - inherit_from_account: - default: work -``` - -Example with an existing `ck` account: - -```bash -ccs auth show ck -ccs auth backup default -ccs auth default ck -``` - -`ccs auth default ck` makes future plain `ccs` sessions use the `ck` account lane, so future `ccs` and `ccs ck` resume from the same local inventory. It does not automatically import old native `~/.claude/projects` history into `ck`; keep using `ccs -r` for the old native lane until you intentionally migrate that local history. - -## User Workflows - -### New account with shared context - -```bash -ccs auth create work2 --share-context -ccs auth create backup --share-context --context-group sprint-a -ccs auth create backup2 --share-context --context-group sprint-a --deeper-continuity -``` - -### Existing account - -History Sync: - -- Open `ccs config` -- Go to `Accounts` -- Click the pencil icon (`Edit History Sync`) -- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity - -Shared Resources: - -```bash -ccs auth resources work --mode profile-local -ccs auth resources work --mode shared -``` - -Dashboard: - -- Open `ccs config` -- Go to `Accounts` -- Use `Resources` to switch an existing account between `shared` and `profile-local` -- Go to `Shared Resources` to inspect the shared commands, skills, agents, plugins, and `settings.json` hub - -No account recreation required for this workflow. - -### Backup Before Changing Sync - -CCS can back up local continuity artifacts before you change settings: - -```bash -ccs auth backup work -ccs auth backup default -``` - -- `ccs auth backup work` backs up the selected account lane -- `ccs auth backup default` backs up the lane plain `ccs` would use right now -- this is a local continuity backup, not a guaranteed export of all upstream Claude-hosted resume state - -## Current Limitations - -- Shared context is local filesystem sharing. It does not bypass remote provider permission models. -- Session continuity still depends on what the upstream tool/provider stores and allows. -- Context sharing should only be enabled for accounts you intentionally trust to share workspace history. -- Shared Resources inspection is read-only in the dashboard. Editing individual files still belongs to the owning command, skill, plugin, or settings surface. - -## Alternative: CLIProxy Claude Pool - -For users who prefer lower manual account switching, use CLIProxy Claude pool instead: - -- Authenticate pool accounts via `ccs cliproxy auth claude` -- Manage account pool behavior in `ccs config` -> `CLIProxy Plus` - -## Validation Checklist - -- Confirm account row shows `shared ()` in Dashboard Accounts table -- Switch between accounts in the same group and verify workspace continuity -- Run `ccs doctor` if symlink/context health looks inconsistent diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 475718ba..21d0b0e9 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -1,465 +1,246 @@ # CCS System Architecture -Last Updated: 2026-04-14 +CCS separates profile resolution, provider routing, and target execution. This +document describes stable boundaries; source registries and tests own mutable +provider and command inventories. -High-level architecture overview for the CCS (Claude Code Switch) system. +## System context ---- - -## System Overview - -CCS is a multi-provider profile and runtime manager that enables seamless switching between multiple Claude accounts, alternative AI providers, and multiple CLI targets (Claude Code, Factory Droid, Codex CLI) for credential delivery. - -The system consists of two main components: - -1. **CLI Application** (`src/`) - Node.js TypeScript CLI -2. **Dashboard UI** (`ui/`) - React web application served by Express - -Dashboard localization (i18n) architecture and contributor workflow are documented in [Dashboard i18n Guide](../i18n-dashboard.md). - -CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy with automatic injection for all profile types. -CCS v7.67 adds a native structured logging lane for CCS-owned runtime events, backed by `src/services/logging/`, bounded JSONL files under `~/.ccs/logs/`, and a dedicated dashboard `/logs` route. -CCS PR review now uses PR-Agent in GitHub Actions, with reviews running on the self-hosted `cliproxy` runner, existing `AI_REVIEW_*` workflow variables and secrets preserved as the runtime contract, and repo-level guidance stored in `.pr_agent.toml`. - -``` -+===========================================================================+ -| CCS System | -+===========================================================================+ -| | -| +------------------+ +-----------------+ +----------------+ | -| | User Terminal | ---> | CCS CLI | ---> | Target CLI | | -| | (ccs command) | | (src/ccs.ts) | | (claude/droid/codex) | | -| +------------------+ +-----------------+ +----------------+ | -| | | | -| v v | -| +------------------+ +-----------------+ +----------------+ | -| | Dashboard UI | <--> | Express | ---> | Provider APIs | | -| | (React SPA) | | Web Server | | (Claude/GLM/ | | -| +------------------+ +-----------------+ | Gemini/etc) | | -| | +----------------+ | -| v | -| +---------------------+ | -| | CLIProxyAPI | | -| | (Local or Remote) | | -| +---------------------+ | -| | -+===========================================================================+ -``` - ---- - -## Component Architecture - -### Multi-Target Adapter System - -CCS v7.45 introduces the Target Adapter pattern, enabling seamless integration with different CLI implementations. - -**Key architecture:** - -``` -Profile Resolution (CLIProxy, Settings/API, Account-based) +```text +User or automation | v -Target Resolution (--target flag > runtime entrypoint / argv[0] > config > default) - | - v -Get Target Adapter (Claude, Droid, or Codex) - | - +---> detectBinary() (find CLI on system) - | - +---> prepareCredentials() (write config or set env) - | - +---> buildArgs() (construct CLI arguments) - | - +---> buildEnv() (prepare environment variables) - | - v -Spawn Target Process +CCS CLI ------------------------> Target CLI + | | + | profile/provider state | provider protocol + v v +CCS local server <-----------> CLIProxy or direct API + | + v +React dashboard ``` -**Each target adapter implements different credential delivery:** +The main implementation surfaces are: -- **Claude Adapter**: Env var delivery (existing behavior) - - `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL` - - No config files needed +| Surface | Ownership | +| --- | --- | +| `src/` | CLI, dispatch, server, provider integration, target adapters | +| `ui/src/` | Dashboard application | +| `dist/` and `dist/ui/` | Build outputs | +| `docker/` | Integrated and legacy container definitions | +| `tests/` | Unit, integration, end-to-end, native, and Docker contracts | -- **Droid Adapter**: Config file delivery to `~/.factory/settings.json` - - Writes custom model entry: `custom:ccs-` - - Spawns: `droid -m custom:ccs- ` - - Model config includes baseUrl, apiKey, provider +Dashboard localization is documented in the +[Dashboard i18n Guide](../i18n-dashboard.md). -- **Codex Adapter**: Transient runtime overrides plus user-layer dashboard inspection - - Uses `codex -c key=value` only for CCS-routed launches - - Preserves native `~/.codex/config.toml` ownership - - Dashboard page reads/writes only the user config layer with explicit runtime-vs-provider warnings +## Execution pipeline -**Runtime entrypoints (built-in bins) and argv[0]-style aliases:** - -``` -ccs → Target: claude (default) -ccs-droid → Target: droid (explicit alias) -ccsd → Target: droid (legacy shortcut) -ccs-codex → Target: codex (explicit alias) -ccsx → Target: codex (short alias) -ccsxp → Target: codex (native cliproxy shortcut; prepends `--config model_provider="cliproxy"`) -``` - -For details on the adapter architecture, see [Target Adapters](./target-adapters.md). - -### CLI Layer - -``` -+===========================================================================+ -| CLI Architecture | -+===========================================================================+ - - User Input (ccs [--target ] [args]) - | - v - +-------------+ - | ccs.ts | Entry point, command routing - +-------------+ - | - +---> [Version/Help/Doctor/etc.] ---> Exit - | - v - +------------------+ - | Target Resolution | Determine which CLI to use - +------------------+ - | - v - +-------------+ - | Profile | Determines execution path - | Detection | - +-------------+ - | - +---> [Native Claude Account] ---> execClaude() - | | - +---> [CLIProxy Provider] ---> execClaudeWithCLIProxy() - | | - +---> [Settings/API Profile] ---> normalize legacy glmt if needed - | - v - +------------------+ - | Target Adapter | Get appropriate adapter - +------------------+ - | - v - +------------------+ - | Prepare Creds | Deliver credentials - +------------------+ - | - v - +------------------+ - | Target CLI | Claude Code or Droid - +------------------+ -``` - ---- - -## Data Flow Architecture - -### CLI Execution Flow - -``` -+===========================================================================+ -| CLI Execution Flow | -+===========================================================================+ - - 1. Parse Arguments - | - v - 2. Resolve Target Type - | - v - 3. Detect Profile Type - | - +---> Native Claude ---> 3a. Load Account Settings - | | - | v - | 4a. Set CLAUDE_CONFIG_DIR - | | - | v - | 5a. Get Claude Target Adapter - | - +---> CLIProxy -------> 3b. Ensure Binary Installed - | | - | v - | 4b. Generate Config - | | - | v - | 5b. Resolve Target Adapter - | | - | v - | 6b. Prepare Credentials - | | - | v - | 7b. Spawn via Adapter - | - +---> Settings/API ---> 3c. Load settings env - | - v - 4c. Normalize legacy glmt if needed - | - v - 5c. Resolve Target Adapter - | - v - 6c. Spawn via Adapter -``` - ---- - -## Provider Integration Architecture - -For detailed provider flows (CLIProxyAPI, legacy GLMT compatibility, quota management), see [Provider Flows](./provider-flows.md). - ---- - -## Configuration Architecture - -### CCS Logging Architecture - -- Shared logging contract lives in `src/services/logging/` and is used for CCS-owned runtime diagnostics, request tracing, and bounded recent-entry reads. -- Config lives at top-level `logging.*` in `~/.ccs/config.yaml`; `cliproxy.logging.*` still controls upstream CLIProxy runtime files only. -- CCS-owned runtime logs write to `~/.ccs/logs/current.jsonl` and rotate into `~/.ccs/logs/archive/` based on policy. -- Dashboard exposure uses native `/api/logs/config`, `/api/logs/sources`, and `/api/logs/entries` endpoints plus the `System -> Logs` React page. -- Request logging explicitly skips `/api/logs` reads so the log viewer does not recursively log itself. - -### Config File Hierarchy - -``` -+===========================================================================+ -| Configuration Hierarchy | -+===========================================================================+ - - ~/.ccs/ +```text +Parse command | - +---> config.yaml # Main CCS config (unified) +Resolve command vs launch | - +---> profiles.json # Claude account registry +Resolve target | - +---> .settings.json # Per-profile settings +Resolve profile type + +-- account profile ------> isolated target config root + +-- settings profile -----> profile environment + +-- CLIProxy provider ----> local or remote proxy route | - +---> cliproxy/ - | | - | +---> config.yaml # CLIProxy configuration - | +---> auth/ # OAuth tokens - | +---> bin/ # CLIProxy binary +Prepare target credentials | - +---> shared/ # Symlinked resources - | - +---> commands/ # Claude Code commands - +---> skills/ # Custom skills - +---> agents/ # Agent configurations - +---> plugins/ - | - +---> cache/ # Shared plugin payload/cache data - +---> marketplaces/ # Shared marketplace payload directories - +---> installed_plugins.json - - ~/.ccs/instances// - | - +---> plugins/ - | - +---> known_marketplaces.json # Instance-local registry for active CLAUDE_CONFIG_DIR validation - - ~/.factory/ (Droid CLI) - | - +---> settings.json # Droid config (custom models) +Spawn target and forward lifecycle signals ``` -Plugin ownership note: -- `commands/`, `skills/`, `agents/`, and `settings.json` remain shared through the existing symlink/copy flow. -- Marketplace payload directories stay shared, but `known_marketplaces.json` is reconciled per instance so Claude Code can validate `installLocation` against that instance's `CLAUDE_CONFIG_DIR/plugins/marketplaces`. +Command routing stops before profile execution for management commands such as +configuration, diagnostics, proxy management, and environment export. -### Config Loading Order +### Profile resolution -``` - 1. Environment Variables (highest priority) - | - v - 2. CLI Arguments (including --target) - | - v - 3. Profile-specific settings (~/.ccs/.settings.json) - | - v - 4. Main config (~/.ccs/config.yaml) - | - v - 5. Default values (lowest priority) +Profile resolution distinguishes: + +1. built-in CLIProxy provider shortcuts; +2. user-defined CLIProxy profiles; +3. settings/API profiles; and +4. registered account profiles. + +Canonical provider IDs and aliases come from +[`src/cliproxy/provider-capabilities.ts`](../../src/cliproxy/provider-capabilities.ts). +The detector and dispatcher consume those registries; documentation must not +maintain a second provider list. + +### Target resolution + +Provider and target are independent axes. The selected target is resolved from +explicit flags and runtime entry points before falling back to configuration +and the default target. + +Each adapter owns credential delivery: + +- **Claude Code:** launch environment and optional isolated + `CLAUDE_CONFIG_DIR`; +- **Factory Droid:** CCS-managed custom-model entries in + `~/.factory/settings.json`; and +- **Codex CLI:** transient `-c` overrides for CCS-routed launches while native + user configuration remains separately owned. + +See [Target Adapters](./target-adapters.md) for the detailed compatibility +contract. + +## Provider routing + +CCS supports three routing boundaries: + +| Route | Credential and transport owner | +| --- | --- | +| Direct settings/API profile | Target receives the selected provider's environment | +| Local CLIProxy | CCS manages a local proxy binary, config, and auth directory | +| Remote CLIProxy | CCS connects to the configured remote service and applies the selected fallback policy | + +Local backend choice is explicit. `original` is the default. `plus` is an +opt-in backend for provider capabilities unavailable in the original backend. +Compatibility restrictions are defined in +[`src/cliproxy/types/provider-types.ts`](../../src/cliproxy/types/provider-types.ts) +and enforced before local execution. + +See [Provider Flows](./provider-flows.md). + +## Configuration ownership + +The effective CCS directory is resolved by +[`src/utils/config-manager.ts`](../../src/utils/config-manager.ts). The normal +default is `~/.ccs`; tests and scoped workflows can override it. + +```text +CCS directory +├── config.yaml +├── profiles.json +├── .settings.json +├── instances/ +├── logs/ +└── cliproxy/ + ├── config.yaml + ├── auth/ + └── bin/ ``` ---- +### Settings-write contract -## WebSocket Architecture +Normal launches do not rewrite shared Claude settings. API profiles store +string-valued launch environment in CCS-owned per-profile settings. -### Real-time Communication +Persistent shared configuration is explicit: -``` -+===========================================================================+ -| WebSocket Communication | -+===========================================================================+ +- `ccs persist` reads and validates `~/.claude/settings.json`; +- it refuses unsafe symlink targets; +- it preserves unrelated settings while updating the requested managed fields; +- it creates a backup when an existing file is present; and +- it writes the replacement atomically under a settings-directory lock. - Dashboard (React) Server (Express) - | | - |<------ Connection Established ------>| - | | - |<------ health:update ----------------| Health status - | | - |<------ auth:status ------------------| Auth changes - | | - |<------ usage:update -----------------| Usage stats - | | - |------- action:refresh -------------->| User requests - | | +Target-owned writers follow their own boundary. For example, the Droid adapter +manages CCS custom-model entries in `~/.factory/settings.json`, not arbitrary +user settings. + +## Local server and dashboard + +The Express server exposes APIs used by the React dashboard for supported +configuration, auth, usage, health, and logging workflows. The dashboard is a +management surface over shared services; it must not implement a competing +configuration model. + +Real-time updates use server-owned WebSocket messages. Event names and payloads +are code contracts and should be read from the server and UI implementations +rather than copied into this overview. + +## Logging + +CCS-owned structured runtime logging lives under `src/services/logging/`. +Top-level `logging.*` configuration controls CCS JSONL logs under the CCS +directory. `cliproxy.logging.*` controls upstream CLIProxy files and is a +separate contract. + +The dashboard log reader excludes its own log-read requests from request +logging to prevent recursive noise. See [Logging Contract](../logging-contract.md). + +## Managed tool preparation + +WebSearch and image analysis are prepared before the target launch when the +selected profile needs CCS-managed tooling. They use provider-aware routes but +have different failure contracts: enabled third-party WebSearch fails closed if +its managed MCP replacement cannot be prepared, while image analysis can use a +compatible native path when available. + +- [WebSearch](../websearch.md) +- [Provider Flows](./provider-flows.md) + +## Security and trust boundaries + +### Local host + +CCS reads and writes user-authorized configuration and starts target processes. +That local filesystem access is more privileged than a provider API request. +Sensitive values must not enter logs or dashboard responses. + +### Local proxy + +The host CLI uses loopback for locally managed CLIProxy traffic. Local auth +files and the management API remain sensitive even when the transport never +leaves the machine. + +### Remote proxy + +A remote CLIProxy crosses a network and administrative boundary. TLS, +authentication, certificate policy, reachability, and local fallback are +explicit configuration choices. Remote-only mode must not silently start a +local proxy. + +### Container deployment + +The integrated container exposes dashboard and proxy ports through the +operator's port mappings. The image therefore does not inherit the host +installation's loopback-only assumption. Network exposure and access control +belong to the deployment operator. + +## Build and distribution + +```text +src/ -------- TypeScript --------> dist/ +ui/src/ ----- Vite --------------> dist/ui/ + | + v + npm package + | + v + integrated Docker image ``` ---- +The package requires Node.js 18 or newer. Repository development supports Bun +1.0 or newer. CI can pin newer tool versions independently; those workflow pins +are not the minimum consumer runtime contract. -## Security Architecture +The integrated Docker image is built from +[`docker/Dockerfile.integrated`](../../docker/Dockerfile.integrated). It layers +CCS onto a digest-pinned CLIProxy base, runs CLIProxy and the dashboard under +supervision, and health-checks both services. It does not bundle Claude Code, +Gemini CLI, Codex CLI, Droid, or other target CLIs. -### Authentication Flow +Release lane details are in [Release Process](../release-process.md). -See [Provider Flows](./provider-flows.md) → Authentication Flow section. +## Architecture invariants -### Security Boundaries +- Provider identity comes from the provider registry. +- Target compatibility is enforced at the adapter boundary. +- Environment values persisted in settings are strings. +- Shared target configuration changes require an explicit workflow. +- Local and remote proxy modes do not silently cross trust boundaries. +- Dashboard and CLI use the same domain services and configuration schema. +- Volatile capability details remain source-owned. -``` - +------------------+ - | User Terminal | - +------------------+ - | - | Local only (no network exposure) - v - +------------------+ - | CCS CLI | - +------------------+ - | - | Localhost only (127.0.0.1) - v - +------------------+ - | CLIProxy/Legacy | Binds to localhost only - +------------------+ - | - | TLS encrypted - v - +------------------+ - | Target CLI | Spawned locally (claude/droid) - +------------------+ - | - | TLS encrypted - v - +------------------+ - | Provider APIs | External endpoints - +------------------+ -``` +## Related documentation ---- - -## Build and Distribution - -### Build Pipeline - -``` -+===========================================================================+ -| Build Pipeline | -+===========================================================================+ - - src/ (TypeScript) ui/src/ (React TSX) - | | - v v - TypeScript Compiler Vite Build - | | - v v - dist/ (JavaScript) dist/ui/ (Static assets) - | | - +---------------+---------------------+ - | - v - npm package (@kaitranntt/ccs) - | - v - npm registry / GitHub releases -``` - -### Package Contents - -``` - @kaitranntt/ccs - | - +---> dist/ # Compiled CLI - +---> dist/ui/ # Built dashboard - +---> lib/ # Native scripts - | +---> ccs # Bash bootstrap - | +---> ccs.ps1 # PowerShell bootstrap - +---> package.json -``` - ---- - -## Deployment Architecture - -### Local Installation - -``` - npm install -g @kaitranntt/ccs - | - v - Global node_modules - | - +---> Creates symlink: ccs --> dist/ccs.js - | - +---> Runtime aliases: ccs-droid / ccsd → ccs (auto-select droid target) - | - +---> First run creates: ~/.ccs/ -``` - -### PR Review Lane - -Automated pull request review stays in `.github/workflows/ai-review.yml`, but the workflow now runs PR-Agent instead of the old Claude action. Reviews run on the existing self-hosted `cliproxy` runner, while the workflow preserves the existing `AI_REVIEW_BASE_URL`, `AI_REVIEW_MODEL`, and `AI_REVIEW_API_KEY` contract by mapping those values into PR-Agent env keys such as `OPENAI.*`, `config.*`, and `github_action_config.*`. Repo-specific reviewer guidance lives in `.pr_agent.toml`. - -``` -GitHub Actions `ai-review.yml` - | - v -Self-hosted `cliproxy` runner - | - v -PR-Agent action - | - v -CLIProxy - | - v -Configured model from `.pr_agent.toml` -``` - -- `ai-review.yml` owns automation wiring such as runner selection, PR-Agent action usage, and runtime values mapped from `AI_REVIEW_*` into `OPENAI.*`, `config.*`, and `github_action_config.*`. -- `.pr_agent.toml` in the repo root owns review instructions for this repository. -- Contributors should treat PR-Agent comments and trusted `/review` reruns as the primary AI review path for PRs targeting CCS. - -### Runtime Dependencies - -``` - +------------------+ +------------------+ - | Node.js 14+ | | Claude CLI | - | (required) | | (required) | - +------------------+ +------------------+ - - +------------------+ +------------------+ - | CLIProxyAPI | | Droid CLI | - | (auto-managed) | | (optional) | - +------------------+ +------------------+ -``` - ---- - -## Related Documentation - -- [Codebase Summary](../codebase-summary.md) - Detailed directory structure -- [Code Standards](../code-standards.md) - Coding conventions & patterns -- [Target Adapters](./target-adapters.md) - Multi-CLI adapter architecture -- [Provider Flows](./provider-flows.md) - CLIProxy, legacy GLMT compatibility, authentication flows -- [Project Roadmap](../project-roadmap.md) - Development phases +- [Codebase Summary](../codebase-summary.md) +- [Code Standards](../code-standards.md) +- [Target Adapters](./target-adapters.md) +- [Provider Flows](./provider-flows.md) +- [Release Process](../release-process.md) +- [Project Roadmap](../project-roadmap.md) diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 39b516c3..c7a7a4d5 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -1,641 +1,247 @@ # Provider Integration Flows -Last Updated: 2026-07-16 +CCS routes profiles through direct target configuration or CLIProxy. This +document describes the durable flow and trust boundaries. Mutable provider +capabilities are linked to their source registries. -Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication. +## Sources of truth ---- +| Detail | Authoritative source | +| --- | --- | +| Canonical provider IDs, aliases, auth-flow type, callback ports, refresh ownership | [`src/cliproxy/provider-capabilities.ts`](../../src/cliproxy/provider-capabilities.ts) | +| Original vs Plus backend restrictions | [`src/cliproxy/types/provider-types.ts`](../../src/cliproxy/types/provider-types.ts) | +| Provider/backend enforcement | [`src/cliproxy/services/variant-service.ts`](../../src/cliproxy/services/variant-service.ts) | +| Remote proxy precedence and fallback | [`src/cliproxy/proxy/proxy-config-resolver.ts`](../../src/cliproxy/proxy/proxy-config-resolver.ts) | +| Provider capability regression tests | [`src/cliproxy/__tests__/provider-capabilities.test.ts`](../../src/cliproxy/__tests__/provider-capabilities.test.ts) | -## CLIProxyAPI Flow +Do not copy provider inventories, callback ports, or quota-supported lists into +this document. They change independently of the architecture. -### Overview +## Route selection -CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with multiple AI providers. CCS manages the binary and configuration automatically. - -### Local Backend Choice - -CCS defaults to the original `router-for-me/CLIProxyAPI` backend because it is the stable MIT upstream. The `plus` backend is an explicit opt-in path that downloads the community-maintained `kaitranntt/CLIProxyAPIPlus` fork for providers that still require Plus-only support, such as Kiro, Cursor, GitLab, CodeBuddy, Kilo, and deprecated GitHub Copilot compatibility. CCS does not silently downgrade `backend: plus` to `original`; users choose that backend deliberately when they need those providers. - -Generated local CLIProxy configs also keep the management dashboard aligned with the selected backend. `backend: original` uses upstream CPAMC (`router-for-me/Cli-Proxy-API-Management-Center`), while `backend: plus` uses the CCS-maintained dashboard fork (`kaitranntt/Cli-Proxy-API-Management-Center`). Advanced users can override the generated `remote-management.panel-github-repository` value by setting `cliproxy.management_panel_repository` in `~/.ccs/config.yaml`; CCS will regenerate stale local CLIProxy configs when the expected dashboard repository changes. - -``` -+===========================================================================+ -| CLIProxyAPI Integration | -+===========================================================================+ - - Claude CLI - | - | ANTHROPIC_BASE_URL = localhost:XXXX - v - +------------------+ - | CLIProxyAPI | Local proxy binary (Plus fork opt-in for plus-only providers) - | (binary) | - +------------------+ - | - +---> OAuth Authentication - | | - | +---> Authorization Code Flow (port-based) - | | - Gemini, Codex, Antigravity, iFlow, Claude, GitLab - | | - Opens browser for user auth - | | - Callback to localhost:PORT - | | - | +---> Device Code Flow (no port needed) - | - xAI/Grok, Kimi, Kiro, Copilot, CodeBuddy, Kilo, and Qoder - | - User enters a code on the provider verification page - | - Polls for token completion - | | - | +---> Browser URL Polling (no callback port) - | - Cursor - | - Opens provider login URL returned by CLIProxyAPIPlus - | - Polls auth state until token is saved - | | - | v - | +------------------+ - | | OAuth Server | Browser-based auth - | +------------------+ - | - +---> Request Transformation - | | - | v - | Anthropic Format --> Provider Format - | - +---> Image Analysis Hook (v7.34) - | | - | v - | Vision Model Proxying (gemini, codex, agy, clipproxy) - | - Auto-injected via claude-hooks - | - Skip for Claude Sub accounts (native vision) - | - Fallback with deprecated block-image-read - | - +---> Provider APIs +```text +Resolved profile + | + +-- account profile + | └── target-native authenticated context + | + +-- settings/API profile + | └── direct or compatible upstream URL and credential environment + | + └-- CLIProxy provider + | + +-- reachable configured remote proxy + | + └-- local proxy, when selected or fallback is allowed | - +---> Google (Gemini) - +---> OpenAI (Codex) - +---> xAI (Grok) - +---> Antigravity (AGY) - +---> AWS Kiro (Claude-powered) - +---> GitHub Copilot (ghcp, deprecated compatibility) - +---> OpenAI-compatible endpoints + └── original or Plus backend ``` -### Supported Built-In Providers +The provider route is resolved before the target adapter prepares credentials. +This keeps provider selection independent from whether Claude Code, Droid, or +Codex receives the route. -| Provider | ID | Auth Method | Callback Port | Backend | -|----------|----|----|------|--------| -| Gemini | `gemini` | Authorization Code | 8085 | Original | -| Codex | `codex` | Authorization Code | 1455 | Original | -| xAI (Grok) | `xai` (`grok` CLI alias) | Device Code | none | Original | -| Antigravity | `agy` | Authorization Code | 51121 | Original | -| Qwen | `qwen` | Account linking unavailable in bundled runtime | none | Original | -| iFlow | `iflow` | Authorization Code | 11451 | Original | -| Claude | `claude` | Authorization Code | 54545 | Original | -| Kimi | `kimi` | Device Code | none | Original | -| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | none by default | Plus | -| GitHub Copilot (deprecated) | `ghcp` | Device Code | none | Plus | -| Cursor | `cursor` | Browser URL polling | none | Plus | -| GitLab Duo | `gitlab` | Authorization Code or PAT | 17171 | Plus | -| CodeBuddy | `codebuddy` | Device-style polling | none | Plus | -| Kilo AI | `kilo` | Device Code | none | Plus | -| Qoder | `qoder` | Device Code | none | Plus | +## Local CLIProxy -xAI uses CLIProxyAPI's `--xai-login` device flow. CCS stores and discovers the resulting -`xai-*.json` credentials under the canonical `xai` provider. `ccs grok` is a command alias only, -so it shares the same accounts, settings, live model catalog, and routing as `ccs xai`. +CCS manages the local binary, generated configuration, auth files, lifecycle, +and provider-specific launch environment. -### Codex Duplicate-Email Account Identity +The local backend contract is: -Codex can legitimately produce multiple auth files for the same email when the user has both a team/business login and a personal/free login. CCS now treats those as separate accounts instead of collapsing them by email. +- `original` is the default upstream CLIProxy backend; +- `plus` is an explicit opt-in community-maintained distribution; +- providers declared Plus-only fail clearly on the original backend; and +- CCS does not silently downgrade a configured Plus backend. -- Internal account IDs stay duplicate-aware for Codex only: `email#variant` -- Variant keys are derived from the auth filename, for example `kaidu.kd@gmail.com#04a0f049-team` and `kaidu.kd@gmail.com#free` -- Dashboard surfaces continue to show the canonical email, with a compact variant badge such as `Team` or `Free` -- Quota fetch resolves the exact registry `tokenFile` for the selected account instead of scanning by email and taking the first match -- Live usage/account monitor stats key by `provider + account identity`, so duplicate Codex emails no longer merge into one runtime bucket +The management-panel repository is generated to match the selected backend +unless the user supplies the supported override. -This preserves the user-visible distinction between business and personal Codex sessions while keeping other providers on their existing email-backed identity model. +Local target traffic uses a provider-compatible endpoint and an internal +credential. CLIProxy owns upstream OAuth token use and request translation +according to the selected provider. -### Built-In Provider Detection +## Remote CLIProxy -CCS derives canonical providers from `provider-capabilities.ts`. `profile-detector.ts` resolves only -explicit CLI aliases before routing through the CLIProxy execution flow. +Remote mode delegates proxy lifecycle and stored authentication to another +CLIProxy installation. -```typescript -const provider = resolveCLIProxyProviderShortcut(profileName); - -if (provider) { - return execClaudeWithCLIProxy(claudeCli, provider, args); -} +```text +CLI flags and environment + | + v +CCS proxy configuration + | + v +Reachability check + +-----+-----+ + | | +reachable unreachable + | | + remote remote-only? ---- yes ---> fail + | + no + | + fallback enabled? -- no ----> fail + | + yes + | + local proxy ``` ---- +Configuration precedence and accepted environment names are implemented in +[`proxy-config-resolver.ts`](../../src/cliproxy/proxy/proxy-config-resolver.ts). +The stable behavioral contract is: -## Legacy GLMT Compatibility Flow +- CLI flags override other sources where supported; +- environment can select a remote host; +- configuration supplies persistent remote settings; +- HTTPS and HTTP defaults remain protocol-aware; +- `--remote-only` disables local fallback; and +- an unreachable remote proxy falls back only when fallback is enabled. -### Overview +A remote proxy is a separate trust boundary. Its operator can receive provider +traffic and owns its stored OAuth state. Use TLS and authentication appropriate +to that boundary. -GLMT is no longer a marketed runtime surface in CCS. Existing `glmt` profiles are kept as a compatibility path and normalized at launch to the direct GLM endpoint. The `src/glmt/` module remains because Cursor response translation still imports its transformer pipeline. +## Authentication -``` -+===========================================================================+ -| Legacy GLMT Compatibility + Internal Transforms | -+===========================================================================+ +CCS asks the selected CLIProxy backend to start the provider's supported auth +flow. The provider registry identifies whether that is an authorization-code +flow, device-code flow, browser polling flow, or currently unsupported account +linking. - Claude CLI +```text +Select canonical provider | - | legacy glmt settings detected - v - +------------------+ - | Compatibility | normalizeDeprecatedGlmtEnv() - | Layer | (src/utils/glmt-deprecation.ts) - +------------------+ +Validate backend and auth-start support | - v - +------------------+ - | Direct GLM API | https://api.z.ai/api/anthropic - +------------------+ +Start backend-owned auth flow | - v - +------------------+ - | src/glmt/* | retained for Cursor translation - +------------------+ +User completes provider interaction + | +Backend writes provider auth file + | +CCS reconciles account identity and readiness + | +Launch through provider route ``` -### Supported Migration Targets +Provider tokens live under the configured CLIProxy auth directory. Documentation +and examples must use neutral account labels; raw email addresses, token +filenames, access tokens, and refresh tokens are not architecture data. -| Provider | Config Key | Endpoint | Auth | -|----------|------------|----------|------| -| Z.AI (GLM) | `glm` | https://api.z.ai/api/anthropic | API key | -| Kimi API | `km` | https://api.kimi.com/coding/ | API key | -| Legacy compatibility | `glmt` | normalized to direct GLM at runtime | existing profile only | +Some providers can produce more than one account with the same display email. +CCS keeps runtime identity tied to the exact registered auth file rather than +collapsing accounts by display text. The dashboard can show a safe variant +label without exposing the internal identifier. -Use `ccs glm` for Z.AI profiles and `ccs km` for reasoning-first Kimi API profiles. Keep `glmt` only when migrating an existing settings file. +## API-key profiles -### Runtime Handling +API-key profiles store string-valued environment under the CCS profile +settings file. -CCS detects the deprecated `glmt` profile name and normalizes legacy proxy-only settings before dispatching through the normal settings-profile flow: - -```typescript -if (isDeprecatedGlmtProfileName(profileName)) { - const normalized = normalizeDeprecatedGlmtEnv(settingsEnv); - // warn user, validate against direct GLM endpoint, continue through settings flow -} +```text +Create or edit API profile + | +Write CCS-owned .settings.json + | +Resolve direct/native vs compatible proxy-style auth + | +Target adapter prepares credentials + | +Spawn target ``` ---- +Native Anthropic profiles use `ANTHROPIC_API_KEY` without forcing a proxy base +URL. Compatible providers typically use `ANTHROPIC_BASE_URL` and +`ANTHROPIC_AUTH_TOKEN`. The profile writer owns that distinction; callers +should not infer it from documentation examples. -## Remote CLIProxy Flow (v7.1) +Normal launches do not write `~/.claude/settings.json`. Users who want shared +Claude settings must select the explicit `ccs persist` workflow. -### Overview +## Legacy GLMT compatibility -Remote CLIProxy enables CCS to delegate authentication to a central proxy server instead of spawning a local binary. +`glmt` is a compatibility input, not a current provider architecture. Existing +legacy settings are normalized to the direct GLM path before normal +settings-profile dispatch. Internal GLMT transformer modules can remain in use +by other compatibility surfaces without making GLMT a supported standalone +runtime. -``` -+===========================================================================+ -| Remote CLIProxy Architecture (v7.1) | -+===========================================================================+ +New configuration should use current API profile commands and provider names. - Config Resolution (proxy-config-resolver.ts) +## Quota and account-pool flow + +Quota support is provider-specific and intentionally sourced from +[`provider-capabilities.ts`](../../src/cliproxy/provider-capabilities.ts). + +The stable pool contract is: + +1. Reconcile registered accounts with live auth files. +2. Exclude manually paused accounts from rotation. +3. Fetch quota only for providers with an implemented quota fetcher. +4. Distinguish an exhausted account from a provider-wide or transient failure. +5. When a healthy fallback exists, CCS can create a temporary quota pause for + an exhausted account. +6. Persist the cooldown so later launches observe the same state. +7. Auto-resume only pauses created by CCS quota management; never override a + user's manual pause. + +Routing strategy remains an explicit user choice. CCS must not infer +round-robin or fill-first from account count, plan tier, or quota state. + +## Image analysis + +For profiles that need managed vision support, CCS prepares the image-analysis +route before launch: + +```text +Resolve profile and provider | - +---> Priority: CLI flags > ENV vars > config.yaml > defaults +Resolve supported provider backend | - v - +------------------+ - | ResolvedProxyConfig | - | mode: local|remote | - +------------------+ +Provision managed MCP/runtime configuration | - +---> [mode = local] ---> Spawn local CLIProxyAPI binary - | | - | v - | localhost:8317 +Target invokes ImageAnalysis | - +---> [mode = remote] ---> Connect to remote server - | - v - +------------------+ - | Health Check | remote-proxy-client.ts - | /v1/models | 2s timeout - +------------------+ - | - +---> [reachable] ---> Use remote - | | - | v - | protocol://host:port - | - +---> [unreachable] ---> Fallback decision - | - +-----------------------------+ - | - +---> [fallbackEnabled] ---> Start local - | - +---> [remoteOnly] ---> Fail with error - - CLI Flags: - --proxy-host Remote hostname/IP - --proxy-port Port (default: 8317 HTTP, 443 HTTPS) - --proxy-protocol http or https - --proxy-auth-token Bearer authentication - --local-proxy Force local mode - --remote-only Fail if remote unreachable - - Environment Variables: - CCS_PROXY_HOST Remote hostname - CCS_PROXY_PORT Remote port - CCS_PROXY_PROTOCOL Protocol (http/https) - CCS_PROXY_AUTH_TOKEN Auth token - CCS_PROXY_FALLBACK_ENABLED Enable fallback (true/false) +CCS sends provider-scoped request + | +Return text result ``` -### Configuration Resolution +The managed route must not expose its internal credential in logs or user +settings. If managed preparation, authentication, or proxy readiness is +unavailable, CCS falls back to compatible native behavior where possible +instead of failing the entire target launch. -```typescript -// proxy-config-resolver.ts: Priority order -const resolved = { - ...DEFAULT_CONFIG, // 4. Defaults (lowest) - ...yamlConfig, // 3. config.yaml - ...envConfig, // 2. Environment variables - ...cliFlags, // 1. CLI flags (highest) -}; -``` +Provider-specific vision mappings are implementation data and should be read +from the image-analysis routing services and tests. -### Health Check +## Session and observability boundary -```typescript -// remote-proxy-client.ts -async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise { - try { - const url = `${config.protocol}://${config.host}:${config.port}/v1/models`; - const response = await fetch(url, { - headers: config.authToken ? { Authorization: `Bearer ${config.authToken}` } : {}, - timeout: 2000, - }); - return response.ok; - } catch { - return false; - } -} -``` +Execution paths can record non-secret metadata such as profile identity, +profile type, canonical provider, target type, timestamps, duration, and +numeric process exit status. Logs must not contain provider tokens, API keys, +raw auth payloads, or personal account identifiers. ---- +Textual CCS log codes and numeric child-process exit statuses are different +contracts. See [Logging Contract](../logging-contract.md). -## Quota Management Flow (v7.14) +## Invariants -### Overview +- Canonical provider identity is registry-driven. +- Provider aliases normalize before execution. +- Backend restrictions are validated before starting local auth or routing. +- Remote-only mode never starts a local fallback. +- Persistent environment values are strings. +- Account display text is not a unique runtime identity. +- Manual pauses are never auto-resumed by quota management. +- Provider credentials never appear in architecture examples. -Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account. -Before local CLIProxy startup, CCS reconciles the whole active account pool for the provider, not only the default account. When CCS detects any quota-exhausted account and a healthy fallback exists, it temporarily pauses the exhausted account out of CLIProxy rotation and automatically resumes that pause after the configured cooldown expires. This durable self-pause uses the same account registry and token movement path as dashboard/manual pause, so the dashboard shows the account as paused and CLIProxy cannot rediscover its token from the live `auth/` folder. +## Related documentation -``` -+===========================================================================+ -| Quota Management Architecture (v7.14) | -+===========================================================================+ - - Pre-Flight Check (before session start) - | - v - +------------------+ - | quota-manager.ts | Hybrid quota management - +------------------+ - | - +---> Get all active accounts for provider - | - +---> For each account: - | | - | v - | +------------------+ - | | quota-fetcher.ts | Provider-specific API calls - | +------------------+ - | | - | +---> Check isPaused flag --> Skip if paused - | | - | +---> Fetch quota from provider API - | | - Antigravity: fetchAvailableModels - | | - Claude: policy limits endpoint - | | - Codex: ChatGPT usage windows - | | - Gemini CLI: Code Assist quota buckets - | | - GitHub Copilot: copilot_internal/user snapshots (deprecated compatibility) - | | - | +---> Detect tier (free/paid/unknown) - | | - | +---> Check exhaustion status - | - +---> Pause exhausted non-default accounts when another healthy account exists - | - +---> Select best account (not paused, not exhausted) - | - +---> Auto-failover to next account if current exhausted - | - +---> Temporarily pause exhausted account when fallback exists - | - move token out of live auth discovery - | - persist cooldown expiry across launches - | - auto-resume only CCS-created quota pauses - - CLI Commands: - ccs cliproxy pause --> Set isPaused=true in account-manager - ccs cliproxy resume --> Set isPaused=false - ccs cliproxy status [account] --> Display quota + tier info - - Dashboard UI: - - Pause/Resume toggle per account - - Tier badge (free/paid/unknown) - - Quota usage display -``` - -### Account Selection Algorithm - -```typescript -// quota-manager.ts: Best account selection -function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null { - // Priority: - // 1. Not paused - // 2. Not exhausted - // 3. Paid tier over free tier - // 4. Highest remaining quota - - return accounts - .filter(acc => !acc.isPaused && !acc.isExhausted) - .sort((a, b) => { - if (a.tier !== b.tier) return (a.tier === 'paid' ? -1 : 1); - return (b.remainingQuota || 0) - (a.remainingQuota || 0); - })[0] || null; -} -``` - ---- - -## Authentication Flow - -### OAuth Providers - Authorization Code Flow - -**Providers**: Gemini, Codex, Antigravity, Kiro (aws method) - -``` -+===========================================================================+ -| OAuth - Authorization Code Flow (Port-based) | -+===========================================================================+ - - 1. User runs: ccs codex - | - v - 2. Check token cache (~/.ccs/cliproxy/auth/) - | - +---> [Valid token] ---> Use cached token - | - +---> [No/Expired token] - | - v - 3. Start local OAuth server (localhost:9876) - | - v - 4. Open browser with OAuth request - | https://oauth-provider/authorize?redirect_uri=http://localhost:9876/callback - v - 5. User authorizes in browser - | - v - 6. OAuth provider redirects to localhost:9876/callback?code=XXXX - | - v - 7. Exchange auth code for access token - | - v - 8. Cache token locally (~/.ccs/cliproxy/auth/gemini.json) - | - v - 9. Proceed with Claude CLI -``` - -### OAuth Providers - Device Code Flow - -**Providers**: GitHub Copilot (ghcp, deprecated compatibility) - -Provider identity note: -- Providers that do not expose a reliable email no longer require a manual nickname during first auth. -- CCS derives a stable internal account identifier from the token/cache context and still allows the user to rename the account later. - -``` -+===========================================================================+ -| OAuth - Device Code Flow (No Port Needed) | -+===========================================================================+ - - 1. User runs: ccs ghcp - | - v - 2. Check token cache (~/.ccs/cliproxy/auth/) - | - +---> [Valid token] ---> Use cached token - | - +---> [No/Expired token] - | - v - 3. Request device code from GitHub - | - v - 4. Display user code + verification URL - | "Enter code XXXX-XXXX at github.com/login/device" - v - 5. User opens URL in browser and enters code - | - v - 6. Poll GitHub for token completion - | - v - 7. Receive and cache token locally - | - v - 8. Proceed with Claude CLI -``` - -### Kiro OAuth - Method-Aware Flow - -**Supported methods**: -- `aws`: Device Code (default, AWS org friendly) -- `aws-authcode`: Authorization Code via CLI flow -- `google`: Social OAuth via management API -- `github`: Social OAuth via management API (Dashboard flow) - -``` -+===========================================================================+ -| Kiro OAuth - Method-Aware Flow | -+===========================================================================+ - - Configuration: - ccs_profile: - target: claude - cliproxy: - provider: kiro - kiro_method: aws # or aws-authcode, google, github - - Flow: - Device Code (aws) - → /start endpoint (no callback port) - → Opens browser - → User enters code - → Poll /status - - Authorization Code (aws-authcode, google, github) - → /start-url endpoint - → Returns auth_url - → User visits URL - → Callback handled - → Poll /status for completion - - Key behavior: - - Device Code method uses /start route (no callback port) - - Callback/social methods use /start-url + status polling - - Some management flows return state first, auth_url later - - Manual nicknames are optional when the upstream provider does not return an email - - Account storage uses a stable internal identifier so reauth/update flows do not depend on dashboard list order -``` - -### API Key Profiles (GLM, Kimi) - -``` -+===========================================================================+ -| API Key Profile (Non-OAuth) | -+===========================================================================+ - - 1. User configures API key in settings - | - v - 2. Key stored in ~/.ccs/.settings.json - | - v - 3. Profile detection: APIKeyProfile - | - v - 4. Key passed via ANTHROPIC_AUTH_TOKEN env var - | - v - 5. Target adapter (Claude/Droid) handles delivery - | - └─ Claude: env var - └─ Droid: config file (~/.factory/settings.json) -``` - -### Anthropic Direct API Key - -``` -+===========================================================================+ -| Anthropic Direct API Key (Native Auth) | -+===========================================================================+ - - 1. User creates profile: ccs api create --preset anthropic - | - v - 2. Key stored in ~/.ccs/.settings.json - | env: { ANTHROPIC_API_KEY: "sk-ant-..." } - | (NO ANTHROPIC_BASE_URL, NO ANTHROPIC_AUTH_TOKEN) - v - 3. Profile detection: settings-based - | - v - 4. Key passed via ANTHROPIC_API_KEY env var - | Claude CLI uses native endpoint (api.anthropic.com) - v - 5. Claude CLI authenticates with x-api-key header - - Detection logic (profile-writer.ts): - - apiKey.startsWith('sk-ant-') -> native mode - - baseUrl.includes('api.anthropic.com') -> native mode - - Otherwise -> proxy mode (existing behavior) -``` - ---- - -## Image Analysis Hook Flow (v7.34) - -### Overview - -Image Analysis Hook enables vision model proxying through CLIProxy with automatic injection for all profile types. - -``` -+===========================================================================+ -| Image Analysis Hook Flow (v7.34) | -+===========================================================================+ - - Claude CLI with image input - | - v - Hook Installer (ensureProfileHooks) - | - +---> Check ~/.claude/hooks/openai-vision-hook.cjs exists - | - +---> If missing: auto-install via image-analyzer-hook-installer - | - v - Hook Configuration - | - +---> Set ANTHROPIC_IMAGE_HOOK_URL - | (proxy endpoint URL) - | - v - Claude CLI processes image request - | - v - Claude prefers ImageAnalysis MCP tool - | - v - CCS provider-backed image analysis - | - +---> Provider route resolved before launch - | - +---> Direct request to /api/provider//v1/messages - | - +---> Native Read fallback if runtime/auth/proxy is unavailable - | - v - Text description returned to Claude CLI -``` - -### Runtime Environment - -```typescript -// getImageAnalysisHookEnv() -{ - CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: 'http://127.0.0.1:8317', - CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy', - CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'ccs-internal-managed', -} -``` - -### Provider Support - -| Provider | Vision Support | Notes | -|----------|---|---| -| Gemini | ✓ | Via CCS ImageAnalysis provider route | -| Codex | ✓ | Via CCS ImageAnalysis provider route | -| Antigravity | ✓ | Via CCS ImageAnalysis provider route | -| Kiro | ✓ | Via mapped CCS provider route when configured | -| Copilot | ✓ | Deprecated compatibility route via mapped ghcp provider | -| GLM/Kimi | ✓ | Via explicit or fallback backend mapping | - ---- - -## Session Tracking - -All execution paths record session metadata including target CLI used: - -```typescript -{ - profileName: 'gemini', - profileType: 'clipproxy', - provider: 'google-gemini', - targetCli: 'claude', // NEW: which target was used - timestamp: '2026-02-16T10:40:00Z', - duration: 12345, - exitCode: 0, - model: 'claude-opus-4-6', -} -``` - -This enables analytics on target CLI usage and adoption. - ---- - -## Related Documentation - -- [System Architecture Index](./index.md) — Overall system design -- [Target Adapters](./target-adapters.md) — Multi-CLI adapter pattern -- [Codebase Summary](../codebase-summary.md) — Module structure -- [Code Standards](../code-standards.md) — Implementation guidelines +- [System Architecture](./index.md) +- [Target Adapters](./target-adapters.md) +- [Logging Contract](../logging-contract.md) +- [Codebase Summary](../codebase-summary.md) +- [Code Standards](../code-standards.md) diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index a2b11eb4..6218f087 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -1,803 +1,181 @@ # Target Adapters -Last Updated: 2026-05-18 +Target adapters are the last-mile boundary between CCS profile resolution and a +supported CLI runtime. Profile discovery and credential resolution happen +before this boundary; the selected adapter owns binary detection, target-native +credential delivery, argument and environment construction, and child-process +execution. -Detailed documentation of the target adapter pattern and implementations. +Related architecture: ---- +- [System architecture](./index.md) +- [Provider flows](./provider-flows.md) -## Overview +## Contract -The target adapter system enables CCS to dispatch credential-resolved profiles to different CLI implementations while maintaining a unified configuration and profile system. +The canonical interface and data types live in +[`src/targets/target-adapter.ts`](../../src/targets/target-adapter.ts). Each +adapter must: -**Key insight**: Profile resolution (detecting provider, loading auth, building credentials) is target-agnostic. Only the final credential delivery and process spawning differ per target. +1. detect its runtime binary without changing user configuration; +2. reject unsupported profile types before launch; +3. prepare only the target-owned configuration needed for the launch; +4. construct an argument vector and environment without exposing credentials in + arguments; +5. spawn the target with inherited stdio and forward process signals. ---- +The registry in +[`src/targets/target-registry.ts`](../../src/targets/target-registry.ts) maps a +target type to its adapter. Target names, built-in aliases, legacy alias +environment variables, and persistence eligibility are centralized in +[`src/targets/target-metadata.ts`](../../src/targets/target-metadata.ts). -## Target Adapter Interface - -Each CLI target implements the `TargetAdapter` contract: - -```typescript -export interface TargetAdapter { - readonly type: TargetType; // 'claude' | 'droid' | 'codex' - readonly displayName: string; // "Claude Code" | "Factory Droid" | "Codex CLI" - - /** Detect if the target CLI binary exists on system */ - detectBinary(): TargetBinaryInfo | null; - - /** Prepare credentials for delivery to target CLI */ - prepareCredentials(creds: TargetCredentials): Promise; - - /** Build spawn arguments for the target CLI */ - buildArgs( - profile: string, - userArgs: string[], - options?: { - creds?: TargetCredentials; - profileType?: ProfileType; - binaryInfo?: TargetBinaryInfo; - } - ): string[]; - - /** Build environment variables for the target CLI */ - buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv; - - /** Spawn the target CLI process (replaces current process flow) */ - exec(args: string[], env: NodeJS.ProcessEnv, options?: { cwd?: string }): void; - - /** Check if a profile type is supported by this target */ - supportsProfileType(profileType: string): boolean; -} -``` - -### Type Definitions - -```typescript -export type TargetType = 'claude' | 'droid' | 'codex'; - -export interface TargetCredentials { - baseUrl: string; // API endpoint - apiKey: string; // Auth token - model?: string; // Model ID - provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api'; - envVars?: NodeJS.ProcessEnv; // Additional env vars -} - -export interface TargetBinaryInfo { - path: string; // Full path to binary - needsShell: boolean; // Windows .cmd/.bat/.ps1? - version?: string; // Optional version string - features?: readonly string[]; // Capability probes -} -``` - ---- +Do not duplicate the TypeScript interface in this guide. Interface signatures +and credential fields change with runtime requirements; the source files are +the contract checked by the compiler. ## Target Resolution -CCS resolves which adapter to use via priority-ordered checks: - -### Resolution Priority - -``` -1. --target flag (CLI argument) — highest priority - └─ ccs --target droid glm - └─ ccs --target codex - -2. Explicit runtime entrypoint (`CCS_INTERNAL_ENTRY_TARGET`) — dedicated bin shims - └─ ccs-droid / ccsd → droid - └─ ccs-codex / ccsx → codex - └─ ccsxp → codex, then prepends `--config model_provider="cliproxy"` - -3. argv[0] detection (runtime alias pattern) — binary name mapping for same-binary/custom aliases - └─ ccs-droid (explicit alias) → droid - └─ ccsd (legacy shortcut) → droid - └─ ccs-codex (explicit alias) → codex - └─ ccsx (short alias) → codex - └─ ccs (regular command) → default - -4. Per-profile config (from ~/.ccs/config.yaml or settings.json) - └─ persisted targets are currently only `claude` and `droid` - └─ profiles: - glm: - target: droid - -5. Fallback: 'claude' — lowest priority -``` - -### Implementation - -```typescript -// src/targets/target-resolver.ts - -export function resolveTargetType( - args: string[], - profileConfig?: { target?: TargetType } -): TargetType { - // 1. Parse --target flags (supports --target value and --target=value) - // Repeated flags: last one wins. - const parsed = parseTargetFlags(args); - if (parsed.targetOverride) { - return parsed.targetOverride; - } - - // 2. Check explicit runtime entrypoint shim - const entrypointTarget = resolveEntrypointTarget(); - if (entrypointTarget) { - return entrypointTarget; - } - - // 3. Check argv[0] (binary name / custom alias map) - const binName = path.basename(process.argv[1] || process.argv0 || '').replace(/\.(cmd|bat|ps1|exe)$/i, ''); - if (ARGV0_TARGET_MAP[binName]) { - return ARGV0_TARGET_MAP[binName]; - } - - // 4. Check profile config - if (profileConfig?.target) { - // Persisted targets are validated before profile configuration is saved. - return profileConfig.target; - } - - // 5. Default to claude - return 'claude'; -} -``` - ---- - -## Claude Adapter - -### Implementation - -```typescript -// src/targets/claude-adapter.ts - -export class ClaudeAdapter implements TargetAdapter { - readonly type: TargetType = 'claude'; - readonly displayName = 'Claude Code'; - - detectBinary(): TargetBinaryInfo | null { - const info = getClaudeCliInfo(); - if (!info) return null; - return { path: info.path, needsShell: info.needsShell }; - } - - async prepareCredentials(_creds: TargetCredentials): Promise { - // No-op: Claude receives credentials via environment variables - } - - buildArgs(_profile: string, userArgs: string[]): string[] { - return userArgs; // Pass through user arguments unchanged - } - - buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv { - const webSearchEnv = getWebSearchHookEnv(); - - // For native profiles, strip stale proxy env to prevent interference - const baseEnv = - profileType === 'account' || profileType === 'default' - ? stripAnthropicEnv(process.env) - : process.env; - - const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv }; - - if (creds.envVars) { - Object.assign(env, creds.envVars); - } - - // Deliver credentials via environment variables - if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl; - if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey; - if (creds.model) env['ANTHROPIC_MODEL'] = creds.model; - - return env; - } - - exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void { - const claudeCli = detectClaudeCli(); - if (!claudeCli) { - void ErrorManager.showClaudeNotFound(); - process.exit(1); - return; - } - - // Handle Windows shell requirements - const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); - - let child: ChildProcess; - if (needsShell) { - const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); - child = spawn(cmdString, { shell: true, stdio: 'inherit', env }); - } else { - child = spawn(claudeCli, args, { stdio: 'inherit', env }); - } - - // Handle process termination - const onSigInt = () => child.kill('SIGINT'); - const onSigTerm = () => child.kill('SIGTERM'); - process.once('SIGINT', onSigInt); - process.once('SIGTERM', onSigTerm); - child.on('exit', () => { - process.removeListener('SIGINT', onSigInt); - process.removeListener('SIGTERM', onSigTerm); - }); - } - - supportsProfileType(profileType: string): boolean { - // Claude supports all profile types - return true; - } -} -``` - -Native Claude launches keep user arguments session-scoped. The launch layer validates and normalizes -`--effort low|medium|high|xhigh|max` before spawning Claude, then passes it through without writing -to Claude or CCS configuration. CLIProxy-backed Claude launches still treat `--effort` as the CCS -thinking alias handled by CLIProxy. - -### Credential Delivery - -**Method**: Environment variables - -```bash -export ANTHROPIC_BASE_URL=https://api.anthropic.com -export ANTHROPIC_AUTH_TOKEN=sk-ant-... -export ANTHROPIC_MODEL=claude-opus-4-6 -export WEBSEARCH_HOOK_ENV=... # Image analysis, websearch -``` - -### Execution - -```bash -# Direct invocation -ccs codex -→ claude "args..." - with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN set - -# With --target override -ccs --target claude glm -→ claude "args..." - with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN set -``` - ---- - -## Droid Adapter - -### Implementation - -```typescript -// src/targets/droid-adapter.ts - -export class DroidAdapter implements TargetAdapter { - readonly type: TargetType = 'droid'; - readonly displayName = 'Factory Droid'; - - detectBinary(): TargetBinaryInfo | null { - const info = getDroidBinaryInfo(); - if (!info) return null; - - // Non-blocking version compatibility check - checkDroidVersion(info.path); - return info; - } - - async prepareCredentials(creds: TargetCredentials): Promise { - // Write custom model entry to ~/.factory/settings.json - await upsertCcsModel(creds.profile, { - model: creds.model || 'claude-opus-4-6', - displayName: `CCS ${creds.profile}`, - baseUrl: creds.baseUrl, - apiKey: creds.apiKey, - provider: creds.provider || 'anthropic', - }); - } - - buildArgs(profile: string, userArgs: string[]): string[] { - // Droid uses -m syntax for model selection - return ['-m', `custom:ccs-${profile}`, ...userArgs]; - } - - buildEnv(_creds: TargetCredentials, _profileType: string): NodeJS.ProcessEnv { - // Droid reads from config file — minimal env needed - return { ...process.env }; - } - - exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void { - const droidPath = detectDroidCli(); - if (!droidPath) { - console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli'); - process.exit(1); - return; - } - - // Handle Windows shell requirements - const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(droidPath); - - let child: ChildProcess; - if (needsShell) { - const cmdString = [droidPath, ...args].map(escapeShellArg).join(' '); - child = spawn(cmdString, { shell: true, stdio: 'inherit', env }); - } else { - child = spawn(droidPath, args, { stdio: 'inherit', env }); - } - - // Handle process termination - const onSigInt = () => child.kill('SIGINT'); - const onSigTerm = () => child.kill('SIGTERM'); - process.once('SIGINT', onSigInt); - process.once('SIGTERM', onSigTerm); - child.on('exit', () => { - process.removeListener('SIGINT', onSigInt); - process.removeListener('SIGTERM', onSigTerm); - }); - } - - supportsProfileType(profileType: string): boolean { - // Droid currently supports direct settings/default paths only - return profileType === 'settings' || profileType === 'default'; - } -} -``` - -### Credential Delivery - -**Method**: Config file (`~/.factory/settings.json`) - -```json -{ - "customModels": [ - { - "model": "claude-opus-4-6", - "displayName": "CCS gemini", - "baseUrl": "https://generativelanguage.googleapis.com/v1beta/openai/", - "apiKey": "AIza...", - "provider": "openai" - }, - { - "model": "glm-4", - "displayName": "CCS glm", - "baseUrl": "https://open.bigmodel.cn/api/paas/v4/", - "apiKey": "your-glm-key", - "provider": "openai" - } - ] -} -``` - -### Execution - -```bash -# Direct invocation -ccs codex -→ droid -m custom:ccs-codex "args..." - (credentials loaded from ~/.factory/settings.json) - -# With --target override -ccs --target droid glm -→ droid -m custom:ccs-glm "args..." - (credentials loaded from ~/.factory/settings.json) -``` - -### Runtime Alias Pattern - -```bash -# Built-in package bin aliases -ccs-droid glm -→ Target: droid (forced by runtime alias) -→ droid -m custom:ccs-glm "args..." - -# Legacy shortcut still works -ccsd glm -→ Target: droid (forced by runtime alias) -→ droid -m custom:ccs-glm "args..." -``` - -On Windows, `ccs-droid.cmd`, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized. - -Additional alias names can be configured at runtime after you create a matching -symlink or another launcher that preserves the invoked basename. Use `CCS_TARGET_ALIASES` (preferred, -`target=alias1,alias2;...`) or legacy `CCS_DROID_ALIASES` (comma-separated). -Example: - -```bash -ln -s /path/to/ccs /path/to/mydroid -CCS_TARGET_ALIASES=droid=mydroid -``` - ---- - -## Codex Adapter - -### Implementation - -The Codex adapter keeps CCS-backed Codex launches transient. It does not rewrite -`~/.codex/config.toml`. Instead it: - -- passes through native default Codex sessions unchanged -- probes the installed Codex binary for `--config ` support -- injects CCS-backed provider credentials through temporary `-c` overrides -- stores the routed API key only in process env via `CCS_CODEX_API_KEY` - -```typescript -// src/targets/codex-adapter.ts - -export class CodexAdapter implements TargetAdapter { - readonly type: TargetType = 'codex'; - readonly displayName = 'Codex CLI'; - - detectBinary(): TargetBinaryInfo | null { - return getCodexBinaryInfo(); - } - - async prepareCredentials(_creds: TargetCredentials): Promise { - // No file writes. Codex uses transient -c overrides plus env_key injection. - } - - buildArgs(profile: string, userArgs: string[], options?: BuildOptions): string[] { - if ((options?.profileType || 'default') === 'default') { - return userArgs; - } - - if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { - throw new Error('Upgrade Codex before using CCS-backed Codex profiles.'); - } - - return [ - '-c', - 'model_provider=\"ccs_runtime\"', - '-c', - 'model_providers.ccs_runtime.base_url=\"http://127.0.0.1:8317/api/provider/codex\"', - '-c', - 'model_providers.ccs_runtime.env_key=\"CCS_CODEX_API_KEY\"', - '-c', - 'model_providers.ccs_runtime.wire_api=\"responses\"', - ...userArgs, - ]; - } - - buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv { - const env = { ...stripAnthropicEnv(process.env) }; - if (profileType !== 'default') { - env['CCS_CODEX_API_KEY'] = creds.apiKey; - } - return env; - } -} -``` - -### Support Matrix - -Codex is a real runtime target, but it is intentionally narrower than Claude or Droid in v1: - -| Profile Type | Codex Target | Notes | -|--------------|--------------|-------| -| `default` | Yes | Uses existing native Codex auth/config | -| `cliproxy` provider=`codex` | Yes | Routed through CLIProxy Codex Responses bridge | -| `cliproxy` composite | No | Not proven native-Codex-safe | -| `settings` with Codex bridge metadata | Yes | Only when the API profile resolves to a Codex CLIProxy bridge | -| `settings` generic API profile | No | Claude/Droid only | -| `account` | No | Claude-only account isolation concept | -| `copilot` | No | Not a native Codex provider path | - -### Codex Dashboard Surface - -CCS also exposes a dedicated dashboard route at `ccs config` -> `Compatible` -> `Codex CLI`. -That page is intentionally narrower than the Droid dashboard in overall scope, but it is no -longer read-mostly: - -- reads and writes only the user config layer: `~/.codex/config.toml` or `$CODEX_HOME/config.toml` -- provides guided controls for top-level settings, project trust, profiles, model providers, - MCP servers, and supported feature flags -- keeps a raw `config.toml` editor as the escape hatch for unsupported or fidelity-sensitive edits -- shows binary detection, user-layer config summaries, support-matrix guidance, and upstream docs -- normalizes TOML formatting and drops comments on structured saves -- keeps structured controls disabled while raw TOML is dirty or invalid, validates project trust - paths as absolute or `~/...`, and lets feature flags reset back to Codex defaults -- warns that transient CCS runtime overrides such as `codex -c key=value` and - `CCS_CODEX_API_KEY` can change the effective runtime without persisting into the file editor - -This keeps the dashboard honest about Codex's merged configuration model while still giving users -one place to inspect and manage the user-owned layer safely. - -### Runtime Entrypoints and argv[0] Fallback - -```bash -# Built-in package bin entrypoints -ccs-codex -→ dist/bin/codex-runtime.js -→ CCS_INTERNAL_ENTRY_TARGET=codex - -ccsx -→ dist/bin/codex-runtime.js -→ CCS_INTERNAL_ENTRY_TARGET=codex -→ passes native Codex diagnostics plus known upstream Codex subcommands and aliases through before CCS profile detection -→ reserves CCS-owned `auth`, `doctor`, and `update` - -ccsxp -→ dist/bin/ccsxp-runtime.js -→ CCS_INTERNAL_ENTRY_TARGET=codex -→ injects native `model_provider="cliproxy"` override -→ pins CODEX_HOME to native `~/.codex` unless `CCSXP_CODEX_HOME` is set -→ repairs `[model_providers.cliproxy]` in the active Codex `config.toml` -→ preserves valid custom `base_url` values for remote or non-default CLIProxy endpoints -→ injects the effective CCS CLIProxy auth token into the provider's configured `env_key` -→ ignores the configured CCS default account/profile and stays in native Codex default mode -``` - -If a user launches CCS through a custom shim instead of the built-in package bins, target -resolution falls back to `argv[0]` aliases from `CCS_TARGET_ALIASES` or legacy -`CCS_CODEX_ALIASES`: - -```bash -ln -s /path/to/ccs /path/to/mycodex -CCS_TARGET_ALIASES='codex=mycodex' -# Legacy fallback: -CCS_CODEX_ALIASES='mycodex' -``` - ---- - -## Registry and Lookup - -The target registry is a simple map-based store for adapters: - -```typescript -// src/targets/target-registry.ts - -const adapters = new Map(); - -export function registerTarget(adapter: TargetAdapter): void { - adapters.set(adapter.type, adapter); -} - -export function getTarget(type: TargetType): TargetAdapter { - const adapter = adapters.get(type); - if (!adapter) { - throw new Error(`Unknown target "${type}"`); - } - return adapter; -} - -export function getDefaultTarget(): TargetAdapter { - return getTarget('claude'); -} -``` - -### Adapter Registration - -At startup, adapters self-register: - -```typescript -// src/ccs.ts (initialization) - -registerTarget(new ClaudeAdapter()); -registerTarget(new DroidAdapter()); -registerTarget(new CodexAdapter()); -``` - ---- - -## Execution Flow - -### Step-by-Step - -``` -1. Parse command-line arguments - └─ args: ['--target', 'droid', 'glm'] - -2. Resolve target type - └─ resolveTargetType(args) → 'droid' - └─ stripTargetFlag(args) → ['glm'] - -3. Detect and resolve profile - └─ detectProfile(['glm']) → { profile: 'glm', ... } - └─ Load credentials from config/CLIProxy/env - -4. Build credentials object - └─ TargetCredentials { - baseUrl: '...', - apiKey: '...', - model: 'claude-opus-4-6', - envVars: { CCS_PROFILE_NAME: 'glm', ... } - } - -5. Get target adapter - └─ getTarget('droid') → DroidAdapter instance - -6. Prepare credentials - └─ adapter.prepareCredentials(creds) - └─ DroidAdapter: writes to ~/.factory/settings.json - -7. Build spawn arguments - └─ adapter.buildArgs('glm', []) → ['-m', 'custom:ccs-glm'] - -8. Build environment - └─ adapter.buildEnv(creds, profileType) → process.env - -9. Spawn target CLI - └─ adapter.exec(spawnArgs, env) - └─ exec spawn('droid', ['-m', 'custom:ccs-glm', ...]) - -10. Replace current process - └─ Child process inherits stdio - └─ Signal handlers propagate to child -``` - ---- - -## Adding a New Target - -To support a new CLI (e.g., MyAI CLI), follow this pattern: - -### 1. Create Adapter Class - -```typescript -// src/targets/myai-adapter.ts - -export class MyAiAdapter implements TargetAdapter { - readonly type: TargetType = 'myai'; - readonly displayName = 'MyAI CLI'; - - detectBinary(): TargetBinaryInfo | null { - const path = which.sync('myai', { nothrow: true }); - if (!path) return null; - return { path, needsShell: process.platform === 'win32' }; - } - - async prepareCredentials(creds: TargetCredentials): Promise { - // Write to ~/.myai/config or similar - } - - buildArgs(profile: string, userArgs: string[]): string[] { - return ['-p', profile, ...userArgs]; - } - - buildEnv(creds: TargetCredentials, _profileType: string): NodeJS.ProcessEnv { - return { - ...process.env, - MYAI_API_KEY: creds.apiKey, - MYAI_API_URL: creds.baseUrl, - }; - } - - exec(args: string[], env: NodeJS.ProcessEnv): void { - const myaiPath = this.detectBinary()?.path; - if (!myaiPath) { - console.error('[X] MyAI CLI not found'); - process.exit(1); - } - spawn(myaiPath, args, { stdio: 'inherit', env }); - } - - supportsProfileType(profileType: string): boolean { - return true; // or implement specific logic - } -} -``` - -### 2. Update Type Definition - -```typescript -// src/targets/target-adapter.ts - -export type TargetType = 'claude' | 'droid' | 'codex' | 'myai'; -``` - -### 3. Register in ccs.ts - -```typescript -registerTarget(new MyAiAdapter()); -``` - -### 4. Update Documentation - -- Add to [Codebase Summary](../codebase-summary.md) -- Update Code Standards adapter examples -- Document CLI-specific behavior - ---- - -## Cross-Platform Considerations - -### Windows Shell Detection - -Both adapters check for shell-requiring binaries: - -```typescript -const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(binaryPath); - -if (needsShell) { - const cmdString = [binaryPath, ...args].map(escapeShellArg).join(' '); - spawn(cmdString, { shell: true, stdio: 'inherit' }); -} else { - spawn(binaryPath, args, { stdio: 'inherit' }); -} -``` - -### Environment Variable Escaping - -Arguments passed to shell are escaped to prevent injection: - -```typescript -export function escapeShellArg(arg: string): string { - // Wrap in quotes and escape internal quotes - return `"${arg.replace(/"/g, '\\"')}"`; -} -``` - -### Signal Handling - -Both adapters propagate signals from parent to child: - -```typescript -const onSigInt = () => child.kill('SIGINT'); -const onSigTerm = () => child.kill('SIGTERM'); -process.once('SIGINT', onSigInt); -process.once('SIGTERM', onSigTerm); - -child.on('exit', () => { - process.removeListener('SIGINT', onSigInt); - process.removeListener('SIGTERM', onSigTerm); -}); -``` - -This ensures CTRL+C and graceful shutdowns work correctly. - ---- - -## Testing Target Adapters - -### Unit Tests - -```typescript -describe('ClaudeAdapter', () => { - it('detects Claude CLI', () => { - const adapter = new ClaudeAdapter(); - const binary = adapter.detectBinary(); - expect(binary).not.toBeNull(); - }); - - it('builds env with credentials', () => { - const adapter = new ClaudeAdapter(); - const env = adapter.buildEnv({ - baseUrl: 'https://api.anthropic.com', - apiKey: 'sk-ant-...', - model: 'claude-opus-4-6', - }, 'cliproxy'); - - expect(env['ANTHROPIC_AUTH_TOKEN']).toBe('sk-ant-...'); - }); -}); -``` - -### Integration Tests - -```bash -# Test Claude adapter -ccs --target claude help - -# Test Droid adapter (if installed) -ccs --target droid help - -# Test Codex adapter (if installed) -ccs --target codex -ccs-codex -ccsxp - -# Test argv[0] detection -ccs-droid help -ccsx -``` - ---- - -## Related Documentation - -- [Codebase Summary](../codebase-summary.md) — Module structure -- [Code Standards](../code-standards.md) — Adapter pattern guidelines -- [System Architecture Index](./index.md) — Overall system design +[`src/targets/target-resolver.ts`](../../src/targets/target-resolver.ts) selects +the runtime in this order: + +1. `--target ` or `--target=`; if repeated, the last flag wins. +2. A trusted package runtime entrypoint identified by + `CCS_INTERNAL_ENTRY_TARGET`. +3. The invoked binary name, including built-in or configured aliases. +4. A persisted per-profile target. +5. `claude`. + +All `--target` flags are removed before the remaining arguments reach the +runtime. Parsing stops at the `--` option terminator. + +The built-in runtime aliases are derived from target metadata: + +| Target | Built-in aliases | +| --- | --- | +| Claude Code | Base `ccs` command | +| Factory Droid | `ccs-droid`, `ccsd` | +| Codex CLI | `ccs-codex`, `ccsx`, `ccsxp` | + +Custom aliases use `CCS_TARGET_ALIASES` with entries such as +`droid=team-droid;codex=team-codex`. The target-specific legacy environment +variables remain compatibility inputs. Alias values are validated and cannot +replace reserved package binary names. + +## Runtime Compatibility + +Adapter-level checks are deliberately conservative. Flow-specific compatibility +is evaluated in +[`src/targets/target-runtime-compatibility.ts`](../../src/targets/target-runtime-compatibility.ts), +which has the provider and bridge context needed for an accurate decision. + +| Profile flow | Claude | Droid | Codex | +| --- | --- | --- | --- | +| Native default | Supported | Conditional: requires resolved `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` | Supported | +| Settings/API profile | Supported | Supported | Codex CLIProxy bridge only | +| CLIProxy profile | Supported | Supported | Non-composite `codex` provider only | +| Claude account | Supported | Not supported | Not supported | +| Copilot | Supported | Not supported | Not supported | +| Cursor local proxy | Supported | Not supported | Not supported | + +When changing compatibility, update both the runtime evaluator and its focused +tests. The authoritative coverage is in +[`tests/unit/targets/target-runtime-compatibility.test.ts`](../../tests/unit/targets/target-runtime-compatibility.test.ts). + +The compatibility evaluator accepts Droid's native-default flow, but adapter +preparation still validates the resolved credentials. A default Droid launch +cannot proceed without both a non-empty base URL and auth token. + +## Credential and Configuration Boundaries + +### Claude Code + +[`src/targets/claude-adapter.ts`](../../src/targets/claude-adapter.ts) delivers +resolved provider values through the child environment. Native account and +default launches remove stale Anthropic routing variables before execution. +Browser and WebSearch launch preparation may add runtime-specific arguments and +environment values. + +The adapter does not persist provider credentials to Claude settings. + +### Factory Droid + +[`src/targets/droid-adapter.ts`](../../src/targets/droid-adapter.ts) validates +the resolved base URL and token, then delegates the target-owned write to +[`src/targets/droid-config-manager.ts`](../../src/targets/droid-config-manager.ts). +That manager updates the CCS custom model and active model in Factory settings. +The adapter passes user arguments through; it does not inject a `-m` selector. + +Factory settings are a persistent user-owned surface. Writes must preserve +unrelated settings and use the configuration manager rather than direct JSON +replacement. + +### Codex CLI + +[`src/targets/codex-adapter.ts`](../../src/targets/codex-adapter.ts) keeps normal +CCS-backed launches transient: + +- native default sessions keep native Codex authentication and configuration; +- CCS-backed sessions use `-c key=value` overrides for a Responses-compatible + runtime provider; +- the resolved API key is supplied through the provider's environment key, not + on the command line; +- stale Anthropic and nested Codex-session variables are removed before spawn. + +The `ccsxp` shortcut is the explicit exception. It may repair the dedicated +`cliproxy` provider block in the active Codex configuration through +[`src/targets/codex-cliproxy-provider-config.ts`](../../src/targets/codex-cliproxy-provider-config.ts). +That repair preserves a valid custom base URL and the configured environment-key +name. General Codex launches must not rewrite `config.toml`. + +## Execution Invariants + +The dispatcher owns the order of operations: + +1. parse CCS-owned arguments and resolve the target; +2. resolve the profile and provider credentials; +3. evaluate target/profile/provider compatibility; +4. detect the target binary; +5. call `prepareCredentials`; +6. build target arguments and environment; +7. execute the child runtime. + +Flow implementations live under +[`src/dispatcher/flows/`](../../src/dispatcher/flows/). Shared target execution +logic lives in +[`src/dispatcher/target-executor.ts`](../../src/dispatcher/target-executor.ts). + +All adapters must preserve these invariants: + +- user credentials never appear in documented examples, logs, or spawn + arguments; +- target-owned persistent writes are explicit and scoped; +- stale routing variables from another runtime do not leak into the child; +- Windows wrapper handling does not use a shell unless the wrapper format + requires one; +- child signals and exit behavior propagate to the CCS process; +- binary and launch failures run registered cleanup before exit. + +## Adding or Changing a Target + +Change the smallest complete set: + +1. update `TargetType` and the adapter contract only when required; +2. add target metadata and aliases in `target-metadata.ts`; +3. implement and register the adapter through `src/targets/index.ts`; +4. add flow-aware compatibility rules; +5. add focused resolver, adapter, compatibility, and integration tests; +6. update CLI help and dashboard controls if the target becomes user + configurable. + +Start with these test suites: + +- [`tests/unit/targets/target-resolver.test.ts`](../../tests/unit/targets/target-resolver.test.ts) +- [`tests/unit/targets/target-registry.test.ts`](../../tests/unit/targets/target-registry.test.ts) +- [`tests/unit/targets/target-runtime-compatibility.test.ts`](../../tests/unit/targets/target-runtime-compatibility.test.ts) +- target-specific tests under + [`tests/unit/targets/`](../../tests/unit/targets/) + +Do not describe a target as supported from adapter registration alone. Support +requires a compatible profile flow, safe credential delivery, binary detection, +execution behavior, and tests for the full combination. diff --git a/docs/websearch.md b/docs/websearch.md index f83ede4c..4cac685e 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -1,96 +1,85 @@ # WebSearch Configuration Guide -Last Updated: 2026-04-11 +CCS provides a local `WebSearch` MCP tool for managed Claude launches that use a +third-party provider. Native Claude sessions keep Anthropic's native search +behavior. -CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API. +## Launch Contract -## How WebSearch Works +For a third-party Claude launch, CCS: -### Native Claude Accounts +1. suppresses the native `WebSearch` tool because the third-party backend cannot + execute it; +2. when WebSearch is enabled, adds a short steering prompt that prefers the CCS + MCP `WebSearch` tool; +3. when WebSearch is enabled, installs the managed MCP server and adds the + `ccs-websearch` entry to the applicable Claude configuration; +4. searches enabled and ready providers in deterministic order. -Native Claude subscription accounts still use Anthropic's server-side WebSearch directly. +Enabled launches fail closed if the MCP runtime or configuration cannot be +prepared. This prevents a launch where native search is suppressed but the +managed replacement is missing. When `websearch.enabled` is `false`, CCS skips +MCP provisioning but still suppresses native WebSearch for third-party +profiles; the model can use ordinary network or shell tools when allowed. -### Third-Party Profiles +Claude subcommands that reject session flags are passed through without +WebSearch argument injection. -Third-party profiles cannot execute Anthropic's server-side WebSearch because the tool never reaches their backend. CCS now handles that by provisioning a first-class local MCP tool when the managed runtime is available, suppressing native `WebSearch` for those launches, appending a short launch-time steering hint, and running real local search providers directly. +Implementation sources: -## Architecture +- [`src/utils/websearch/mcp-installer.ts`](../src/utils/websearch/mcp-installer.ts) +- [`src/utils/websearch/claude-tool-args.ts`](../src/utils/websearch/claude-tool-args.ts) +- [`src/dispatcher/flows/settings-flow.ts`](../src/dispatcher/flows/settings-flow.ts) +- [`lib/mcp/ccs-websearch-server.cjs`](../lib/mcp/ccs-websearch-server.cjs) -``` -┌──────────────────────────────────────────────────────────────────┐ -│ Claude Code CLI │ -│ │ -│ Search Request │ -│ │ │ -│ ├── Native Claude Account? → Anthropic WebSearch API │ -│ │ │ -│ └── Third-party Profile? → native WebSearch disabled │ -│ │ │ -│ ├── CCS MCP tool when ready │ -│ │ ccs-websearch.WebSearch │ -│ │ │ │ -│ │ ├── 1. Exa │ -│ │ ├── 2. Tavily │ -│ │ ├── 3. Brave │ -│ │ ├── 4. SearXNG │ -│ │ ├── 5. DuckDuckGo│ -│ │ └── 6. Legacy CLI│ -│ │ fallback │ -│ │ (Gemini/ │ -│ │ OpenCode/│ -│ │ Grok) │ -│ └── Bash/network fallback │ -└──────────────────────────────────────────────────────────────────┘ -``` +## Provider Order, Runtime Eligibility, and Dashboard Status -## Why This Changed +The managed runtime tries eligible providers sequentially: -The previous design asked another model CLI to perform web search and summarize the answer. A later compatibility path also depended on a denied native-tool hook. Both were brittle: +1. Exa +2. Tavily +3. Brave Search +4. SearXNG +5. DuckDuckGo +6. Antigravity CLI +7. Gemini CLI compatibility fallback +8. OpenCode +9. Grok CLI -- CLI syntax changed upstream -- auth state varied per tool -- prompt/tool behavior drifted across releases -- hook-shaped denial output produced awkward host UX +The first successful provider wins. Temporarily failing providers can enter a +bounded cooldown and be skipped on later calls. Runtime attempt eligibility is +defined in +[`lib/hooks/websearch-transformer.cjs`](../lib/hooks/websearch-transformer.cjs); +do not duplicate that list in other architecture docs. -The new flow matches the `goclaw` model more closely: web search is treated as a first-class deterministic capability, not an LLM-to-LLM workaround or a denied native tool call. +The transformer and dashboard answer related but different questions: -When provisioned, the managed MCP tool is exposed as `ccs-websearch.WebSearch`, not a generic `search` helper. That naming is deliberate: it gives Claude a tool that matches the native `WebSearch` concept more directly, which should reduce cases where the model reaches for ad hoc Bash or `curl` fetches instead. +| Provider | Transformer attempts when | Dashboard reports available when | +| --- | --- | --- | +| Exa | Enabled and `EXA_API_KEY` is present. | Enabled and the key is available through the active process or enabled Global Env. | +| Tavily | Enabled and `TAVILY_API_KEY` is present. | Enabled and the key is available through the active process or enabled Global Env. | +| Brave Search | Enabled and `BRAVE_API_KEY` is present. | Enabled and the key is available through the active process or enabled Global Env. | +| SearXNG | Enabled and a base URL is present. | Enabled with a valid normalized base URL. | +| DuckDuckGo | Enabled. | Enabled. | +| Antigravity | Enabled and the `agy` executable is available. | Enabled and the CLI is installed. | +| Gemini compatibility | Enabled and the `gemini` executable is available. | Enabled, installed, and authenticated. | +| OpenCode | Enabled and the `opencode` executable is available. | Enabled and the CLI is installed. | +| Grok | Enabled and the `grok` executable is available. | Enabled, installed, and `GROK_API_KEY` is available. | -CCS also appends a third-party-only `--append-system-prompt` hint telling Claude to prefer that managed `WebSearch` tool for web lookups and current-information requests. This is soft steering only: if the user explicitly asks for shell commands, or the tool is unavailable, Claude can still fall back to Bash/network tools. -That shared launch helper applies to normal third-party settings profiles, CLIProxy/Copilot-backed Claude launches, and CCS headless/delegation runs that execute through a settings profile. - -`websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party profiles. That native tool cannot be satisfied by Exa, Tavily, Brave, DuckDuckGo, or other non-Anthropic backends, so CCS avoids sending a broken native-tool request and lets Claude fall back to normal shell/network tools instead. - -## Providers - -| Provider | Type | Setup | Default | Notes | -|----------|------|-------|---------|-------| -| Exa | HTTP API | `EXA_API_KEY` | No | High-quality API search with extracted content | -| Tavily | HTTP API | `TAVILY_API_KEY` | No | Agent-oriented search API | -| Brave Search | HTTP API | `BRAVE_API_KEY` | No | Cleaner snippets and metadata | -| SearXNG | JSON API | `providers.searxng.url` | No | Self-hosted/public SearXNG backend via `/search?format=json` | -| DuckDuckGo | HTML fetch | None | Yes | Built-in zero-setup fallback | -| Antigravity (agy) | LLM CLI | `curl -fsSL https://antigravity.google/cli/install.sh \| bash` | No | Recommended LLM CLI fallback (Gemini CLI successor) | -| Gemini CLI | LLM CLI | Deprecated, use Antigravity (agy) | No | Deprecated. Google retired the gemini CLI on 2026-06-18 | -| OpenCode | LLM CLI | `curl -fsSL https://opencode.ai/install \| bash` | No | Optional compatibility fallback | -| Grok CLI | LLM CLI | `npm i -g @vibe-kit/grok-cli` + `GROK_API_KEY` | No | Optional compatibility fallback | +DuckDuckGo is the default zero-setup provider. API-backed and CLI fallback +providers are disabled by default. Dashboard availability and setup guidance +are computed separately by +[`src/utils/websearch/status.ts`](../src/utils/websearch/status.ts); they do not +change the transformer's attempt predicate. ## Configuration -### Via Dashboard +Use `ccs config` and open `Settings` → `WebSearch`, or edit the `websearch` +section in the unified CCS configuration: -Open `ccs config` → `Settings` → `WebSearch`. - -- Enable Exa, Tavily, Brave, SearXNG, or DuckDuckGo in the backend chain -- Configure the SearXNG base URL (for example `https://search.example.com`) when SearXNG is enabled - Do not include `/search`, embedded credentials, query parameters, or URL fragments. CCS appends `/search?format=json`. -- Set or rotate Exa, Tavily, and Brave API keys directly inside each provider card -- Saved keys are persisted in `global_env` and injected at runtime, so readiness updates from the same screen -- Review whether any legacy fallback CLIs are still enabled in config - -### Via Config File - -Edit `~/.ccs/config.yaml`: +The runtime schema supports Antigravity through `providers.agy`; the current +dashboard editor does not expose that provider, so configure it in +`config.yaml`. ```yaml websearch: @@ -129,80 +118,84 @@ websearch: timeout: 55 ``` -Note: `enabled: false` stops provisioning the managed local `ccs-websearch.WebSearch` runtime. It does not re-enable Anthropic's native `WebSearch` for third-party backends. +The schema is +[`src/config/schemas/websearch.ts`](../src/config/schemas/websearch.ts), and +defaults are in +[`src/config/schemas/unified-config.ts`](../src/config/schemas/unified-config.ts). +Deprecated top-level WebSearch fields remain load-compatible but must not be +used for new configuration. -## Environment Variables +### SearXNG URL -| Variable | Description | -|----------|-------------| -| `EXA_API_KEY` | Enables Exa when `providers.exa.enabled: true` | -| `TAVILY_API_KEY` | Enables Tavily when `providers.tavily.enabled: true` | -| `BRAVE_API_KEY` | Enables Brave Search when `providers.brave.enabled: true` | -| `CCS_WEBSEARCH_SEARXNG_URL` | Runtime URL used when `providers.searxng.enabled: true` | -| `CCS_WEBSEARCH_SEARXNG_MAX_RESULTS` | Optional runtime override for SearXNG result count (clamped 1..10) | -| `GROK_API_KEY` | Required only for legacy Grok CLI fallback | -| `CCS_WEBSEARCH_SKIP` | Disable the CCS local WebSearch runtime for the current process; third-party launches still keep native Anthropic `WebSearch` disabled | -| `CCS_DEBUG` | Verbose WebSearch runtime logging | -| `CCS_WEBSEARCH_TRACE` | Write opt-in JSONL trace records under `~/.ccs/logs/websearch-trace.jsonl` | -| `CCS_WEBSEARCH_TRACE_FILE` | Override the trace file path (must stay inside `~/.ccs/`, your system temp directory, or `/var/log`) | +Configure the instance base URL, for example +`https://search.example.invalid`. Do not include `/search`, credentials, query +parameters, or a URL fragment. CCS normalizes the base and calls the JSON search +endpoint. -## Managed Runtime Files +### Dashboard-managed API keys -- `~/.claude.json` → CCS manages `mcpServers.ccs-websearch` -- `~/.ccs/mcp/ccs-websearch-server.cjs` → local MCP server binary -- `~/.ccs/hooks/websearch-transformer.cjs` → shared provider runtime plus legacy compatibility fallback +The dashboard stores supported provider keys in `global_env`. They are +available to WebSearch only when global environment injection is enabled. +Shell-provided environment values remain supported. Never place real keys in +documentation, tests, or committed configuration. -## Troubleshooting +## Managed Files -### WebSearch says "Ready (DuckDuckGo)" +In the default user layout, CCS manages: -That is expected. DuckDuckGo is the default zero-setup backend. +- `~/.claude.json` → `mcpServers.ccs-websearch` +- `~/.ccs/mcp/ccs-websearch-server.cjs` +- `~/.ccs/hooks/websearch-transformer.cjs` -### Exa, Tavily, or Brave is enabled but not ready +CCS installation and test paths can differ because configuration helpers honor +the active CCS and Claude home locations. Provisioning uses a lock and +preserves unrelated MCP server entries. Malformed Claude configuration is not +overwritten. -Set the matching API key in the WebSearch dashboard card, or export it in the environment that launches CCS, then refresh status: +## Runtime Environment -```bash -export EXA_API_KEY="your-api-key" -# or: export TAVILY_API_KEY="your-api-key" -# or: export BRAVE_API_KEY="your-api-key" -ccs config -``` +[`src/utils/websearch/hook-env.ts`](../src/utils/websearch/hook-env.ts) converts +the resolved configuration into runtime environment values. Provider API keys +remain environment inputs; boolean and limit variables describe provider +selection. -If the dashboard says the key is stored but still not ready, check whether `Settings -> Global Env` is disabled. WebSearch reuses that injection path for dashboard-managed keys. +Operational overrides: -### SearXNG is enabled but not ready +| Variable | Effect | +| --- | --- | +| `CCS_DEBUG` | Enables verbose diagnostics and WebSearch trace collection. | +| `CCS_WEBSEARCH_TRACE` | Enables WebSearch JSONL trace collection. | +| `CCS_WEBSEARCH_TRACE_FILE` | Requests a trace path within an allowed CCS log, system temporary, or `/var/log` boundary. | -1. Confirm the configured base URL is valid (for example `https://search.example.com`) -2. Confirm the instance exposes `GET /search?q=&format=json` -3. If the hook reports `SearXNG returned 403: format=json is disabled on this instance`, enable JSON format on that SearXNG deployment or switch to another backend +An unsafe trace-file override is ignored. Trace writes are best effort and do +not change launch or search results. -### I still want Gemini/OpenCode/Grok fallback +## Diagnostics -Those providers remain supported, but they are no longer the primary path. Enable them explicitly in `config.yaml` if you want them as last-resort fallback. +By default, trace records are written under +`~/.ccs/logs/websearch-trace.jsonl`. They correlate launch preparation, MCP +exposure, tool calls, provider attempts, provider success/failure, and session +summaries. -### I need to see whether CCS exposed WebSearch or the model bypassed it +Normal trace metadata uses a query fingerprint and length instead of the raw +query. Provider-failure records can also include `error` detail returned by a +provider implementation, including HTTP response excerpts or CLI stderr. +That provider-generated detail is not guaranteed to exclude query text or +other sensitive content unless the implementation redacts it. Treat the trace +file as sensitive operational data. -Run the launch with `CCS_WEBSEARCH_TRACE=1` (or `CCS_DEBUG=1`). CCS writes a JSONL trace to `~/.ccs/logs/websearch-trace.jsonl` with: +For delegated/headless sessions, a likely-bypass summary means the tool was +exposed but no WebSearch call occurred and another allowed tool path was used. -1. source-side launch records from CCS (`ccs_websearch_launch`) -2. MCP exposure and call records (`mcp_initialize`, `mcp_tools_list`, `mcp_tool_call_*`) -3. provider attempt and winner records (`websearch_provider_attempt`, `websearch_provider_success`) -4. session summaries (`mcp_session_summary`, and headless `headless_websearch_summary` when applicable) +When search is unavailable: -Queries are fingerprinted (`queryHash`, `queryLength`) instead of logged raw by default. For headless/delegation runs, `headless_websearch_summary.likelyBypassed=true` means the MCP tool was exposed, no WebSearch call occurred, and Claude fell back to `Bash` or `WebFetch`. +1. confirm `websearch.enabled` and at least one provider are enabled; +2. check readiness in the dashboard; +3. verify API keys or the SearXNG base URL without printing secrets; +4. enable `CCS_WEBSEARCH_TRACE=1` for one launch; +5. inspect provider attempt and cooldown events. -### WebSearch returns no results - -1. Check `websearch.enabled: true` -2. Keep DuckDuckGo enabled unless you have a strong reason to disable it -3. If using Exa, Tavily, or Brave, verify the matching API key -4. Run with `CCS_DEBUG=1` for runtime logs, or `CCS_WEBSEARCH_TRACE=1` for correlated launch/MCP/provider traces -5. If DuckDuckGo returns a non-result HTML error, retry later or enable another provider. CCS now treats that as a provider failure instead of a false empty result. - -## Security Considerations - -- API keys entered from the dashboard are stored in `~/.ccs/config.yaml` under `global_env` and injected as environment variables at runtime -- Shell-exported keys still work and are detected as external environment input -- Never commit API keys to version control -- Use the dashboard only on trusted machines, and protect `~/.ccs/config.yaml` with normal user-level filesystem permissions +Focused coverage lives under +[`tests/unit/utils/websearch/`](../tests/unit/utils/websearch/), +[`tests/unit/hooks/`](../tests/unit/hooks/), and +[`tests/unit/targets/settings-profile-websearch-launch.test.ts`](../tests/unit/targets/settings-profile-websearch-launch.test.ts). diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index e95bfe53..9fa25647 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -49,7 +49,7 @@ "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/index.ts:390", "src/cliproxy/executor/lifecycle-manager.ts:129", "src/cliproxy/executor/lifecycle-manager.ts:162", "src/cliproxy/executor/lifecycle-manager.ts:59", @@ -161,14 +161,14 @@ "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/delegation/headless-executor.ts:272", + "src/delegation/headless-executor.ts:683", "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/dispatcher/flows/settings-flow.ts:133", "src/docker/docker-assets.ts:35", "src/docker/docker-executor.ts:154", "src/docker/docker-executor.ts:435", @@ -280,7 +280,7 @@ "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/mcp-installer.ts:443", "src/utils/websearch/profile-hook-injector.ts:206", "src/web-server/index.ts:266", "src/web-server/services/claude-extension-binding-service.ts:144", diff --git a/lib/ccs b/lib/ccs index b5c2351a..d4fe7c4b 100755 --- a/lib/ccs +++ b/lib/ccs @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# CCS - Claude Code Switch (Bootstrap) +# CCS - Claude Codex Switch (Bootstrap) # Delegates to Node.js implementation via npx # https://github.com/kaitranntt/ccs set -euo pipefail diff --git a/lib/ccs.ps1 b/lib/ccs.ps1 index 8203a2d6..3195bf84 100644 --- a/lib/ccs.ps1 +++ b/lib/ccs.ps1 @@ -1,4 +1,4 @@ -# CCS - Claude Code Switch (Bootstrap) +# CCS - Claude Codex Switch (Bootstrap) # Delegates to Node.js implementation via npx # https://github.com/kaitranntt/ccs diff --git a/macos-bar/README.md b/macos-bar/README.md index 796b072c..c0388a3b 100644 --- a/macos-bar/README.md +++ b/macos-bar/README.md @@ -44,3 +44,29 @@ the first time via right-click then Open, or clear quarantine with `xattr -dr com.apple.quarantine "/Applications/CCS Bar.app"`. Developer ID signing + notarization (`CCS_BAR_SIGNING=developer-id`) is the public-launch path and is not required for ad-hoc distribution. + +The app ships as `CCS-Bar.app.zip` on the floating `ccs-bar-latest` GitHub +release. `macos-bar/VERSION` is the single semver source; bump it in the same PR +when a new app version is required. + +### Automatic release + +`.github/workflows/bar-release.yml` publishes the asset after a push to `main` +that changes `macos-bar/**`, or from a manual `workflow_dispatch` run whose +selected ref is `main`. It runs on the dedicated self-hosted macOS runner +labelled `ccs-bar`. + +### Manual fallback + +From a macOS machine with Swift and `gh`: + +```bash +cd macos-bar +./Scripts/package_app.sh +gh release upload ccs-bar-latest dist/CCS-Bar.app.zip --clobber +``` + +Pass a version to `package_app.sh` only when intentionally overriding +`macos-bar/VERSION`. Ad-hoc signing is the default; use +`CCS_BAR_SIGNING=developer-id` only with the configured Apple signing and +notarization credentials. diff --git a/package.json b/package.json index 54ba48bd..604df17e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@kaitranntt/ccs", - "version": "8.8.1", - "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", + "version": "8.8.1-dev.22", + "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", "claude", @@ -81,6 +81,7 @@ "test:unit": "bun test tests/unit", "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", + "test:runtime-matrix": "bun run build && CCS_RUNTIME_MATRIX=1 bun test tests/integration/proxy/runtime-transport-matrix.test.ts --timeout 60000", "test:e2e": "bun test tests/e2e/ --bail --timeout 60000", "report:hardening": "node scripts/hardening-inventory.js", "dev": "bun run build:server && node dist/ccs.js config --dev", diff --git a/scripts/ci-parity-gate.sh b/scripts/ci-parity-gate.sh index 73b98615..9616c92f 100755 --- a/scripts/ci-parity-gate.sh +++ b/scripts/ci-parity-gate.sh @@ -57,36 +57,9 @@ 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 +# Age does not prove that generated metrics match the checked-out source tree. +# Compare both inventory artifacts byte-for-byte before expensive parity checks. +node scripts/hardening-inventory.js --check echo "[i] Running CI-parity local checks..." # `set -euo pipefail` above makes every step fail fast. Keep these commands @@ -94,6 +67,7 @@ echo "[i] Running CI-parity local checks..." bun run typecheck bun run lint bun run format:check +bun run test:runtime-matrix bun run build:all bun run test:all CCS_E2E_SKIP_BUILD=1 bun run test:e2e diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index a1b27984..48fb9541 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -1,4 +1,4 @@ -# Bash completion for CCS (Claude Code Switch) +# Bash completion for CCS (Claude Codex Switch) # Compatible with bash 3.2+ _ccs_completion() { diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index c06111e3..34f0c1fd 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -1,4 +1,4 @@ -# Fish completion for CCS (Claude Code Switch) +# Fish completion for CCS (Claude Codex Switch) function __fish_ccs_complete set -l tokens_before_current (commandline -opc) diff --git a/scripts/completion/ccs.ps1 b/scripts/completion/ccs.ps1 index 04051ead..d279764e 100644 --- a/scripts/completion/ccs.ps1 +++ b/scripts/completion/ccs.ps1 @@ -1,4 +1,4 @@ -# PowerShell completion for CCS (Claude Code Switch) +# PowerShell completion for CCS (Claude Codex Switch) function Invoke-CcsCompletionBackend { param( diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index 281da9e3..155aa391 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -1,6 +1,6 @@ #compdef ccs -# Zsh completion for CCS (Claude Code Switch) +# Zsh completion for CCS (Claude Codex Switch) _ccs() { local current diff --git a/scripts/hardening-inventory.js b/scripts/hardening-inventory.js index 1634b965..0cc2ca16 100644 --- a/scripts/hardening-inventory.js +++ b/scripts/hardening-inventory.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const { collectMaintainabilityMetrics } = require('./maintainability-metrics.js'); +const { isTestFile } = require('./runtime-source-classifier.js'); const ROOT_DIR = path.resolve(__dirname, '..'); const SRC_DIR = path.join(ROOT_DIR, 'src'); @@ -98,7 +99,7 @@ function isSourceFile(filePath) { } function isHotpath(filePath) { - return HOTPATH_PATTERNS.some((pattern) => pattern.test(filePath)); + return !isTestFile(filePath) && HOTPATH_PATTERNS.some((pattern) => pattern.test(filePath)); } function walkFiles(dirPath) { @@ -450,7 +451,9 @@ function renderMarkdown(report) { lines.push(`| Sync fs occurrences (all) | ${report.syncFs.totalOccurrences} |`); lines.push(`| Sync fs files affected (all) | ${report.syncFs.filesAffected} |`); lines.push(`| Sync fs occurrences (runtime hotpaths) | ${report.syncFs.hotpathOccurrences} |`); - lines.push(`| Sync fs files affected (runtime hotpaths) | ${report.syncFs.hotpathFilesAffected} |`); + lines.push( + `| Sync fs files affected (runtime hotpaths) | ${report.syncFs.hotpathFilesAffected} |` + ); lines.push(`| Legacy shim markers | ${report.legacyShim.totalMarkers} |`); lines.push(`| Legacy shim files affected | ${report.legacyShim.filesAffected} |`); lines.push(''); @@ -547,10 +550,39 @@ function renderMarkdown(report) { function main() { const report = buildReport(); + const jsonContent = JSON.stringify(report, null, 2) + '\n'; + const markdownContent = renderMarkdown(report); + const checkOnly = process.argv.includes('--check'); + + if (checkOnly) { + const staleReports = [ + [JSON_REPORT_PATH, jsonContent], + [MD_REPORT_PATH, markdownContent], + ].filter(([reportPath, expected]) => { + try { + return fs.readFileSync(reportPath, 'utf8') !== expected; + } catch { + return true; + } + }); + + if (staleReports.length > 0) { + console.error('[X] Hardening inventory does not match the current source tree:'); + for (const [reportPath] of staleReports) { + console.error(` ${relativePath(reportPath)}`); + } + console.error(' Regenerate with: bun run report:hardening'); + process.exitCode = 1; + return; + } + + console.log('[OK] Hardening inventory matches the current source tree.'); + return; + } fs.mkdirSync(REPORT_DIR, { recursive: true }); - fs.writeFileSync(JSON_REPORT_PATH, JSON.stringify(report, null, 2) + '\n', 'utf8'); - fs.writeFileSync(MD_REPORT_PATH, renderMarkdown(report), 'utf8'); + fs.writeFileSync(JSON_REPORT_PATH, jsonContent, 'utf8'); + fs.writeFileSync(MD_REPORT_PATH, markdownContent, 'utf8'); const relJson = relativePath(JSON_REPORT_PATH); const relMd = relativePath(MD_REPORT_PATH); @@ -575,6 +607,7 @@ function main() { module.exports = { buildReport, collectSyncCallSites, + isTestFile, renderMarkdown, stripComments, }; diff --git a/scripts/maintainability-metrics.js b/scripts/maintainability-metrics.js index a9c68277..6f129f71 100644 --- a/scripts/maintainability-metrics.js +++ b/scripts/maintainability-metrics.js @@ -24,6 +24,7 @@ const fs = require('fs'); const path = require('path'); +const { isTestFile } = require('./runtime-source-classifier.js'); const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); @@ -79,14 +80,6 @@ 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); @@ -350,6 +343,7 @@ module.exports = { classifyThrows: classifyThrows, countConsoleErrors: countConsoleErrors, hasCreateLogger: hasCreateLogger, + isTestFile: isTestFile, countLoc: countLoc, TYPED_ERROR_CLASSES: TYPED_ERROR_CLASSES, TYPED_ADOPTION_SUBDOMAINS: TYPED_ADOPTION_SUBDOMAINS, diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index 1dd392c2..213028bf 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -40,6 +40,7 @@ const slowTests = [ 'tests/unit/targets/settings-profile-websearch-launch.test.ts', 'tests/unit/web-server/cursor-routes.test.ts', 'tests/unit/web-server/websearch-routes.test.ts', + 'src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts', ]; // CommonJS-heavy JS suites stay slow by default because many of them mutate // module cache or process state. Opt them into `test:fast` only after they are diff --git a/scripts/runtime-source-classifier.js b/scripts/runtime-source-classifier.js new file mode 100644 index 00000000..0fe3db64 --- /dev/null +++ b/scripts/runtime-source-classifier.js @@ -0,0 +1,12 @@ +function isTestFile(filePath) { + const normalizedPath = filePath.replace(/\\/g, '/'); + return ( + /(^|\/)(__tests__|__mocks__|fixtures)(\/|$)/.test(normalizedPath) || + /(^|\/)(tests?|test-fixtures)(\/|$)/.test(normalizedPath) || + /\.(test|spec)\.[^.]+$/.test(normalizedPath) + ); +} + +module.exports = { + isTestFile, +}; diff --git a/src/api/services/profile-lifecycle-validation.ts b/src/api/services/profile-lifecycle-validation.ts index d7902584..6897d113 100644 --- a/src/api/services/profile-lifecycle-validation.ts +++ b/src/api/services/profile-lifecycle-validation.ts @@ -10,22 +10,16 @@ import { } from '../../cliproxy/ai-providers/model-id-normalizer'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import { ANTHROPIC_MODEL_ENV_KEYS } from '../../shared/extended-context-utils'; import type { ProfileValidationIssue, ProfileValidationSummary } from './profile-types'; -const MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', -] as const; - const ALLOWED_ANTHROPIC_ENV_KEYS = new Set([ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', // Written by profile-writer when --extra-models is supplied 'ANTHROPIC_EXTRA_MODELS', - ...MODEL_ENV_KEYS, + ...ANTHROPIC_MODEL_ENV_KEYS, ]); function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null { @@ -118,7 +112,7 @@ export function validateApiProfileSettingsPayload(settings: unknown): ProfileVal } const provider = resolveProviderFromBaseUrl(baseUrl); - for (const modelKey of MODEL_ENV_KEYS) { + for (const modelKey of ANTHROPIC_MODEL_ENV_KEYS) { const value = envObj[modelKey]; if (typeof value !== 'string' || value.trim().length === 0) continue; const denyReason = getDeniedModelIdReasonForProvider(value, provider); diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 7556094e..e4309702 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -142,7 +142,7 @@ class AuthCommands { ` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts` ); console.log( - ` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)` + ` ${color('--bare', 'command')} Create clean profile without shared settings/instructions/resources` ); console.log( ` ${color('--mode ', 'command')} Shared resource mode for resources: shared|profile-local` @@ -169,10 +169,10 @@ class AuthCommands { ); console.log(` Account logins, tokens, and .anthropic stay isolated for every profile.`); console.log( - ` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show ', 'command')} shows the link state.` + ` Non-bare account profiles share ${color('settings.json', 'path')} and ${color('CLAUDE.md', 'path')} from ${color('~/.claude/', 'path')}; ${color('ccs auth show ', 'command')} shows resource mode and settings link state.` ); console.log( - ` Shared Resources control plugins/commands/skills/agents/settings.json; History Sync controls project/session continuity only.` + ` Shared Resources control plugins/commands/skills/agents/settings.json/CLAUDE.md; History Sync controls project/session continuity only.` ); console.log( ` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.` diff --git a/src/auth/resume-lane-diagnostics.ts b/src/auth/resume-lane-diagnostics.ts index ba3e3b95..260080d2 100644 --- a/src/auth/resume-lane-diagnostics.ts +++ b/src/auth/resume-lane-diagnostics.ts @@ -4,9 +4,11 @@ import { getDefaultClaudeConfigDir } from '../utils/claude-config-path'; import InstanceManager from '../management/instance-manager'; import ProfileDetector from './profile-detector'; +import ProfileRegistry from './profile-registry'; import { resolveConfiguredContinuitySourceAccount } from './profile-continuity-inheritance'; import type { ProfileType } from '../types/profile'; import { getCcsDir } from '../config/config-loader-facade'; +import { isValidAccountProfileName } from './account-context'; export type ResumeLaneKind = | 'native' @@ -29,6 +31,37 @@ export interface ResumeFlagIntent { explicitSessionId?: string; } +export interface ResumeSessionLane { + kind: 'native' | 'account'; + configDir: string; + accountName?: string; +} + +export interface ResumeSessionScanDependencies { + beforeSessionOpen?: (sessionPath: string) => void; +} + +const SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isPathWithin(candidate: string, root: string): boolean { + return candidate === root || candidate.startsWith(`${root}${path.sep}`); +} + +function hasSameIdentity(before: fs.Stats, after: fs.Stats): boolean { + return before.dev === after.dev && before.ino === after.ino; +} + +function isStableDirectory(directoryPath: string, before: fs.Stats): boolean { + const after = fs.lstatSync(directoryPath); + return after.isDirectory() && !after.isSymbolicLink() && hasSameIdentity(before, after); +} + +function isStableFile(filePath: string, before: fs.Stats): boolean { + const after = fs.lstatSync(filePath); + return after.isFile() && !after.isSymbolicLink() && hasSameIdentity(before, after); +} + function countTopLevelProjectDirs(projectsDir: string): number { try { return fs @@ -39,6 +72,103 @@ function countTopLevelProjectDirs(projectsDir: string): number { } } +function laneContainsSession( + configDir: string, + sessionId: string, + deps: ResumeSessionScanDependencies +): boolean { + if (!SESSION_ID_PATTERN.test(sessionId)) { + return false; + } + + const projectsDir = path.join(configDir, 'projects'); + try { + const configStat = fs.lstatSync(configDir); + if (!configStat.isDirectory() || configStat.isSymbolicLink()) return false; + const configRealPath = fs.realpathSync(configDir); + const projectsStat = fs.lstatSync(projectsDir); + if (!projectsStat.isDirectory() || projectsStat.isSymbolicLink()) return false; + const projectsRealPath = fs.realpathSync(projectsDir); + if (!isPathWithin(projectsRealPath, configRealPath)) return false; + + return fs.readdirSync(projectsDir, { withFileTypes: true }).some((projectEntry) => { + if (!projectEntry.isDirectory() || projectEntry.isSymbolicLink()) { + return false; + } + const projectPath = path.join(projectsDir, projectEntry.name); + const sessionPath = path.join(projectPath, `${sessionId}.jsonl`); + let sessionFd: number | undefined; + try { + const projectStat = fs.lstatSync(projectPath); + if (!projectStat.isDirectory() || projectStat.isSymbolicLink()) return false; + const projectRealPath = fs.realpathSync(projectPath); + if (!isPathWithin(projectRealPath, projectsRealPath)) return false; + const sessionRealPath = fs.realpathSync(sessionPath); + if (!isPathWithin(sessionRealPath, projectRealPath)) return false; + const sessionStat = fs.lstatSync(sessionPath); + if (!sessionStat.isFile() || sessionStat.isSymbolicLink()) return false; + deps.beforeSessionOpen?.(sessionPath); + sessionFd = fs.openSync( + sessionPath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + ); + const openedSessionStat = fs.fstatSync(sessionFd); + if (!openedSessionStat.isFile() || !hasSameIdentity(sessionStat, openedSessionStat)) { + return false; + } + return ( + isStableDirectory(configDir, configStat) && + isStableDirectory(projectsDir, projectsStat) && + isStableDirectory(projectPath, projectStat) && + isStableFile(sessionPath, sessionStat) && + fs.realpathSync(configDir) === configRealPath && + fs.realpathSync(projectsDir) === projectsRealPath && + fs.realpathSync(projectPath) === projectRealPath && + fs.realpathSync(sessionPath) === sessionRealPath + ); + } catch { + return false; + } finally { + if (sessionFd !== undefined) fs.closeSync(sessionFd); + } + }); + } catch { + return false; + } +} + +export function findResumeSessionLanes( + sessionId: string, + deps: ResumeSessionScanDependencies = {} +): ResumeSessionLane[] { + if (!SESSION_ID_PATTERN.test(sessionId)) { + return []; + } + + const instanceMgr = new InstanceManager(); + const profileRegistry = new ProfileRegistry(); + const lanes: ResumeSessionLane[] = []; + const nativeConfigDir = getDefaultClaudeConfigDir(); + if (laneContainsSession(nativeConfigDir, sessionId, deps)) { + lanes.push({ kind: 'native', configDir: nativeConfigDir }); + } + + const accountNames = Object.entries(profileRegistry.getAllProfilesMerged()) + .filter( + ([accountName, profile]) => + profile.type === 'account' && isValidAccountProfileName(accountName) + ) + .map(([accountName]) => accountName); + for (const accountName of accountNames) { + const configDir = instanceMgr.getInstancePath(accountName); + if (laneContainsSession(configDir, sessionId, deps)) { + lanes.push({ kind: 'account', configDir, accountName }); + } + } + + return lanes; +} + function resolveNativeLaneSummary( kind: ResumeLaneKind = 'native', profileName?: string diff --git a/src/auth/resume-lane-warning.ts b/src/auth/resume-lane-warning.ts index ca59217c..de6fb121 100644 --- a/src/auth/resume-lane-warning.ts +++ b/src/auth/resume-lane-warning.ts @@ -1,17 +1,65 @@ import * as path from 'path'; import { info, warn } from '../utils/ui'; +import { detectShell, formatExport, formatUnset, type Shell } from '../codex-auth/shell-detect'; +import { ANTHROPIC_ROUTING_ENV_KEYS } from '../utils/shell-executor'; +import { isValidAccountProfileName } from './account-context'; import { + findResumeSessionLanes, parseResumeFlagIntent, resolveRuntimePlainCcsResumeLane, + type ResumeSessionLane, type ResumeLaneSummary, } from './resume-lane-diagnostics'; interface ResumeLaneWarningDependencies { resolvePlainLane?: () => Promise; + findSessionLanes?: (sessionId: string) => ResumeSessionLane[]; + shell?: Shell; + env?: NodeJS.ProcessEnv; + platform?: string; + parentProcessName?: string; log?: (message: string) => void; debug?: boolean; } +function joinShellStatements(shell: Shell, statements: string[]): string { + if (shell === 'cmd') return statements.join(' && '); + if (shell === 'fish') return statements.join(' '); + return statements.join('; '); +} + +function buildExplicitNativeResumeCommand( + configDir: string, + sessionId: string, + shell: Shell +): string { + const statements = ANTHROPIC_ROUTING_ENV_KEYS.map((key) => formatUnset(shell, key)); + statements.push(formatExport(shell, 'CLAUDE_CONFIG_DIR', configDir)); + statements.push(`claude --resume ${sessionId}`); + return joinShellStatements(shell, statements); +} + +function buildResumeCommand( + sourceLane: ResumeSessionLane, + plainLane: ResumeLaneSummary, + sessionId: string, + shell: Shell +): string | null { + if (sourceLane.kind === 'account') { + if (!sourceLane.accountName || !isValidAccountProfileName(sourceLane.accountName)) return null; + return `ccs ${sourceLane.accountName} --resume ${sessionId}`; + } + + if ( + plainLane.kind === 'native' && + path.resolve(sourceLane.configDir) === path.resolve(plainLane.configDir) + ) { + return `ccs --resume ${sessionId}`; + } + + return buildExplicitNativeResumeCommand(sourceLane.configDir, sessionId, shell); +} + export async function maybeWarnAboutResumeLaneMismatch( profileName: string, accountConfigDir: string, @@ -26,8 +74,20 @@ export async function maybeWarnAboutResumeLaneMismatch( const log = deps.log ?? console.error; try { + const sessionLanes = resumeIntent.explicitSessionId + ? (deps.findSessionLanes ?? findResumeSessionLanes)(resumeIntent.explicitSessionId) + : []; + if ( + sessionLanes.some((lane) => path.resolve(lane.configDir) === path.resolve(accountConfigDir)) + ) { + return; + } + const plainLane = await (deps.resolvePlainLane ?? resolveRuntimePlainCcsResumeLane)(); - if (path.resolve(plainLane.configDir) === path.resolve(accountConfigDir)) { + if ( + sessionLanes.length === 0 && + path.resolve(plainLane.configDir) === path.resolve(accountConfigDir) + ) { return; } @@ -38,18 +98,70 @@ export async function maybeWarnAboutResumeLaneMismatch( ); log(info(` Account lane: ${accountConfigDir}`)); log(info(` Plain ccs lane: ${plainLane.label} (${plainLane.configDir})`)); + const sourceLanes = sessionLanes.filter( + (lane) => path.resolve(lane.configDir) !== path.resolve(accountConfigDir) + ); + const shell = + deps.shell ?? + detectShell( + deps.env ?? process.env, + deps.platform ?? process.platform, + deps.parentProcessName + ); if (resumeIntent.explicitSessionId) { + if (sourceLanes.length === 1) { + const sourceLane = sourceLanes[0]; + const command = buildResumeCommand( + sourceLane, + plainLane, + resumeIntent.explicitSessionId, + shell + ); + if (command) { + log(info(` Resume from the lane that owns this session: ${command}`)); + } else { + log(warn(' Session owner has an unsafe account name; no executable command emitted.')); + } + } else if (sourceLanes.length > 1) { + log(info(' This session exists in multiple lanes; choose one without merging history:')); + for (const sourceLane of sourceLanes) { + const command = buildResumeCommand( + sourceLane, + plainLane, + resumeIntent.explicitSessionId, + shell + ); + if (command) log(info(` ${command}`)); + } + } else { + log( + info( + ' This explicit session ID may have been created in a different lane, so Claude may not find it here.' + ) + ); + } + } + if (sourceLanes.length === 0) { + log(info(' Recover the original lane first: ccs -r')); + log(info(' Back it up before changing setup: ccs auth backup default')); + if (isValidAccountProfileName(profileName)) { + log( + info( + ` For future work, align plain ccs with this account: ccs auth default ${profileName}` + ) + ); + } + } else if ( + sourceLanes.length === 1 && + sourceLanes[0].accountName && + isValidAccountProfileName(sourceLanes[0].accountName) + ) { log( info( - ' This explicit session ID may have been created in a different lane, so Claude may not find it here.' + ` Back up that lane before changing setup: ccs auth backup ${sourceLanes[0].accountName}` ) ); } - log(info(' Recover the original lane first: ccs -r')); - log(info(' Back it up before changing setup: ccs auth backup default')); - log( - info(` For future work, align plain ccs with this account: ccs auth default ${profileName}`) - ); log(''); } catch (error) { log( diff --git a/src/ccs.ts b/src/ccs.ts index e8fe5996..7157997c 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1,5 +1,3 @@ -import './utils/fetch-proxy-setup'; - import { ErrorManager } from './utils/error-manager'; import { fail } from './utils/ui'; // Import centralized error handling @@ -7,6 +5,7 @@ import { handleError, runCleanup } from './errors'; import { createLogger, runWithRequestId } from './services/logging'; import { redactArgv } from './services/logging/log-redaction'; +import { applyGlobalFetchProxy } from './utils/fetch-proxy-setup'; // Import target adapter system import { registerTarget, ClaudeAdapter, DroidAdapter, CodexAdapter } from './targets'; @@ -19,6 +18,11 @@ import { resolveProfileAndTarget } from './dispatcher/profile-resolver'; // ========== Main Execution ========== async function main(): Promise { + const fetchProxySetup = applyGlobalFetchProxy(); + if (fetchProxySetup.error) { + console.error(`[!] Skipping global fetch proxy setup: ${fetchProxySetup.error}`); + } + // Register target adapters (singleton wiring — stays in main) registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); diff --git a/src/cliproxy/__tests__/cliproxy-retry-generator.test.ts b/src/cliproxy/__tests__/cliproxy-retry-generator.test.ts new file mode 100644 index 00000000..d84a2bc0 --- /dev/null +++ b/src/cliproxy/__tests__/cliproxy-retry-generator.test.ts @@ -0,0 +1,156 @@ +/** + * CLIProxy request-retry config — generator tests. + * + * Covers: + * 1. Unset retry config produces today's exact request-retry/max-retry-interval bytes (0/0). + * 2. Configured retry values are emitted verbatim into the generated CLIProxy config.yaml. + * 3. Invalid persisted values (negative/non-integer) fall back to 0, matching the + * unset default (defensive: a corrupt config.yaml must not crash the generator). + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +function createTestHome(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-retry-test-')); + const ccsDir = path.join(dir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8'); + return dir; +} + +describe('CLIProxy retry config — generator', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + function regenerate(tag: string): string { + return path.join(tempHome, '.ccs', 'cliproxy', `config-${tag}.yaml`); + } + + it("unset retry config emits request-retry: 0 and max-retry-interval: 0 (today's default)", async () => { + const { regenerateConfig } = await import(`../config/generator?crgen1=${Date.now()}`); + const configPath = regenerate('unset'); + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('request-retry: 0'); + expect(content).toContain('max-retry-interval: 0'); + }); + + it('unset retry config produces byte-identical output to an explicit 0/0 config', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?crgen2a=${Date.now()}` + ); + const { regenerateConfig } = await import(`../config/generator?crgen2a=${Date.now()}`); + + const unsetPath = regenerate('unset-baseline'); + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(unsetPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + regenerateConfig(8317, { configPath: unsetPath, authDir }); + const unsetContent = fs.readFileSync(unsetPath, 'utf-8'); + + mutateConfig((cfg: { cliproxy?: { retry?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.retry = { request_retry: 0, max_retry_interval: 0 }; + }); + invalidateConfigCache(); + + const explicitPath = regenerate('explicit-zero'); + regenerateConfig(8317, { configPath: explicitPath, authDir }); + const explicitContent = fs.readFileSync(explicitPath, 'utf-8'); + + const stripTimestamp = (s: string) => s.replace(/# Generated: .+/g, '# Generated: TIMESTAMP'); + expect(stripTimestamp(explicitContent)).toBe(stripTimestamp(unsetContent)); + }); + + it('configured retry values are emitted into the generated config', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?crgen3=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { retry?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.retry = { request_retry: 3, max_retry_interval: 30 }; + }); + invalidateConfigCache(); + + const { regenerateConfig } = await import(`../config/generator?crgen3=${Date.now()}`); + const configPath = regenerate('configured'); + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('request-retry: 3'); + expect(content).toContain('max-retry-interval: 30'); + }); + + it('only request_retry set falls back to 0 for max_retry_interval', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?crgen4=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { retry?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.retry = { request_retry: 5 }; + }); + invalidateConfigCache(); + + const { regenerateConfig } = await import(`../config/generator?crgen4=${Date.now()}`); + const configPath = regenerate('partial'); + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('request-retry: 5'); + expect(content).toContain('max-retry-interval: 0'); + }); + + it('a negative persisted request_retry falls back to 0 (defensive, does not throw)', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?crgen5=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { retry?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.retry = { request_retry: -1, max_retry_interval: 2.5 }; + }); + invalidateConfigCache(); + + const { regenerateConfig } = await import(`../config/generator?crgen5=${Date.now()}`); + const configPath = regenerate('invalid'); + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + + expect(() => regenerateConfig(8317, { configPath, authDir })).not.toThrow(); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('request-retry: 0'); + expect(content).toContain('max-retry-interval: 0'); + }); +}); diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index ff8bce9f..aaaa01d6 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -75,6 +75,18 @@ describe('model-catalog compatibility lookups', () => { expect(catalog?.models[0]?.extendedContext).toBeUndefined(); }); + it('maps positive integer live context length into contextWindow metadata', () => { + const catalog = mergeCatalog('xai', [ + { + id: 'grok-4.3', + display_name: 'Grok 4.3 Live', + context_length: 1_000_000, + }, + ]); + + expect(catalog?.models[0]?.contextWindow).toBe(1_000_000); + }); + it('does not re-add stale static-only models when live catalog data is present', () => { const catalog = mergeCatalog('gemini', [ { diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index 4377ed32..ca020e79 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -236,6 +236,22 @@ describe('Model Catalog', () => { assert.strictEqual(fable5.extendedContext, true); }); + it('includes Claude Opus 5 with adaptive levels and extended context', () => { + const { MODEL_CATALOG } = modelCatalog; + const opus5 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-5'); + assert(opus5, 'Should include Claude Opus 5'); + assert.strictEqual(opus5.name, 'Claude Opus 5'); + // Opus 5 requires adaptive thinking (type: 'levels') like Sonnet 5 and Opus 4.8; + // manual budget_tokens is rejected with 400. Proxy reports zero + dynamic allowed. + assert.strictEqual(opus5.thinking.type, 'levels'); + assert.deepStrictEqual(opus5.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']); + assert.strictEqual(opus5.thinking.maxLevel, 'max'); + assert.strictEqual(opus5.thinking.zeroAllowed, true); + assert.strictEqual(opus5.thinking.dynamicAllowed, true); + assert.strictEqual(opus5.nativeImageInput, true); + assert.strictEqual(opus5.extendedContext, true); + }); + it('retains previous 4.5 snapshot models for explicit selection', () => { const { MODEL_CATALOG } = modelCatalog; const ids = MODEL_CATALOG.claude.models.map((m) => m.id); diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index 06ca3dff..dc65fb01 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -5,11 +5,11 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as lockfile from 'proper-lockfile'; import { CLIProxyProvider } from '../types'; import { PROVIDER_CAPABILITIES } from '../provider-capabilities'; import { PROVIDER_TYPE_VALUES } from '../auth/auth-types'; import { getAuthDir, getCliproxyDir } from '../config/config-generator'; +import { withSyncLockRetry } from '../../utils/sync-lock-retry'; import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL, DrainOrderConfig } from './types'; import { getAccountsRegistryPath, @@ -338,24 +338,24 @@ function recoverAccountsRegistryFromCorruption(registryPath: string): AccountsRe function withAccountsRegistryLock(callback: () => T): T { const lockTarget = getCliproxyDir(); - let release: (() => void) | undefined; if (!fs.existsSync(lockTarget)) { fs.mkdirSync(lockTarget, { recursive: true, mode: 0o700 }); } - try { - release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void; - return callback(); - } finally { - if (release) { - try { - release(); - } catch { - // Best-effort release - } - } - } + // proper-lockfile rejects built-in retries in synchronous mode. + // Keep this API sync while waiting for short-lived concurrent launches. + // Preserve the existing stale-lock window. + // Retry only contention failures. + // Wait in short intervals so released locks are picked up promptly. + // Bound the wait so a genuinely stuck process still produces an error. + // The helper adds recovery guidance when that timeout is reached. + return withSyncLockRetry(lockTarget, callback, { + description: 'CLIProxy account registry lock', + staleMs: 10000, + retryDelayMs: 200, + retryTimeoutMs: 10000, + }); } function readAccountsRegistryFromDisk(): AccountsRegistry { diff --git a/src/cliproxy/auth/__tests__/oauth-cli-capabilities.test.ts b/src/cliproxy/auth/__tests__/oauth-cli-capabilities.test.ts new file mode 100644 index 00000000..9e532e98 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-cli-capabilities.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, spyOn } from 'bun:test'; +import * as childProcess from 'child_process'; +import { + extractAdvertisedCliFlags, + getOAuthFlagCandidatesForProvider, + OAUTH_HELP_PROBE_TIMEOUT_MS, + probeCliProxyAdvertisedFlags, + resolveAdvertisedAuthFlag, +} from '../oauth-cli-capabilities'; + +describe('oauth CLI capability probing', () => { + it('extracts advertised flags from Go help output', () => { + const flags = extractAdvertisedCliFlags(` +Usage of cli-proxy-api-plus + -login + Login Google Account + -codex-login + Login to Codex using OAuth +`); + + expect(flags.has('--login')).toBe(true); + expect(flags.has('--codex-login')).toBe(true); + }); + + it('falls back to the legacy Kiro Google alias when advertised', () => { + const selected = resolveAdvertisedAuthFlag( + 'kiro', + getOAuthFlagCandidatesForProvider('kiro', 'google'), + new Set(['--kiro-login']) + ); + + expect(selected).toBe('--kiro-login'); + }); + + it('probes help with a raw argv array so paths with spaces stay intact', () => { + const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({ + status: 0, + stdout: ' -login\n', + stderr: '', + pid: 0, + output: [], + signal: null, + error: undefined, + }); + + try { + const binaryPath = 'C:\\Program Files\\CCS\\cli-proxy-api-plus.exe'; + const flags = probeCliProxyAdvertisedFlags(binaryPath); + + expect(flags.has('--login')).toBe(true); + expect(spawnSpy).toHaveBeenCalledWith( + binaryPath, + ['--help'], + expect.objectContaining({ + encoding: 'utf8', + shell: false, + timeout: OAUTH_HELP_PROBE_TIMEOUT_MS, + windowsHide: true, + }) + ); + } finally { + spawnSpy.mockRestore(); + } + }); + + it('fails fast when help probing times out', () => { + const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({ + status: null, + stdout: '', + stderr: '', + pid: 0, + output: [], + signal: 'SIGTERM', + error: Object.assign(new Error('spawnSync timed out'), { code: 'ETIMEDOUT' }), + }); + + try { + expect(() => probeCliProxyAdvertisedFlags('/tmp/cli-proxy-api')).toThrow( + `Timed out after ${OAUTH_HELP_PROBE_TIMEOUT_MS}ms` + ); + } finally { + spawnSpy.mockRestore(); + } + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-auth-args.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-auth-args.test.ts new file mode 100644 index 00000000..728d2a92 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-handler-auth-args.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test'; +import { buildOAuthArgs } from '../oauth-cli-args'; + +describe('buildOAuthArgs capability negotiation', () => { + it('uses the advertised Gemini login flag when supported', () => { + expect( + buildOAuthArgs('gemini', '/tmp/cliproxy-config.yaml', false, false, { + advertisedFlags: new Set(['--login']), + }) + ).toEqual(['--config', '/tmp/cliproxy-config.yaml', '--login']); + }); + + it('uses the legacy Kiro Google alias when the binary only advertises it', () => { + expect( + buildOAuthArgs('kiro', '/tmp/path with spaces/cliproxy config.yaml', false, false, { + kiroMethod: 'google', + advertisedFlags: new Set(['--kiro-login']), + }) + ).toEqual(['--config', '/tmp/path with spaces/cliproxy config.yaml', '--kiro-login']); + }); + + it('fails early when Gemini login is unsupported by the installed binary', () => { + expect(() => + buildOAuthArgs('gemini', '/tmp/cliproxy-config.yaml', false, false, { + backend: 'original', + advertisedFlags: new Set(['--codex-login']), + }) + ).toThrow('cliproxy.backend: original'); + }); + + it('preserves Windows config paths as a separate argv entry', () => { + const args = buildOAuthArgs( + 'gemini', + 'C:\\Users\\Kai Tran\\AppData\\Roaming\\CCS\\cliproxy config.yaml', + true, + false, + { + advertisedFlags: new Set(['--login']), + } + ); + + expect(args[1]).toBe('C:\\Users\\Kai Tran\\AppData\\Roaming\\CCS\\cliproxy config.yaml'); + expect(args).toContain('--no-browser'); + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts new file mode 100644 index 00000000..ec00d9e1 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts @@ -0,0 +1,40 @@ +import * as childProcess from 'child_process'; +import * as path from 'path'; +import { describe, expect, it } from 'bun:test'; + +const childTestPath = path.resolve( + import.meta.dir, + '../test-fixtures/gemini-backend-guidance-child.scenario.ts' +); + +function runScenario(scenario: string): void { + const result = childProcess.spawnSync(process.execPath, ['test', childTestPath], { + cwd: path.resolve(import.meta.dir, '../../..'), + encoding: 'utf8', + env: { + ...process.env, + GEMINI_GUIDANCE_SCENARIO: scenario, + }, + }); + + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim(); + expect(result.status, output || `child scenario ${scenario} failed`).toBe(0); +} + +describe('triggerOAuth Gemini backend guidance', () => { + it('fails early on plus when Gemini OAuth client env vars are missing', () => { + runScenario('plus-missing-env'); + }); + + it('reports original backend Gemini incompatibility after probing direct CLI auth', () => { + runScenario('original-direct-unsupported'); + }); + + it('reports original backend Gemini incompatibility after probing headless auto paste mode', () => { + runScenario('original-headless-auto-paste-unsupported'); + }); + + it('allows pinned original binaries that still advertise Gemini login to continue past the probe', () => { + runScenario('original-headless-auto-paste-supported'); + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts index 6a91c889..6cf7a085 100644 --- a/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import { OAUTH_CALLBACK_PORTS } from '../../../management/oauth-port-diagnostics'; -import { buildOAuthArgs } from '../oauth-handler'; +import { buildOAuthArgs } from '../oauth-cli-args'; describe('xAI OAuth process arguments', () => { it('starts CLIProxy device auth without a callback port argument', () => { - expect(OAUTH_CALLBACK_PORTS.xai).toBeNull(); expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', false, false)).toEqual([ '--config', '/tmp/cliproxy-config.yaml', diff --git a/src/cliproxy/auth/oauth-cli-args.ts b/src/cliproxy/auth/oauth-cli-args.ts new file mode 100644 index 00000000..fd643bd8 --- /dev/null +++ b/src/cliproxy/auth/oauth-cli-args.ts @@ -0,0 +1,93 @@ +import { ValidationError, AuthError } from '../../errors/error-types'; +import { getUnsupportedAuthStartReason } from '../provider-capabilities'; +import { CLIProxyBackend, CLIProxyProvider } from '../types'; +import { + getKiroCLIAuthFlag, + getOAuthConfig, + isKiroCLIAuthMethod, + normalizeKiroAuthMethod, + normalizeKiroIDCFlow, + type OAuthOptions, +} from './auth-types'; +import { + getOAuthFlagCandidatesForProvider, + resolveAdvertisedAuthFlag, +} from './oauth-cli-capabilities'; + +export function buildOAuthArgs( + provider: CLIProxyProvider, + configPath: string, + headless: boolean, + noIncognito: boolean, + options: { + advertisedFlags?: ReadonlySet; + backend?: CLIProxyBackend; + kiroMethod?: OAuthOptions['kiroMethod']; + kiroIDCStartUrl?: string; + kiroIDCRegion?: string; + kiroIDCFlow?: OAuthOptions['kiroIDCFlow']; + } = {} +): string[] { + const unsupportedReason = getUnsupportedAuthStartReason(provider); + if (unsupportedReason) { + throw new AuthError(unsupportedReason, provider); + } + + const args = ['--config', configPath]; + const advertisedFlags = options.advertisedFlags; + + if (provider === 'kiro') { + const method = normalizeKiroAuthMethod(options.kiroMethod); + if (!isKiroCLIAuthMethod(method)) { + throw new AuthError(`Kiro auth method '${method}' is not supported by CLI flow.`, 'kiro'); + } + + const selectedKiroFlag = advertisedFlags + ? resolveAdvertisedAuthFlag( + provider, + getOAuthFlagCandidatesForProvider(provider, method), + advertisedFlags, + { backend: options.backend } + ) + : getKiroCLIAuthFlag(method); + + if (method !== 'idc') { + args.push(selectedKiroFlag); + } else { + const startUrl = options.kiroIDCStartUrl?.trim(); + if (!startUrl) { + throw new ValidationError( + 'Kiro IDC login requires --kiro-idc-start-url', + 'kiroIDCStartUrl' + ); + } + + args.push(selectedKiroFlag, '--kiro-idc-start-url', startUrl); + const region = options.kiroIDCRegion?.trim(); + if (region) { + args.push('--kiro-idc-region', region); + } + args.push('--kiro-idc-flow', normalizeKiroIDCFlow(options.kiroIDCFlow)); + } + } else { + args.push( + advertisedFlags + ? resolveAdvertisedAuthFlag( + provider, + getOAuthFlagCandidatesForProvider(provider), + advertisedFlags, + { backend: options.backend } + ) + : getOAuthConfig(provider).authFlag + ); + } + + if (headless) { + args.push('--no-browser'); + } + if (provider === 'kiro' && noIncognito) { + args.push('--no-incognito'); + } + + return args; +} diff --git a/src/cliproxy/auth/oauth-cli-capabilities.ts b/src/cliproxy/auth/oauth-cli-capabilities.ts new file mode 100644 index 00000000..8bbd0af8 --- /dev/null +++ b/src/cliproxy/auth/oauth-cli-capabilities.ts @@ -0,0 +1,108 @@ +import * as childProcess from 'child_process'; +import { AuthError, BinaryError } from '../../errors/error-types'; +import type { CLIProxyBackend, CLIProxyProvider } from '../types'; +import { getKiroCLIAuthFlag, getOAuthConfig, type KiroCLIAuthMethod } from './auth-types'; + +const HELP_FLAG_PATTERN = /(?:^|\n)\s+-([a-z0-9][a-z0-9-]*)\b/gi; +export const OAUTH_HELP_PROBE_TIMEOUT_MS = 5000; + +export function extractAdvertisedCliFlags(helpText: string): Set { + const flags = new Set(); + + for (const match of helpText.matchAll(HELP_FLAG_PATTERN)) { + const flagName = match[1]?.trim(); + if (!flagName) { + continue; + } + flags.add(`--${flagName}`); + } + + return flags; +} + +export function getOAuthFlagCandidatesForProvider( + provider: CLIProxyProvider, + kiroMethod?: KiroCLIAuthMethod +): readonly string[] { + if (provider !== 'kiro') { + return [getOAuthConfig(provider).authFlag]; + } + + switch (kiroMethod) { + case 'google': + return ['--kiro-google-login', '--kiro-login']; + case 'aws': + case 'aws-authcode': + case 'idc': + return [getKiroCLIAuthFlag(kiroMethod)]; + default: + return [getKiroCLIAuthFlag('aws')]; + } +} + +export function selectAdvertisedAuthFlag( + candidates: readonly string[], + advertisedFlags: ReadonlySet +): string | null { + for (const candidate of candidates) { + if (advertisedFlags.has(candidate)) { + return candidate; + } + } + + return null; +} + +export function resolveAdvertisedAuthFlag( + provider: CLIProxyProvider, + candidates: readonly string[], + advertisedFlags: ReadonlySet, + options: { backend?: CLIProxyBackend } = {} +): string { + const selected = selectAdvertisedAuthFlag(candidates, advertisedFlags); + if (selected) { + return selected; + } + + const oauthConfig = getOAuthConfig(provider); + if (provider === 'gemini' && options.backend === 'original') { + throw new AuthError( + 'Installed CLIProxy binary does not advertise Google Gemini login support (--login). The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS. To use Gemini OAuth, switch `cliproxy.backend` to `plus`, set CLIPROXY_GEMINI_OAUTH_CLIENT_ID and CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET before starting CLIProxy Plus, reinstall the maintained Plus fork, and retry auth.', + provider + ); + } + + throw new AuthError( + `Installed CLIProxy binary does not advertise a supported ${oauthConfig.displayName} login flag (${candidates.join(' or ')}). Run \`ccs cliproxy status\`, then reinstall the active backend binary before retrying auth.`, + provider + ); +} + +export function probeCliProxyAdvertisedFlags(binaryPath: string): Set { + const result = childProcess.spawnSync(binaryPath, ['--help'], { + encoding: 'utf8', + shell: false, + timeout: OAUTH_HELP_PROBE_TIMEOUT_MS, + windowsHide: true, + }); + + if (result.error) { + const errorCode = (result.error as NodeJS.ErrnoException).code; + if (errorCode === 'ETIMEDOUT') { + throw new BinaryError( + `Timed out after ${OAUTH_HELP_PROBE_TIMEOUT_MS}ms while inspecting CLIProxy login capabilities`, + binaryPath + ); + } + throw result.error; + } + + if (result.signal) { + throw new BinaryError( + `CLIProxy capability probe was interrupted while inspecting login capabilities (${result.signal})`, + binaryPath + ); + } + + return extractAdvertisedCliFlags(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); +} diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 14264460..8495503a 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -14,9 +14,9 @@ import * as fs from 'fs'; import * as path from 'path'; import { fail, info, warn, color, ok } from '../../utils/ui'; import { createLogger } from '../../services/logging'; -import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager'; +import { ensureCLIProxyBinary, getConfiguredBackend } from '../binary-manager'; import { generateConfig } from '../config/config-generator'; -import { AuthError, ConfigError } from '../../errors/error-types'; +import { AuthError, BinaryError, ConfigError } from '../../errors/error-types'; import { CLIProxyBackend, CLIProxyProvider } from '../types'; import { AccountInfo, @@ -37,8 +37,6 @@ import { DEFAULT_KIRO_AUTH_METHOD, DEFAULT_KIRO_IDC_FLOW, getKiroCallbackPort, - getKiroCLIAuthArgs, - isKiroCLIAuthMethod, isKiroDeviceCodeMethod, getOAuthConfig, ProviderOAuthConfig, @@ -47,6 +45,7 @@ import { getManagementOAuthCallbackPath, normalizeKiroAuthMethod, normalizeKiroIDCFlow, + isKiroCLIAuthMethod, } from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; import { @@ -60,6 +59,12 @@ import { import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; import { parseGitLabPatAuthResponse } from './gitlab-pat-response'; +import { + getOAuthFlagCandidatesForProvider, + probeCliProxyAdvertisedFlags, + selectAdvertisedAuthFlag, +} from './oauth-cli-capabilities'; +import { buildOAuthArgs } from './oauth-cli-args'; import { buildOAuthStartFailureGuidance, formatOAuthStartFailureForCli, @@ -144,14 +149,27 @@ function buildPlusOAuthCredentialMessage( displayName: string, idEnv: string, secretEnv: string, - missing?: string[] + missing?: string[], + options?: { originalFallbackSupported?: boolean } ): string { const missingText = missing?.length ? ` Missing: ${missing.join(', ')}.` : ''; + const fallbackText = + options?.originalFallbackSupported === false + ? ' Current `cliproxy.backend: original` releases do not advertise Gemini login, so switching back to original will not restore Gemini OAuth.' + : ` or switch \`cliproxy.backend\` to \`original\` for ${displayName}.`; return ( `${displayName} OAuth from CLIProxy Plus is missing Google OAuth client credentials.` + missingText + ` Set ${idEnv} and ${secretEnv} before starting CLIProxy Plus,` + - ` or switch \`cliproxy.backend\` to \`original\` for ${displayName}.` + fallbackText + ); +} + +function getGeminiOriginalBackendOAuthMessage(): string { + return ( + 'Installed CLIProxy binary does not advertise Google Gemini login support (--login). ' + + 'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS. ' + + `To use Gemini OAuth, switch \`cliproxy.backend\` to \`plus\`, set ${GEMINI_PLUS_CLIENT_ID_ENV} and ${GEMINI_PLUS_CLIENT_SECRET_ENV} before starting CLIProxy Plus, reinstall the maintained Plus fork, and retry auth.` ); } @@ -177,7 +195,9 @@ export function getPlusOAuthCredentialError( const missing = [entry.idEnv, entry.secretEnv].filter((name) => !env[name]?.trim()); return missing.length > 0 - ? buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv, missing) + ? buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv, missing, { + originalFallbackSupported: provider !== 'gemini', + }) : null; } @@ -205,7 +225,15 @@ export function getPlusAuthUrlCredentialError( const clientId = parsed.searchParams.get('client_id')?.trim(); return clientId ? null - : buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv); + : buildPlusOAuthCredentialMessage( + entry.displayName, + entry.idEnv, + entry.secretEnv, + undefined, + { + originalFallbackSupported: provider !== 'gemini', + } + ); } catch { return null; } @@ -579,12 +607,21 @@ async function runPreflightChecks( */ async function prepareBinary( provider: CLIProxyProvider, - verbose: boolean -): Promise<{ binaryPath: string; tokenDir: string; configPath: string } | null> { + verbose: boolean, + backend: CLIProxyBackend +): Promise<{ + binaryPath: string; + tokenDir: string; + configPath: string; + backend: CLIProxyBackend; +} | null> { showStep(1, 4, 'progress', 'Preparing CLIProxy binary...'); try { - const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); + const binaryPath = await ensureCLIProxyBinary(verbose, { + backend, + skipAutoUpdate: true, + }); process.stdout.write('\x1b[1A\x1b[2K'); showStep(1, 4, 'ok', 'CLIProxy binary ready'); @@ -596,7 +633,7 @@ async function prepareBinary( console.error(`[auth] Config generated: ${configPath}`); } - return { binaryPath, tokenDir, configPath }; + return { binaryPath, tokenDir, configPath, backend }; } catch (error) { process.stdout.write('\x1b[1A\x1b[2K'); showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary'); @@ -605,49 +642,31 @@ async function prepareBinary( } } -export function buildOAuthArgs( +async function prepareOAuthRuntime( provider: CLIProxyProvider, - configPath: string, - headless: boolean, - noIncognito: boolean, - options: { - kiroMethod?: OAuthOptions['kiroMethod']; - kiroIDCStartUrl?: string; - kiroIDCRegion?: string; - kiroIDCFlow?: OAuthOptions['kiroIDCFlow']; - } = {} -): string[] { - const unsupportedReason = getUnsupportedAuthStartReason(provider); - if (unsupportedReason) { - throw new AuthError(unsupportedReason, provider); + verbose: boolean, + backend: CLIProxyBackend +): Promise<{ + advertisedFlags: ReadonlySet; + backend: CLIProxyBackend; + binaryPath: string; + configPath: string; + tokenDir: string; +}> { + const prepared = await prepareBinary(provider, verbose, backend); + if (!prepared) { + throw new BinaryError('CLIProxy binary preparation returned no runtime'); } - const args = ['--config', configPath]; + return { + ...prepared, + advertisedFlags: probeCliProxyAdvertisedFlags(prepared.binaryPath), + }; +} - if (provider === 'kiro') { - const method = normalizeKiroAuthMethod(options.kiroMethod); - if (!isKiroCLIAuthMethod(method)) { - throw new AuthError(`Kiro auth method '${method}' is not supported by CLI flow.`, 'kiro'); - } - args.push( - ...getKiroCLIAuthArgs(method, { - idcStartUrl: options.kiroIDCStartUrl, - idcRegion: options.kiroIDCRegion, - idcFlow: options.kiroIDCFlow, - }) - ); - } else { - args.push(getOAuthConfig(provider).authFlag); - } - - if (headless) { - args.push('--no-browser'); - } - if (provider === 'kiro' && noIncognito) { - args.push('--no-incognito'); - } - - return args; +function formatOAuthRuntimePreparationError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return `Unable to prepare CLIProxy OAuth runtime: ${message}`; } export function usesKiroLocalCallbackReplay( @@ -1223,12 +1242,10 @@ export async function triggerOAuth( usesKiroLocalCallbackReplay(resolvedKiroMethod, resolvedKiroIDCFlow); const useSelectedKiroDirectCliFlow = provider === 'kiro' && (isDeviceCodeFlow || useSelectedKiroLocalPasteCallback); + const activeBackend = getConfiguredBackend(); if (!(selectedPasteCallback && !useSelectedKiroDirectCliFlow)) { - const credentialError = getGeminiPlusOAuthCredentialError( - provider, - getStoredConfiguredBackend() - ); + const credentialError = getGeminiPlusOAuthCredentialError(provider, activeBackend); if (credentialError) { console.log(fail(credentialError)); return null; @@ -1266,7 +1283,26 @@ export async function triggerOAuth( } if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) { - const tokenDir = getProviderTokenDir(provider); + const target = getProxyTarget(); + let tokenDir = getProviderTokenDir(provider); + if (provider === 'gemini' && activeBackend === 'original' && !target.isRemote) { + try { + const runtime = await prepareOAuthRuntime(provider, verbose, activeBackend); + tokenDir = runtime.tokenDir; + const selectedFlag = selectAdvertisedAuthFlag( + getOAuthFlagCandidatesForProvider(provider), + runtime.advertisedFlags + ); + if (!selectedFlag) { + console.log(fail(getGeminiOriginalBackendOAuthMessage())); + return null; + } + } catch (error) { + console.log(fail(formatOAuthRuntimePreparationError(error))); + return null; + } + } + return handlePasteCallbackMode( provider, oauthConfig, @@ -1289,11 +1325,16 @@ export async function triggerOAuth( console.log(''); - // Prepare binary - const prepared = await prepareBinary(provider, verbose); - if (!prepared) return null; - - const { binaryPath, tokenDir, configPath } = prepared; + let runtime; + let advertisedFlags: ReadonlySet; + try { + runtime = await prepareOAuthRuntime(provider, verbose, activeBackend); + advertisedFlags = runtime.advertisedFlags; + } catch (error) { + console.log(fail(formatOAuthRuntimePreparationError(error))); + return null; + } + const { binaryPath, tokenDir, configPath } = runtime; // Free callback port if needed (only for authorization code flows) const localCallbackPort = callbackPort; @@ -1308,6 +1349,8 @@ export async function triggerOAuth( let args: string[]; try { args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, { + advertisedFlags, + backend: activeBackend, kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, kiroIDCStartUrl: options.kiroIDCStartUrl, kiroIDCRegion: options.kiroIDCRegion, diff --git a/src/cliproxy/auth/test-fixtures/gemini-backend-guidance-child.scenario.ts b/src/cliproxy/auth/test-fixtures/gemini-backend-guidance-child.scenario.ts new file mode 100644 index 00000000..f0c04e55 --- /dev/null +++ b/src/cliproxy/auth/test-fixtures/gemini-backend-guidance-child.scenario.ts @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as childProcess from 'child_process'; +import { ConfigError } from '../../../errors/error-types'; +import type { ProxyTarget } from '../../proxy/proxy-target-resolver'; + +const scenario = process.env['GEMINI_GUIDANCE_SCENARIO']; +if (!scenario) { + throw new ConfigError('GEMINI_GUIDANCE_SCENARIO is required'); +} + +let activeBackend: 'original' | 'plus' = 'original'; +let headlessEnvironment = false; +const proxyTarget: ProxyTarget = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, +}; + +const ensureBinaryMock = mock(async () => '/tmp/fake-cli-proxy-api'); +const generateConfigMock = mock(() => '/tmp/cliproxy-config.yaml'); +const preflightCheckMock = mock(async () => ({ + ready: true, + checks: [], + firewallWarning: false, + firewallFixCommand: undefined, +})); + +const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +const originalFetch = globalThis.fetch; + +function restoreIsTTY(): void { + if (originalIsTTY) { + Object.defineProperty(process.stdin, 'isTTY', originalIsTTY); + } +} + +function getAdvertisedHelpText(localScenario: string): string { + switch (localScenario) { + case 'original-headless-auto-paste-supported': + return ' -login\n'; + case 'original-direct-unsupported': + case 'original-headless-auto-paste-unsupported': + return ' -codex-login\n'; + default: + return ''; + } +} + +async function registerScenarioMocks(): Promise { + const [ + realConfigGenerator, + realAccountManager, + realTokenManager, + realEnvironmentDetector, + realProxyTargetResolver, + realAccountSafety, + realAccountSafetyCrossLane, + realPoolOptInPrompt, + realOAuthPortDiagnostics, + ] = await Promise.all([ + import('../../config/config-generator'), + import('../../accounts/account-manager'), + import('../token-manager'), + import('../environment-detector'), + import('../../proxy/proxy-target-resolver'), + import('../../accounts/account-safety'), + import('../../accounts/account-safety-cross-lane'), + import('../../routing/pool-opt-in-prompt'), + import('../../../management/oauth-port-diagnostics'), + ]); + + mock.module('../../binary-manager', () => ({ + ensureCLIProxyBinary: ensureBinaryMock, + getConfiguredBackend: () => activeBackend, + })); + + mock.module('../../config/config-generator', () => ({ + ...realConfigGenerator, + generateConfig: generateConfigMock, + })); + + mock.module('../../../management/oauth-port-diagnostics', () => ({ + ...realOAuthPortDiagnostics, + enhancedPreflightOAuthCheck: preflightCheckMock, + OAUTH_CALLBACK_PORTS: { gemini: 8085 }, + })); + + mock.module('../environment-detector', () => ({ + ...realEnvironmentDetector, + isHeadlessEnvironment: () => headlessEnvironment, + killProcessOnPort: () => false, + showStep: () => {}, + })); + + mock.module('../../proxy/proxy-target-resolver', () => ({ + ...realProxyTargetResolver, + getProxyTarget: () => proxyTarget, + buildProxyUrl: (target: ProxyTarget, endpointPath: string) => + `${target.protocol}://${target.host}:${target.port}${ + endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}` + }`, + buildManagementHeaders: () => ({}), + })); + + mock.module('../../accounts/account-manager', () => ({ + ...realAccountManager, + getProviderAccounts: () => [], + getDefaultAccount: () => null, + touchAccount: () => undefined, + hasAccountNameConflict: () => false, + findAccountNameMatch: () => null, + PROVIDERS_WITHOUT_EMAIL: [], + validateNickname: () => null, + })); + + mock.module('../token-manager', () => ({ + ...realTokenManager, + getProviderTokenDir: () => '/tmp/ccs-gemini-auth-tests', + isAuthenticated: () => false, + listProviderTokenSnapshots: () => [], + findNewTokenSnapshotForAuthAttempt: () => null, + registerAccountFromToken: () => null, + })); + + mock.module('../../accounts/account-safety', () => ({ + ...realAccountSafety, + checkNewAccountConflict: () => null, + warnNewAccountConflict: () => undefined, + warnOAuthBanRisk: () => undefined, + warnPossible403Ban: () => undefined, + })); + + mock.module('../../accounts/account-safety-cross-lane', () => ({ + ...realAccountSafetyCrossLane, + checkCrossLaneEmailOverlap: () => null, + })); + + mock.module('../../routing/pool-opt-in-prompt', () => ({ + ...realPoolOptInPrompt, + maybeOfferPoolRouting: async () => undefined, + })); +} + +afterEach(() => { + delete process.env.CLIPROXY_GEMINI_OAUTH_CLIENT_ID; + delete process.env.CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET; + restoreIsTTY(); + globalThis.fetch = originalFetch; +}); + +describe('child Gemini backend guidance scenario', () => { + it(`validates ${scenario}`, async () => { + if (scenario === 'plus-missing-env') { + activeBackend = 'plus'; + } else if ( + scenario === 'original-headless-auto-paste-unsupported' || + scenario === 'original-headless-auto-paste-supported' + ) { + headlessEnvironment = true; + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + } + + if (scenario === 'original-headless-auto-paste-supported') { + globalThis.fetch = mock(async () => { + throw new ConfigError('fetch failed'); + }) as unknown as typeof fetch; + } + + const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({ + status: 0, + stdout: getAdvertisedHelpText(scenario), + stderr: '', + pid: 0, + output: [], + signal: null, + error: undefined, + }); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await registerScenarioMocks(); + const { triggerOAuth } = await import(`../oauth-handler?gemini-guidance-child=${Date.now()}`); + const account = await triggerOAuth('gemini', { + headless: scenario === 'original-direct-unsupported' ? false : undefined, + }); + const output = logSpy.mock.calls.map(([message]) => String(message)).join('\n'); + + expect(account).toBeNull(); + + if (scenario === 'plus-missing-env') { + expect(ensureBinaryMock).not.toHaveBeenCalled(); + expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_ID'); + expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET'); + expect(output).toContain( + 'Current `cliproxy.backend: original` releases do not advertise Gemini login' + ); + expect(output).not.toContain('switch `cliproxy.backend` to `original` for Gemini'); + return; + } + + expect(ensureBinaryMock).toHaveBeenCalledTimes(1); + const firstEnsureBinaryCall = ensureBinaryMock.mock.calls.at(0) as unknown[] | undefined; + expect(firstEnsureBinaryCall?.[1]).toEqual( + expect.objectContaining({ backend: 'original', skipAutoUpdate: true }) + ); + + if (scenario === 'original-headless-auto-paste-supported') { + expect(output).not.toContain( + 'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS' + ); + expect(output).toContain('Starting Google Gemini OAuth (paste-callback mode)...'); + expect(output).toContain('Failed to start OAuth flow'); + return; + } + + expect(output).toContain( + 'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS' + ); + expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_ID'); + expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET'); + } finally { + spawnSpy.mockRestore(); + logSpy.mockRestore(); + } + }); +}); diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index f778ff8c..e35753a9 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -244,6 +244,7 @@ export class BinaryManager { export interface EnsureCLIProxyBinaryOptions { allowInstall?: boolean; + backend?: CLIProxyBackend; skipAutoUpdate?: boolean; } @@ -252,7 +253,7 @@ export async function ensureCLIProxyBinary( verbose = false, options: EnsureCLIProxyBinaryOptions = {} ): Promise { - const configuredBackend = getConfiguredOrDefaultBackend(); + const configuredBackend = options.backend ?? getConfiguredOrDefaultBackend(); const backend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); // Migrate old shared pin to backend-specific location (one-time migration) diff --git a/src/cliproxy/config/__tests__/auto-compact-window.test.ts b/src/cliproxy/config/__tests__/auto-compact-window.test.ts new file mode 100644 index 00000000..c3a99476 --- /dev/null +++ b/src/cliproxy/config/__tests__/auto-compact-window.test.ts @@ -0,0 +1,180 @@ +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 { invalidateConfigCache } from '../../../config/config-loader-facade'; +import { clearConfigCache } from '../base-config-loader'; +import { applyClaudeAutoCompactWindow } from '../env-builder'; +import { buildClaudeEnvironment } from '../../executor/env-resolver'; + +describe('applyClaudeAutoCompactWindow', () => { + it('injects the catalog window for the effective model after suffix normalization', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'gpt-5.6-sol-xhigh[1m]', + }, + 'codex' + ); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('372000'); + }); + + it('uses the selected provider when the same model family exists in multiple catalogs', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', + }, + 'agy' + ); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('200000'); + }); + + it('does not inject a value for unknown or custom models', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'custom-model-with-unknown-window', + }, + 'codex' + ); + + expect('CLAUDE_CODE_AUTO_COMPACT_WINDOW' in env).toBe(false); + }); + + it('preserves an explicit user value', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'gpt-5.6-sol', + CLAUDE_CODE_AUTO_COMPACT_WINDOW: '123456', + }, + 'codex' + ); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('123456'); + }); + + it('treats an explicit empty value as authoritative', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'gpt-5.6-sol', + CLAUDE_CODE_AUTO_COMPACT_WINDOW: '', + }, + 'codex' + ); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe(''); + }); + + it('does not mistake a differently cased variable for the Claude Code key', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'gpt-5.6-sol', + claude_code_auto_compact_window: '123456', + }, + 'codex' + ); + + expect(env.claude_code_auto_compact_window).toBe('123456'); + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('372000'); + }); +}); + +describe('Claude launch auto-compact integration', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let originalAutoCompactWindow: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + originalAutoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auto-compact-')); + process.env.CCS_HOME = tempHome; + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + invalidateConfigCache(); + clearConfigCache(); + }); + + afterEach(() => { + if (originalCcsHome === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = originalCcsHome; + + if (originalAutoCompactWindow === undefined) { + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + } else { + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = originalAutoCompactWindow; + } + + invalidateConfigCache(); + clearConfigCache(); + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('injects the catalog window for the provider default model on Claude launches', () => { + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + verbose: false, + }); + + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4(high)'); + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('1050000'); + }); + + it('preserves an inherited user value on Claude launches', () => { + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = ''; + + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + verbose: false, + }); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe(''); + }); + + it('uses the composite default tier provider for model lookup', () => { + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + verbose: false, + isComposite: true, + compositeTiers: { + opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, + sonnet: { provider: 'codex', model: 'gpt-5.6-sol' }, + haiku: { provider: 'kimi', model: 'kimi-k2' }, + }, + compositeDefaultTier: 'opus', + }); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('200000'); + }); + + it('preserves an explicit empty value from a custom settings file', () => { + const settingsPath = path.join(tempHome, 'codex-custom.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'gpt-5.6-sol', + CLAUDE_CODE_AUTO_COMPACT_WINDOW: '', + }, + }) + ); + + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + verbose: false, + }); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe(''); + }); +}); diff --git a/src/cliproxy/config/__tests__/codex-user-routing-generator.test.ts b/src/cliproxy/config/__tests__/codex-user-routing-generator.test.ts new file mode 100644 index 00000000..c4c2d853 --- /dev/null +++ b/src/cliproxy/config/__tests__/codex-user-routing-generator.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { configNeedsRegeneration, regenerateConfig } from '../generator'; + +interface GeneratedCliProxyConfig { + 'oauth-model-alias'?: Record>; + payload?: { + override?: Array<{ + models: Array<{ name: string; protocol?: string }>; + params: Record; + }>; + [section: string]: unknown; + }; + [key: string]: unknown; +} + +describe('Codex user routing config generation', () => { + let testHome: string; + let originalCcsHome: string | undefined; + let generatedConfigPath: string; + let generatedAuthDir: string; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-routing-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = testHome; + const ccsDir = path.join(testHome, '.ccs'); + const cliproxyDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + generatedConfigPath = path.join(cliproxyDir, 'config.yaml'); + generatedAuthDir = path.join(cliproxyDir, 'auth'); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + `version: 14 +cliproxy: + oauth_model_alias: + codex: + - name: gpt-5.6-sol + alias: gpt-5.6-sol-fast + fork: true + payload: + override: + - models: + - name: gpt-5.6-sol-fast + protocol: codex + params: + service_tier: priority +`, + 'utf8' + ); + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(testHome, { recursive: true, force: true }); + }); + + function readGeneratedConfig(): GeneratedCliProxyConfig { + return yaml.load(fs.readFileSync(generatedConfigPath, 'utf8')) as GeneratedCliProxyConfig; + } + + it('generates external client alias semantics with a Codex-only priority override', () => { + regenerateConfig(8317, { + configPath: generatedConfigPath, + authDir: generatedAuthDir, + }); + + const generated = readGeneratedConfig(); + expect(generated['oauth-model-alias']?.codex).toEqual([ + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }, + ]); + expect(generated.payload?.override).toEqual([ + { + models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }], + params: { service_tier: 'priority' }, + }, + ]); + expect( + generated.payload?.override?.some((rule) => + rule.models.some((model) => model.name === 'gpt-5.6-sol') + ) + ).toBe(false); + }); + + it('preserves manual provider/payload sections and stays stable across regeneration', () => { + fs.writeFileSync( + generatedConfigPath, + `# CLIProxyAPI config generated by CCS v20 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${generatedAuthDir}" +oauth-model-alias: + codex: + - name: existing-upstream + alias: existing-client + - name: gpt-5.6-sol + alias: gpt-5.6-sol-fast + future-provider: + - name: future-upstream + alias: future-client +payload: + default: + - models: + - name: existing-client + params: + temperature: 0.2 + override: + - models: + - name: gpt-5.6-sol-fast + protocol: codex + params: + service_tier: standard + - models: + - name: manual-client + protocol: codex + params: + custom_flag: preserved +`, + 'utf8' + ); + + expect(configNeedsRegeneration()).toBe(true); + regenerateConfig(8317, { + configPath: generatedConfigPath, + authDir: generatedAuthDir, + }); + const first = readGeneratedConfig(); + + expect(first['oauth-model-alias']?.codex).toEqual([ + { name: 'existing-upstream', alias: 'existing-client' }, + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }, + ]); + expect(first['oauth-model-alias']?.['future-provider']).toEqual([ + { name: 'future-upstream', alias: 'future-client' }, + ]); + expect(first.payload?.default).toBeDefined(); + expect(first.payload?.override).toEqual([ + { + models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }], + params: { service_tier: 'priority' }, + }, + { + models: [{ name: 'manual-client', protocol: 'codex' }], + params: { custom_flag: 'preserved' }, + }, + ]); + expect(configNeedsRegeneration()).toBe(false); + + regenerateConfig(8317, { + configPath: generatedConfigPath, + authDir: generatedAuthDir, + }); + expect(readGeneratedConfig()).toEqual(first); + }); +}); diff --git a/src/cliproxy/config/__tests__/user-routing-config.test.ts b/src/cliproxy/config/__tests__/user-routing-config.test.ts new file mode 100644 index 00000000..5fb86c85 --- /dev/null +++ b/src/cliproxy/config/__tests__/user-routing-config.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'bun:test'; +import { + mergeOAuthModelAliases, + parseOAuthModelAliasSection, + serializeOAuthModelAliasBody, +} from '../oauth-model-alias-config'; +import { + mergePayloadConfig, + parsePayloadSection, + serializePayloadSection, +} from '../payload-rule-config'; + +describe('CLIProxy user routing config', () => { + it('parses and round-trips provider aliases without dropping provider sections', () => { + const body = ` codex: + - name: gpt-5.6-sol + alias: gpt-5.6-sol-fast + fork: true + future-provider: + - name: upstream-model + alias: client-model +`; + + const parsed = parseOAuthModelAliasSection(body); + expect(parsed.codex).toEqual([{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }]); + expect(parsed['future-provider']).toEqual([{ name: 'upstream-model', alias: 'client-model' }]); + expect(parseOAuthModelAliasSection(serializeOAuthModelAliasBody(parsed))).toEqual(parsed); + }); + + it('preserves insertion order and deduplicates exact aliases while promoting fork', () => { + const merged = mergeOAuthModelAliases( + { + codex: [ + { name: 'first-upstream', alias: 'first-client' }, + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast' }, + ], + }, + { + codex: [ + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }, + { name: 'last-upstream', alias: 'last-client' }, + ], + } + ); + + expect(merged.codex).toEqual([ + { name: 'first-upstream', alias: 'first-client' }, + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }, + { name: 'last-upstream', alias: 'last-client' }, + ]); + }); + + it('lets configured aliases replace an existing provider client alias in place', () => { + const merged = mergeOAuthModelAliases( + { + codex: [ + { name: 'first-upstream', alias: 'first-client' }, + { name: 'old-upstream', alias: 'gpt-5.6-sol-fast', fork: true }, + { name: 'last-upstream', alias: 'last-client' }, + ], + }, + { + codex: [{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast' }], + } + ); + + expect(merged.codex).toEqual([ + { name: 'first-upstream', alias: 'first-client' }, + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast' }, + { name: 'last-upstream', alias: 'last-client' }, + ]); + }); + + it('round-trips unknown payload subsections and replaces matching scoped rules', () => { + const existing = parsePayloadSection(` default: + - models: + - name: keep-default + params: + temperature: 0.2 + override: + - models: + - name: gpt-5.6-sol-fast + protocol: codex + params: + service_tier: standard +`); + const configured = { + override: [ + { + models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }], + params: { service_tier: 'priority' }, + }, + ], + }; + + const merged = mergePayloadConfig(existing, configured); + expect(merged?.default).toBeDefined(); + expect(merged?.override).toEqual(configured.override); + expect(parsePayloadSection(serializePayloadSection(merged).replace(/^payload:\n/, ''))).toEqual( + merged + ); + }); + + it('preserves distinct full predicates that select the same model', () => { + const existing = { + override: [ + { + models: [ + { + name: 'gpt-5.6-sol-fast', + protocol: 'codex', + 'from-protocol': 'openai', + }, + ], + headers: { 'x-tenant': 'alpha' }, + match: { project: '^priority-' }, + params: { service_tier: 'standard' }, + }, + { + models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }], + headers: { 'x-tenant': 'beta' }, + 'not-match': { project: '^disabled-' }, + exist: ['metadata.project'], + params: { service_tier: 'standard' }, + }, + ], + }; + const configured = { + override: [ + { + models: [ + { + protocol: 'codex', + 'from-protocol': 'openai', + name: 'gpt-5.6-sol-fast', + }, + ], + match: { project: '^priority-' }, + headers: { 'x-tenant': 'alpha' }, + params: { service_tier: 'priority' }, + }, + ], + }; + + const merged = mergePayloadConfig(existing, configured); + expect(merged?.override).toEqual([configured.override[0], existing.override[1]]); + expect(parsePayloadSection(serializePayloadSection(merged).replace(/^payload:\n/, ''))).toEqual( + merged + ); + }); +}); diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 6cc77712..7050885f 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -38,6 +38,7 @@ import { } from '../../config/config-loader-facade'; import { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route'; import { ConfigError } from '../../errors/error-types'; +import { findModel } from '../model-catalog'; export { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route'; @@ -68,11 +69,38 @@ const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([ 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', ]); +const CLAUDE_AUTO_COMPACT_WINDOW_ENV = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW'; function isObjectRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Add Claude Code's auto-compact window for a known effective model. + * Existing env keys win by presence, including an explicit empty value. + */ +export function applyClaudeAutoCompactWindow( + envVars: NodeJS.ProcessEnv, + provider: CLIProxyProvider +): NodeJS.ProcessEnv { + if (Object.prototype.hasOwnProperty.call(envVars, CLAUDE_AUTO_COMPACT_WINDOW_ENV)) { + return envVars; + } + + const modelId = envVars.ANTHROPIC_MODEL; + if (typeof modelId !== 'string' || modelId.trim().length === 0) return envVars; + + const contextWindow = findModel(provider, modelId)?.contextWindow; + if (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0) { + return envVars; + } + + return { + ...envVars, + [CLAUDE_AUTO_COMPACT_WINDOW_ENV]: String(contextWindow), + }; +} + /** * Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file. * CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention. diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 4bddff8a..8e9ded86 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -16,6 +16,18 @@ import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-res import { CLIPROXY_DEFAULT_PORT } from './port-manager'; import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; import { getActiveDockerLegacyApiKeys } from '../../docker/docker-key-rotation'; +import { isValidCliproxyRetryValue } from '../../config/schemas/cliproxy'; +import type { CLIProxyOAuthModelAliasConfig } from '../../config/schemas/cliproxy'; +import { + mergeOAuthModelAliases, + parseOAuthModelAliasSection, + serializeOAuthModelAliasBody, +} from './oauth-model-alias-config'; +import { + mergePayloadConfig, + parsePayloadSection, + serializePayloadSection, +} from './payload-rule-config'; /** Internal API key for CCS-managed requests */ export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; @@ -45,8 +57,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config * v19: Persist backend-aware management panel repository from CCS unified config * v20: Pool-gated cooling/routing/retry-cap block; disable-cooling flips to false for pool users + * v21: Persist user-defined OAuth aliases and scoped payload override rules */ -export const CLIPROXY_CONFIG_VERSION = 20; +export const CLIPROXY_CONFIG_VERSION = 21; export const ORIGINAL_MANAGEMENT_PANEL_REPOSITORY = 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center'; @@ -192,6 +205,25 @@ function getSessionAffinityTtl(): string { return ttl && GO_DURATION_PATTERN.test(ttl) && hasPositiveDuration(ttl) ? ttl : '1h'; } +/** + * Number of times CLIProxy retries a request on a transient error + * (403, 408, 500, 502, 503, 504). Opt-in via config.cliproxy.retry.request_retry; + * defaults to 0 (disabled) to avoid burning quota on multi-account pools. + */ +function getRequestRetry(): number { + const value = loadOrCreateUnifiedConfig().cliproxy?.retry?.request_retry; + return isValidCliproxyRetryValue(value) ? value : 0; +} + +/** + * Maximum wait time in seconds for a cooled-down credential before CLIProxy + * retries. Opt-in via config.cliproxy.retry.max_retry_interval; defaults to 0. + */ +function getMaxRetryInterval(): number { + const value = loadOrCreateUnifiedConfig().cliproxy?.retry?.max_retry_interval; + return isValidCliproxyRetryValue(value) ? value : 0; +} + function normalizeManagementPanelRepository(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; @@ -276,7 +308,7 @@ function addAliasEntry( if (getDeniedModelIdReasonForProvider(normalized.name, 'agy')) return; if (getDeniedModelIdReasonForProvider(normalized.alias, 'agy')) return; - const key = `${normalized.name}\u0000${normalized.alias}`; + const key = normalized.alias; const existingIndex = indexByKey.get(key); if (existingIndex !== undefined) { if (normalized.fork) entries[existingIndex].fork = true; @@ -287,6 +319,33 @@ function addAliasEntry( entries.push(normalized); } +function upsertConfiguredAliasEntry( + entries: OAuthModelAliasEntry[], + indexByKey: Map, + entry: OAuthModelAliasEntry +): void { + const normalized: OAuthModelAliasEntry = { + name: sanitizeYamlScalar(entry.name), + alias: normalizeAntigravityAlias(entry.alias), + fork: entry.fork || undefined, + }; + if (!normalized.name || !normalized.alias) return; + if (getDeniedModelIdReasonForProvider(normalized.name, 'agy')) return; + if (getDeniedModelIdReasonForProvider(normalized.alias, 'agy')) return; + + const existingIndex = entries.findIndex((candidate) => candidate.alias === normalized.alias); + if (existingIndex === -1) { + addAliasEntry(entries, indexByKey, normalized); + return; + } + + entries[existingIndex] = normalized; + indexByKey.clear(); + entries.forEach((candidate, index) => { + indexByKey.set(candidate.alias, index); + }); +} + function buildAntigravityAliasKey(entry: Pick): string { return `${sanitizeYamlScalar(entry.name)}\u0000${normalizeAntigravityAlias(entry.alias)}`; } @@ -602,9 +661,13 @@ function writeLegacyGeminiAliasCleanupBackup(configPath: string, existingContent * Generate oauth-model-alias YAML section. * Merges default Antigravity aliases with any user-added custom aliases. */ -function generateOAuthModelAliasSection(existingAliases?: string): string { +function generateOAuthModelAliasSection( + existingAliases?: string, + configuredAliases?: CLIProxyOAuthModelAliasConfig +): string { const aliasEntries: OAuthModelAliasEntry[] = []; const aliasIndexByKey = new Map(); + const normalizedConfiguredAliases = mergeOAuthModelAliases({}, configuredAliases); // Start with default aliases. for (const alias of DEFAULT_ANTIGRAVITY_ALIASES) { @@ -619,6 +682,11 @@ function generateOAuthModelAliasSection(existingAliases?: string): string { } } + // CCS-owned structured aliases replace the same client alias in place. + for (const alias of normalizedConfiguredAliases.antigravity ?? []) { + upsertConfiguredAliasEntry(aliasEntries, aliasIndexByKey, alias); + } + // Expand lightweight compatibility aliases (dot/hyphen + customtools toggle). for (const alias of getCompatibilityAliases(aliasEntries)) { addAliasEntry(aliasEntries, aliasIndexByKey, alias); @@ -632,7 +700,15 @@ function generateOAuthModelAliasSection(existingAliases?: string): string { }) .join('\n'); - return `oauth-model-alias:\n antigravity:\n${entries}`; + const existingAliasConfig = parseOAuthModelAliasSection(existingAliases ?? ''); + const mergedAliasConfig = mergeOAuthModelAliases( + existingAliasConfig, + normalizedConfiguredAliases + ); + delete mergedAliasConfig.antigravity; + const otherProviders = serializeOAuthModelAliasBody(mergedAliasConfig); + + return `oauth-model-alias:\n antigravity:\n${entries}${otherProviders ? `\n${otherProviders}` : ''}`; } /** @@ -643,11 +719,13 @@ function generateOAuthModelAliasSection(existingAliases?: string): string { * @param port - Server port (default: 8317) * @param userApiKeys - User-added API keys to preserve (default: []) * @param existingAliases - Existing oauth-model-alias content to merge with defaults + * @param existingPayload - Existing payload content to merge with structured CCS rules */ function generateUnifiedConfigContent( port: number = CLIPROXY_DEFAULT_PORT, userApiKeys: string[] = [], - existingAliases?: string + existingAliases?: string, + existingPayload?: string ): string { const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories // Convert Windows backslashes to forward slashes for YAML compatibility @@ -659,7 +737,13 @@ function generateUnifiedConfigContent( const routingStrategy = getRoutingStrategy(); const sessionAffinityEnabled = getSessionAffinityEnabled(); const sessionAffinityTtl = getSessionAffinityTtl(); + const requestRetry = getRequestRetry(); + const maxRetryInterval = getMaxRetryInterval(); const managementPanelRepository = getManagementPanelRepository(); + const userRoutingConfig = loadOrCreateUnifiedConfig().cliproxy; + const payloadSection = serializePayloadSection( + mergePayloadConfig(parsePayloadSection(existingPayload ?? ''), userRoutingConfig.payload) + ); // Get effective auth tokens (respects user customization) const effectiveApiKey = getEffectiveApiKey(); @@ -751,8 +835,8 @@ ${coolingComment} disable-cooling: ${disableCoolingValue} # Auto-retry on transient errors (403, 408, 500, 502, 503, 504) -request-retry: 0 -max-retry-interval: 0 +request-retry: ${requestRetry} +max-retry-interval: ${maxRetryInterval} ${poolRoutingBlock} # Auto-switch accounts on quota exceeded (429) # This enables seamless multi-account rotation when rate limited @@ -773,7 +857,8 @@ ${apiKeysYaml} # OAuth tokens directory (auto-discovered by CLIProxyAPI) auth-dir: "${authDirNormalized}" -${generateOAuthModelAliasSection(existingAliases)} +${generateOAuthModelAliasSection(existingAliases, userRoutingConfig.oauth_model_alias)} +${payloadSection ? `${payloadSection}\n` : ''} `; return config; @@ -895,6 +980,7 @@ export function regenerateConfig( let effectivePort = port; let userApiKeys: string[] = []; let existingAliases = ''; + let existingPayload = ''; const preservedSections: PreservedYamlSection[] = []; if (fs.existsSync(configPath)) { @@ -923,15 +1009,21 @@ export function regenerateConfig( // Preserve user customizations while pruning legacy generated Gemini preview noise. const existingConfigVersion = getConfigVersionFromContent(content); - const preservedAliases = extractPreservedAntigravityAliases( - extractYamlSection(content, 'oauth-model-alias'), - { - enableLegacyGeminiStaleCleanup: - existingConfigVersion === null || - existingConfigVersion < LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION, - } - ); - existingAliases = preservedAliases.yaml; + const existingAliasBody = extractYamlSection(content, 'oauth-model-alias'); + const preservedAliases = extractPreservedAntigravityAliases(existingAliasBody, { + enableLegacyGeminiStaleCleanup: + existingConfigVersion === null || + existingConfigVersion < LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION, + }); + const preservedAliasConfig = parseOAuthModelAliasSection(existingAliasBody); + const preservedAntigravityConfig = parseOAuthModelAliasSection(preservedAliases.yaml); + if (preservedAntigravityConfig.antigravity) { + preservedAliasConfig.antigravity = preservedAntigravityConfig.antigravity; + } else { + delete preservedAliasConfig.antigravity; + } + existingAliases = serializeOAuthModelAliasBody(preservedAliasConfig); + existingPayload = extractYamlSection(content, 'payload'); if (preservedAliases.prunedLegacyAliasCount > 0) { writeLegacyGeminiAliasCleanupBackup(configPath, content); } @@ -947,7 +1039,12 @@ export function regenerateConfig( fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); // Generate fresh config with preserved user API keys and aliases - let configContent = generateUnifiedConfigContent(effectivePort, userApiKeys, existingAliases); + let configContent = generateUnifiedConfigContent( + effectivePort, + userApiKeys, + existingAliases, + existingPayload + ); // Re-append managed top-level sections that are not part of the generated defaults. for (const section of preservedSections) { diff --git a/src/cliproxy/config/oauth-model-alias-config.ts b/src/cliproxy/config/oauth-model-alias-config.ts new file mode 100644 index 00000000..3a1c31d7 --- /dev/null +++ b/src/cliproxy/config/oauth-model-alias-config.ts @@ -0,0 +1,130 @@ +/** Parse, merge, and serialize user-owned CLIProxy OAuth model aliases. */ + +import * as yaml from 'js-yaml'; +import type { + CLIProxyOAuthModelAliasConfig, + CLIProxyOAuthModelAliasEntry, +} from '../../config/schemas/cliproxy'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeAliasEntry(value: unknown): CLIProxyOAuthModelAliasEntry | null { + if (!isRecord(value) || typeof value.name !== 'string' || typeof value.alias !== 'string') { + return null; + } + + const name = value.name.trim(); + const alias = value.alias.trim(); + if (!name || !alias) { + return null; + } + + return { + name, + alias, + ...(value.fork === true ? { fork: true } : {}), + }; +} + +function aliasKey(entry: CLIProxyOAuthModelAliasEntry): string { + return entry.alias; +} + +export function parseOAuthModelAliasSection(body: string): CLIProxyOAuthModelAliasConfig { + if (!body.trim()) { + return {}; + } + + try { + const parsed = yaml.load(`oauth-model-alias:\n${body}`); + if (!isRecord(parsed) || !isRecord(parsed['oauth-model-alias'])) { + return {}; + } + + const result = Object.create(null) as CLIProxyOAuthModelAliasConfig; + for (const [provider, entries] of Object.entries(parsed['oauth-model-alias'])) { + if (!provider.trim() || !Array.isArray(entries)) { + continue; + } + + const normalized = entries + .map(normalizeAliasEntry) + .filter((entry): entry is CLIProxyOAuthModelAliasEntry => entry !== null); + if (normalized.length > 0) { + result[provider] = normalized; + } + } + return result; + } catch { + return {}; + } +} + +export function mergeOAuthModelAliases( + existing: CLIProxyOAuthModelAliasConfig, + configured?: CLIProxyOAuthModelAliasConfig +): CLIProxyOAuthModelAliasConfig { + const merged = Object.create(null) as CLIProxyOAuthModelAliasConfig; + + for (const [sourceIndex, source] of [existing, configured].entries()) { + if (!isRecord(source)) { + continue; + } + const replaceMatching = sourceIndex === 1; + for (const [provider, rawEntries] of Object.entries(source)) { + if (!provider.trim() || !Array.isArray(rawEntries)) { + continue; + } + + const entries = merged[provider] ?? []; + const indexByKey = new Map(entries.map((entry, index) => [aliasKey(entry), index])); + for (const rawEntry of rawEntries) { + const entry = normalizeAliasEntry(rawEntry); + if (!entry) { + continue; + } + + const key = aliasKey(entry); + const existingIndex = indexByKey.get(key); + if (existingIndex !== undefined) { + if (replaceMatching) { + entries[existingIndex] = entry; + } else if (entry.fork) { + entries[existingIndex] = { ...entries[existingIndex], fork: true }; + } + continue; + } + + indexByKey.set(key, entries.length); + entries.push(entry); + } + + if (entries.length > 0) { + merged[provider] = entries; + } + } + } + + return merged; +} + +export function serializeOAuthModelAliasBody(config: CLIProxyOAuthModelAliasConfig): string { + if (Object.keys(config).length === 0) { + return ''; + } + + return yaml + .dump(config, { + indent: 2, + lineWidth: -1, + noRefs: true, + noCompatMode: true, + quotingType: '"', + }) + .trimEnd() + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); +} diff --git a/src/cliproxy/config/payload-rule-config.ts b/src/cliproxy/config/payload-rule-config.ts new file mode 100644 index 00000000..b46a8a14 --- /dev/null +++ b/src/cliproxy/config/payload-rule-config.ts @@ -0,0 +1,141 @@ +/** Parse, merge, and serialize user-owned CLIProxy payload rules. */ + +import * as yaml from 'js-yaml'; +import type { + CLIProxyPayloadConfig, + CLIProxyPayloadModelSelector, + CLIProxyPayloadOverrideRule, +} from '../../config/schemas/cliproxy'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isValidModelSelector(value: unknown): value is CLIProxyPayloadModelSelector { + if (!isRecord(value) || typeof value.name !== 'string') { + return false; + } + + return value.name.trim().length > 0; +} + +function cloneOverrideRule(value: unknown): CLIProxyPayloadOverrideRule | null { + if (!isRecord(value) || !Array.isArray(value.models) || !isRecord(value.params)) { + return null; + } + + if (value.models.length === 0 || !value.models.every(isValidModelSelector)) { + return null; + } + + return structuredClone(value) as CLIProxyPayloadOverrideRule; +} + +function canonicalizeForKey(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeForKey); + } + if (!isRecord(value)) { + return value; + } + + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonicalizeForKey(value[key])]) + ); +} + +function predicateKey(rule: CLIProxyPayloadOverrideRule): string { + const predicate = Object.fromEntries(Object.entries(rule).filter(([key]) => key !== 'params')); + return JSON.stringify(canonicalizeForKey(predicate)); +} + +export function parsePayloadSection(body: string): CLIProxyPayloadConfig | undefined { + if (!body.trim()) { + return undefined; + } + + try { + const parsed = yaml.load(`payload:\n${body}`); + if (!isRecord(parsed) || !isRecord(parsed.payload)) { + return undefined; + } + return parsed.payload as CLIProxyPayloadConfig; + } catch { + return undefined; + } +} + +export function mergePayloadConfig( + existing?: CLIProxyPayloadConfig, + configured?: CLIProxyPayloadConfig +): CLIProxyPayloadConfig | undefined { + const existingConfig = isRecord(existing) ? (existing as CLIProxyPayloadConfig) : undefined; + const configuredConfig = isRecord(configured) ? (configured as CLIProxyPayloadConfig) : undefined; + if (!existingConfig && !configuredConfig) { + return undefined; + } + + const merged: CLIProxyPayloadConfig = { + ...(existingConfig ?? {}), + ...(configuredConfig ?? {}), + }; + const rules: CLIProxyPayloadOverrideRule[] = []; + const indexByPredicate = new Map(); + + const addRules = (rawRules: unknown, replaceMatching: boolean) => { + if (!Array.isArray(rawRules)) { + return; + } + + for (const rawRule of rawRules) { + const rule = cloneOverrideRule(rawRule); + if (!rule) { + continue; + } + + const key = predicateKey(rule); + const existingIndex = indexByPredicate.get(key); + if (existingIndex !== undefined) { + if (replaceMatching) { + rules[existingIndex] = rule; + } + continue; + } + + indexByPredicate.set(key, rules.length); + rules.push(rule); + } + }; + + addRules(existingConfig?.override, false); + addRules(configuredConfig?.override, true); + + if (rules.length > 0) { + merged.override = rules; + } else { + delete merged.override; + } + + return Object.keys(merged).length > 0 ? merged : undefined; +} + +export function serializePayloadSection(config?: CLIProxyPayloadConfig): string { + if (!config || Object.keys(config).length === 0) { + return ''; + } + + return yaml + .dump( + { payload: config }, + { + indent: 2, + lineWidth: -1, + noRefs: true, + noCompatMode: true, + quotingType: '"', + } + ) + .trimEnd(); +} diff --git a/src/cliproxy/executor/__tests__/claude-launcher.test.ts b/src/cliproxy/executor/__tests__/claude-launcher.test.ts index 3e587ca9..2afe136f 100644 --- a/src/cliproxy/executor/__tests__/claude-launcher.test.ts +++ b/src/cliproxy/executor/__tests__/claude-launcher.test.ts @@ -47,7 +47,10 @@ mock.module('../../config/config-generator', () => ({ })); mock.module('../../../utils/websearch-manager', () => ({ - appendThirdPartyWebSearchToolArgs: (args: string[]) => args, + appendThirdPartyWebSearchToolArgs: (args: string[], enabled = true) => + enabled + ? [...args, '--append-system-prompt', 'CCS WebSearch steering'] + : [...args, '--disallowedTools', 'WebSearch'], createWebSearchTraceContext: jest.fn().mockReturnValue({}), ensureWebSearchMcpOrThrow: jest.fn(), displayWebSearchStatus: jest.fn(), @@ -124,6 +127,11 @@ function baseContext(overrides: object = {}) { skipLocalAuth: true, sessionId: undefined, imageAnalysisMcpReady: false, + webSearchLaunch: { + config: { enabled: true }, + enabled: true, + hookEnv: { CCS_WEBSEARCH_ENABLED: '1' }, + }, browserRuntimeEnv: undefined, inheritedClaudeConfigDir: undefined, codexReasoningProxy: null, @@ -193,6 +201,22 @@ describe('launchClaude', () => { expect(spawnArgs).toContain('--browser'); }); + it('disallows native WebSearch without CCS steering when the launch snapshot is disabled', async () => { + await launchClaude( + baseContext({ + webSearchLaunch: { + config: { enabled: false }, + enabled: false, + hookEnv: { CCS_WEBSEARCH_ENABLED: '0', CCS_WEBSEARCH_SKIP: '1' }, + }, + }) + ); + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toContain('--disallowedTools'); + expect(spawnArgs).toContain('WebSearch'); + expect(spawnArgs).not.toContain('CCS WebSearch steering'); + }); + it('registers cleanup handlers after spawn', async () => { await launchClaude(baseContext()); expect(mockSetupCleanupHandlers).toHaveBeenCalledTimes(1); diff --git a/src/cliproxy/executor/__tests__/launch-settings.test.ts b/src/cliproxy/executor/__tests__/launch-settings.test.ts index b08d27bc..6080ab27 100644 --- a/src/cliproxy/executor/__tests__/launch-settings.test.ts +++ b/src/cliproxy/executor/__tests__/launch-settings.test.ts @@ -37,6 +37,7 @@ describe('buildLaunchSettingsOverlay', () => { env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', ANTHROPIC_MODEL: 'gpt-5.5', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', }, }); @@ -44,12 +45,14 @@ describe('buildLaunchSettingsOverlay', () => { const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', ANTHROPIC_MODEL: 'gpt-5.5-high', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini-medium', } as NodeJS.ProcessEnv); expect(changed).toBe(true); const env = settings.env as Record; expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5-high'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('gpt-5.4-mini-medium'); // Untouched keys are preserved. expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); }); diff --git a/src/cliproxy/executor/claude-launcher.ts b/src/cliproxy/executor/claude-launcher.ts index ff189bbf..4f03938a 100644 --- a/src/cliproxy/executor/claude-launcher.ts +++ b/src/cliproxy/executor/claude-launcher.ts @@ -15,6 +15,7 @@ import { appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, } from '../../utils/websearch-manager'; +import type { WebSearchLaunchState } from '../../utils/websearch-manager'; import { appendThirdPartyImageAnalysisToolArgs } from '../../utils/image-analysis'; import { appendBrowserToolArgs } from '../../utils/browser'; import { getDefaultAccount } from '../accounts/account-manager'; @@ -51,6 +52,8 @@ export interface ClaudeLaunchContext { sessionId: string | undefined; /** Whether image analysis MCP is ready */ imageAnalysisMcpReady: boolean; + /** WebSearch state captured when launch provisioning ran */ + webSearchLaunch: WebSearchLaunchState; /** Browser runtime environment variables (undefined if browser not active) */ browserRuntimeEnv: NodeJS.ProcessEnv | undefined; /** Inherited Claude config dir for continuity */ @@ -82,6 +85,7 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise; + /** WebSearch hook env captured from the launch configuration snapshot. */ + webSearchEnv?: Record; /** Optional browser runtime env for Claude browser MCP reuse. */ browserRuntimeEnv?: Record; } @@ -246,6 +248,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record v !== undefined) + Object.entries(autoCompactEnv).filter(([, v]) => v !== undefined) ) as Record; } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index d2fc4c76..99737b49 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -52,7 +52,7 @@ import { resolveCliproxyImageAnalysisEnv, } from './env-resolver'; import { checkOrJoinProxy, registerProxySession } from './session-bridge'; -import { getWebSearchHookEnv } from '../../utils/websearch-manager'; +import { resolveWebSearchLaunchState } from '../../utils/websearch-manager'; import { handleLogout, handleImport, @@ -276,10 +276,9 @@ export async function execClaudeWithCLIProxy( await handleImport(authCtx); // Setup first-class CCS WebSearch runtime for non-strict user launches. - const shouldDisplayWebSearchStatus = ensureWebSearchMcpForLaunch(); - if (shouldDisplayWebSearchStatus) { - displayWebSearchStatus(); - } + const webSearchLaunch = resolveWebSearchLaunchState(); + ensureWebSearchMcpForLaunch(webSearchLaunch.config); + displayWebSearchStatus(webSearchLaunch.config); // 3. Ensure OAuth completed (if provider requires it) const remoteAuthToken = proxyConfig.authToken?.trim(); @@ -483,6 +482,7 @@ export async function execClaudeWithCLIProxy( compositeDefaultTier: cfg.compositeDefaultTier, claudeConfigDir: inheritedClaudeConfigDir, imageAnalysisEnv, + webSearchEnv: webSearchLaunch.hookEnv, }); // 9b. Build env-dependent proxy chain (tool-sanitization + codex-reasoning) @@ -527,6 +527,7 @@ export async function execClaudeWithCLIProxy( compositeDefaultTier: cfg.compositeDefaultTier, claudeConfigDir: inheritedClaudeConfigDir, imageAnalysisEnv, + webSearchEnv: webSearchLaunch.hookEnv, browserRuntimeEnv, }); @@ -542,7 +543,7 @@ export async function execClaudeWithCLIProxy( ); } - const webSearchEnv = getWebSearchHookEnv(); + const webSearchEnv = webSearchLaunch.hookEnv; if (process.env.CCS_DEBUG) { logger.info('browser-env-keys', 'CCS_BROWSER_* keys in environment', { keys: Object.keys(env) @@ -580,6 +581,7 @@ export async function execClaudeWithCLIProxy( skipLocalAuth, sessionId, imageAnalysisMcpReady, + webSearchLaunch, browserRuntimeEnv, inheritedClaudeConfigDir, codexReasoningProxy, diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index f300fe13..942730bb 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -52,6 +52,8 @@ export interface ModelEntry { tier?: 'free' | 'pro' | 'ultra'; /** Optional description for the model */ description?: string; + /** Total context window in tokens when authoritatively known */ + contextWindow?: number; /** Model has known issues - show warning when selected */ broken?: boolean; /** Issue URL for broken models */ @@ -95,6 +97,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-6-thinking', name: 'Claude Opus 4.6 Thinking', description: 'Latest flagship, extended thinking', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -111,6 +114,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Latest Sonnet with thinking budget support', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -194,6 +198,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', description: 'Latest frontier agentic coding model.', + contextWindow: 372000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -206,6 +211,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra', description: 'Balanced agentic coding model for everyday work.', + contextWindow: 372000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -218,6 +224,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna', description: 'Fast and affordable agentic coding model.', + contextWindow: 372000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -232,6 +239,7 @@ export const MODEL_CATALOG: Partial> = tier: 'pro', description: 'Newest Codex-released GPT-5 family model; falls back to GPT-5.4 on free plans', + contextWindow: 272000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -244,6 +252,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.4', name: 'GPT-5.4', description: 'Recommended Codex default for most coding and agentic tasks', + contextWindow: 1050000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -256,6 +265,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', description: 'Fast, lower-cost Codex option for lighter tasks and haiku-tier routing', + contextWindow: 400000, thinking: { type: 'levels', levels: ['low', 'medium', 'high'], @@ -281,6 +291,7 @@ export const MODEL_CATALOG: Partial> = tier: 'pro', description: 'Research preview model for ChatGPT Pro subscribers, optimized for near-instant coding iteration', + contextWindow: 128000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -310,11 +321,13 @@ export const MODEL_CATALOG: Partial> = id: 'grok-build-0.1', name: 'Grok Build 0.1', description: 'Fast coding model for agentic software engineering workflows', + contextWindow: 256000, }, { id: 'grok-4.5', name: 'Grok 4.5', description: 'Frontier model for coding, engineering, and agentic workflows', + contextWindow: 500000, thinking: { type: 'levels', levels: ['low', 'medium', 'high'], @@ -325,6 +338,7 @@ export const MODEL_CATALOG: Partial> = id: 'grok-4.3', name: 'Grok 4.3', description: 'General-purpose Grok model with a one-million-token context window', + contextWindow: 1000000, thinking: { type: 'levels', levels: ['none', 'low', 'medium', 'high'], @@ -335,34 +349,40 @@ export const MODEL_CATALOG: Partial> = id: 'grok-4.20-0309-reasoning', name: 'Grok 4.20 0309 Reasoning', description: 'Reasoning model with a two-million-token context window', + contextWindow: 2000000, }, { id: 'grok-4.20-0309-non-reasoning', name: 'Grok 4.20 0309 Non Reasoning', description: 'Non-reasoning model with a two-million-token context window', + contextWindow: 2000000, }, { id: 'grok-4.20-multi-agent-0309', name: 'Grok 4.20 Multi Agent 0309', description: 'Multi-agent model with a two-million-token context window', + contextWindow: 2000000, thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, }, { id: 'grok-3-mini', name: 'Grok 3 Mini', description: 'Compact reasoning model', + contextWindow: 131072, thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, }, { id: 'grok-3-mini-fast', name: 'Grok 3 Mini Fast', description: 'Faster compact reasoning model', + contextWindow: 131072, thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, }, { id: 'grok-composer-2.5-fast', name: 'Grok Composer 2.5 Fast', description: 'Fast Composer model for lightweight coding tasks', + contextWindow: 200000, }, ], }, @@ -375,56 +395,67 @@ export const MODEL_CATALOG: Partial> = id: 'qoder/auto', name: 'Qoder Auto', description: 'Auto selects the best Qoder model for your prompt', + contextWindow: 180000, }, { id: 'qoder/ultimate', name: 'Qoder Ultimate', description: 'Highest quality Qoder tier', + contextWindow: 180000, }, { id: 'qoder/performance', name: 'Qoder Performance', description: 'Balanced quality and speed', + contextWindow: 272000, }, { id: 'qoder/efficient', name: 'Qoder Efficient', description: 'Cost-efficient Qoder tier', + contextWindow: 180000, }, { id: 'qoder/lite', name: 'Qoder Lite', description: 'Fastest and most affordable Qoder tier', + contextWindow: 180000, }, { id: 'qoder/qmodel', name: 'Qwen 3.6 Plus (via Qoder)', description: 'Qwen 3.6 Plus frontier model', + contextWindow: 180000, }, { id: 'qoder/dmodel', name: 'DeepSeek V4 Pro (via Qoder)', description: 'DeepSeek V4 Pro frontier model', + contextWindow: 180000, }, { id: 'qoder/dfmodel', name: 'DeepSeek V4 Flash (via Qoder)', description: 'DeepSeek V4 Flash frontier model', + contextWindow: 180000, }, { id: 'qoder/gm51model', name: 'GLM 5.1 (via Qoder)', description: 'GLM 5.1 frontier model', + contextWindow: 180000, }, { id: 'qoder/kmodel', name: 'Kimi K2.6 (via Qoder)', description: 'Kimi K2.6 frontier model', + contextWindow: 256000, }, { id: 'qoder/mmodel', name: 'MiniMax M2.7 (via Qoder)', description: 'MiniMax M2.7 frontier model', + contextWindow: 180000, }, ], }, @@ -437,6 +468,7 @@ export const MODEL_CATALOG: Partial> = id: 'kimi-k2.5', name: 'Kimi K2.5', description: 'Latest multimodal model (262K context)', + contextWindow: 262144, nativeImageInput: true, thinking: { type: 'budget', @@ -450,6 +482,7 @@ export const MODEL_CATALOG: Partial> = id: 'kimi-k2-thinking', name: 'Kimi K2 Thinking', description: 'Extended reasoning model', + contextWindow: 131072, thinking: { type: 'budget', min: 1024, @@ -462,6 +495,7 @@ export const MODEL_CATALOG: Partial> = id: 'kimi-k2', name: 'Kimi K2', description: 'Flagship coding model', + contextWindow: 131072, }, ], }, @@ -474,6 +508,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-5', name: 'Claude Sonnet 5', description: 'Latest Sonnet model', + contextWindow: 1000000, nativeImageInput: true, // Sonnet 5 uses adaptive thinking; manual budget_tokens is rejected with 400. thinking: { @@ -488,6 +523,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-fable-5', name: 'Claude Fable 5', description: 'Most powerful model', + contextWindow: 1000000, nativeImageInput: true, // New tier above Opus. Same adaptive-thinking surface as Opus 4.8: // Anthropic accepts only effort levels; manual budget_tokens is rejected with 400. @@ -499,10 +535,28 @@ export const MODEL_CATALOG: Partial> = }, extendedContext: true, }, + { + id: 'claude-opus-5', + name: 'Claude Opus 5', + description: 'Latest premium model', + nativeImageInput: true, + // Opus 5 (released 2026-07-24) uses adaptive thinking, matching Sonnet 5 + // and Opus 4.8: Anthropic accepts only effort levels; manual budget_tokens + // is rejected with 400. Proxy metadata reports zero + dynamic allowed. + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', + zeroAllowed: true, + dynamicAllowed: true, + }, + extendedContext: true, + }, { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', description: 'Latest flagship model', + contextWindow: 1000000, nativeImageInput: true, // Mirrors 4.7: Anthropic accepts only adaptive thinking levels on the // current Opus generation; manual budget_tokens is rejected with 400. @@ -518,6 +572,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-7', name: 'Claude Opus 4.7', description: 'Previous flagship model', + contextWindow: 1000000, nativeImageInput: true, // Opus 4.7 only supports adaptive thinking on the Anthropic API; manual // thinking.type: "enabled" with budget_tokens is rejected with 400. @@ -535,6 +590,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-6', name: 'Claude Opus 4.6', description: 'Older flagship model', + contextWindow: 1000000, nativeImageInput: true, thinking: { type: 'budget', @@ -549,6 +605,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Balanced performance and speed', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -563,6 +620,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', description: 'Most capable Claude model', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -577,6 +635,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', description: 'Balanced performance and speed', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -591,6 +650,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', description: 'Previous generation Sonnet', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -605,6 +665,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', description: 'Fast and efficient', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'none' }, }, @@ -658,6 +719,16 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt .trim() .toLowerCase(); const lookupCandidates = new Set([normalizedId, providerNormalizedId]); + if (provider === 'codex') { + for (const candidate of [...lookupCandidates]) { + const tuningMatch = candidate.match( + /^(.*?)(?:-(?:minimal|low|medium|high|xhigh)(?:-fast)?|-fast(?:-(?:minimal|low|medium|high|xhigh))?)$/i + ); + if (tuningMatch?.[1]) { + lookupCandidates.add(tuningMatch[1].trim()); + } + } + } if (isAntigravityProvider(provider)) { const migratedRaw = migrateDeniedAntigravityModelAliases(normalizedId).trim().toLowerCase(); const migratedProvider = migrateDeniedAntigravityModelAliases(providerNormalizedId) diff --git a/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts b/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts index 36b84903..480a7d11 100644 --- a/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts +++ b/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts @@ -622,7 +622,7 @@ describe('Claude Quota Fetcher', () => { expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60); }); - it('does NOT inner-retry on 429; returns retryable single-attempt result honoring Retry-After', async () => { + it('classifies 429 as usage-probe unavailability without implying inference is limited', async () => { // Safety intent: 429 must NOT trigger an immediate, delay-free inner retry. // The outer 10-min cache + circuit breaker honor Retry-After and bound total // volume, so a single attempt is made and the retryable signal is surfaced. @@ -636,18 +636,66 @@ describe('Claude Quota Fetcher', () => { global.fetch = mock(() => { attempt += 1; return Promise.resolve( - new Response('', { status: 429, headers: { 'Retry-After': '120' } }) + new Response(JSON.stringify({ message: 'Rate limited. Please try again later.' }), { + status: 429, + headers: { 'Retry-After': '0', 'Content-Type': 'application/json' }, + }) ); }) as typeof fetch; const result = await fetchClaudeQuota('claude-429@example.com'); expect(result.success).toBe(false); - // Single attempt — no inner retry burned on the 429. expect(attempt).toBe(1); + expect(result.error).toBe('Claude usage status temporarily unavailable'); + expect(result.error).not.toContain('Rate limited'); + expect(result.errorCode).toBe('usage_probe_unavailable'); + expect(result.actionHint).toContain('Inference may still be available'); expect(result.httpStatus).toBe(429); expect(result.retryable).toBe(true); - expect(result.errorDetail).toBe('retry-after:120'); + expect(result.errorDetail).toBe('retry-after:0'); + expect(result.needsReauth).toBe(false); + }); + + it('keeps 403 OAuth usage responses on the authorization-failure path', async () => { + createClaudeAccount('claude-usage-403@example.com', { + access_token: 'oauth-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + global.fetch = mock(() => Promise.resolve(new Response('', { status: 403 }))) as typeof fetch; + + const result = await fetchClaudeQuota('claude-usage-403@example.com'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Not authorized for Claude OAuth usage'); + expect(result.errorCode).toBeUndefined(); + expect(result.httpStatus).toBeUndefined(); + expect(result.retryable).toBeUndefined(); + }); + + it('keeps terminal 5xx responses on the generic retryable server-error path', async () => { + createClaudeAccount('claude-usage-503@example.com', { + access_token: 'oauth-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + let attempt = 0; + global.fetch = mock(() => { + attempt += 1; + return Promise.resolve(new Response('Service unavailable', { status: 503 })); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-usage-503@example.com'); + + expect(attempt).toBe(2); + expect(result.success).toBe(false); + expect(result.error).toBe('Service unavailable'); + expect(result.errorCode).toBeUndefined(); + expect(result.httpStatus).toBe(503); + expect(result.retryable).toBe(true); }); it('clears the request timeout before retrying a retryable HTTP error', async () => { diff --git a/src/cliproxy/quota/quota-fetcher-claude.ts b/src/cliproxy/quota/quota-fetcher-claude.ts index 84863144..48f3897a 100644 --- a/src/cliproxy/quota/quota-fetcher-claude.ts +++ b/src/cliproxy/quota/quota-fetcher-claude.ts @@ -295,10 +295,21 @@ async function runClaudeUsageFetch( continue; } clearTimeout(timeoutId); + if (response.status === 429) { + return { + ...buildEmptyResult('Claude usage status temporarily unavailable', accountId), + httpStatus: response.status, + errorCode: 'usage_probe_unavailable', + actionHint: + 'Inference may still be available. Retry the Claude quota status check later.', + retryable: true, + ...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}), + }; + } return { ...buildEmptyResult(lastError, accountId), httpStatus: response.status, - retryable: response.status === 429 || response.status >= 500, + retryable: response.status >= 500, ...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}), }; } diff --git a/src/cliproxy/routing/__tests__/retry-settings.test.ts b/src/cliproxy/routing/__tests__/retry-settings.test.ts new file mode 100644 index 00000000..97405fdb --- /dev/null +++ b/src/cliproxy/routing/__tests__/retry-settings.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import type { ProxyTarget } from '../../proxy/proxy-target-resolver'; + +const localTarget: ProxyTarget = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, +}; +const remoteTarget: ProxyTarget = { + host: 'proxy.example.com', + port: 443, + protocol: 'https', + isRemote: true, +}; + +describe('CLIProxy retry settings service', () => { + let target: ProxyTarget; + let config: { cliproxy?: { retry?: { request_retry?: number; max_retry_interval?: number } } }; + let regenerateMock: ReturnType; + let fetchRetryMock: ReturnType; + + beforeEach(() => { + target = localTarget; + config = { cliproxy: { retry: { request_retry: 1, max_retry_interval: 10 } } }; + regenerateMock = mock(() => '/tmp/config.yaml'); + fetchRetryMock = mock(); + + mock.module('../../config/generator', () => ({ regenerateConfig: regenerateMock })); + mock.module('../../config/path-resolver', () => ({ + getAuthDir: () => '/tmp/auth', + getConfigPathForPort: () => '/tmp/config.yaml', + })); + mock.module('../../../config/config-loader-facade', () => ({ + loadOrCreateUnifiedConfig: () => structuredClone(config), + mutateConfig: (mutator: (value: typeof config) => void) => { + mutator(config); + return structuredClone(config); + }, + })); + mock.module('../routing-strategy-http', () => ({ + getCliproxyRoutingTarget: () => target, + fetchCliproxyRetryResponse: fetchRetryMock, + getRoutingErrorMessage: async (response: Response, fallback: string) => { + const body = (await response.json().catch(() => null)) as { error?: string } | null; + return body?.error ?? fallback; + }, + })); + }); + + afterEach(() => mock.restore()); + + async function loadService() { + return import(`../retry-settings?test=${Date.now()}-${Math.random()}`) as Promise< + typeof import('../retry-settings') + >; + } + + it('reads both official management endpoints as one live state', async () => { + fetchRetryMock.mockImplementation( + async (_target: ProxyTarget, setting: string) => + new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 3 : 30 })) + ); + const { readCliproxyRetryState } = await loadService(); + + await expect(readCliproxyRetryState()).resolves.toEqual({ + request_retry: 3, + max_retry_interval: 30, + source: 'live', + target: 'local', + reachable: true, + manageable: true, + }); + expect(fetchRetryMock.mock.calls.map((call) => call[1])).toEqual([ + 'request-retry', + 'max-retry-interval', + ]); + }); + + it('keeps remote updates live-only', async () => { + target = remoteTarget; + fetchRetryMock.mockImplementation( + async (_target: ProxyTarget, setting: string, method: string) => + method === 'GET' + ? new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 1 : 10 })) + : new Response('{}') + ); + const { applyCliproxyRetrySettings } = await loadService(); + + const result = await applyCliproxyRetrySettings({ + request_retry: 4, + max_retry_interval: 40, + }); + + expect(result.applied).toBe('live'); + expect(config.cliproxy?.retry).toEqual({ request_retry: 1, max_retry_interval: 10 }); + expect(regenerateMock).not.toHaveBeenCalled(); + }); + + it('rolls back the first upstream value when the second PUT fails', async () => { + target = remoteTarget; + const puts: Array<[string, number]> = []; + fetchRetryMock.mockImplementation( + async (_target: ProxyTarget, setting: string, method: string, value?: number) => { + if (method === 'GET') { + return new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 2 : 20 })); + } + puts.push([setting, value as number]); + if (setting === 'max-retry-interval') { + return new Response(JSON.stringify({ error: 'second write failed' }), { status: 500 }); + } + return new Response('{}'); + } + ); + const { applyCliproxyRetrySettings } = await loadService(); + + await expect( + applyCliproxyRetrySettings({ request_retry: 5, max_retry_interval: 50 }) + ).rejects.toThrow('second write failed'); + expect(puts).toEqual([ + ['request-retry', 5], + ['max-retry-interval', 50], + ['request-retry', 2], + ]); + }); + + it('restores local persisted settings when regeneration fails', async () => { + fetchRetryMock.mockRejectedValue(new Error('offline')); + regenerateMock + .mockImplementationOnce(() => { + throw new Error('write failed'); + }) + .mockImplementationOnce(() => '/tmp/config.yaml'); + const { applyCliproxyRetrySettings } = await loadService(); + + await expect( + applyCliproxyRetrySettings({ request_retry: 9, max_retry_interval: 90 }) + ).rejects.toThrow('Saved retry settings were rolled back'); + expect(config.cliproxy?.retry).toEqual({ request_retry: 1, max_retry_interval: 10 }); + expect(regenerateMock).toHaveBeenCalledTimes(2); + }); + + it('serializes complete pair operations', async () => { + target = remoteTarget; + const events: string[] = []; + let releaseFirstRead: (() => void) | undefined; + const firstReadGate = new Promise((resolve) => { + releaseFirstRead = resolve; + }); + let requestReadCount = 0; + fetchRetryMock.mockImplementation( + async (_target: ProxyTarget, setting: string, method: string, value?: number) => { + events.push(`${method}:${setting}:${value ?? ''}`); + if (method === 'GET' && setting === 'request-retry' && requestReadCount++ === 0) { + await firstReadGate; + } + return method === 'GET' + ? new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 1 : 10 })) + : new Response('{}'); + } + ); + const { applyCliproxyRetrySettings } = await loadService(); + + const first = applyCliproxyRetrySettings({ request_retry: 2, max_retry_interval: 20 }); + const second = applyCliproxyRetrySettings({ request_retry: 3, max_retry_interval: 30 }); + await Promise.resolve(); + expect(events).toEqual(['GET:request-retry:']); + releaseFirstRead?.(); + await Promise.all([first, second]); + + expect(events).toEqual([ + 'GET:request-retry:', + 'GET:max-retry-interval:', + 'PUT:request-retry:2', + 'PUT:max-retry-interval:20', + 'GET:request-retry:', + 'GET:max-retry-interval:', + 'PUT:request-retry:3', + 'PUT:max-retry-interval:30', + ]); + }); +}); diff --git a/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts b/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts index 5f6682b9..2ae16bc6 100644 --- a/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts +++ b/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts @@ -104,4 +104,44 @@ describe('routing-strategy-http', () => { 'https://proxy.example.com:443/v0/management/routing/strategy' ); }); + + it('builds target-aware retry management URLs', async () => { + const { getCliproxyRetryManagementUrl } = await loadRoutingHttpModule(); + const target: ProxyTarget = { + host: 'proxy.example.com', + port: 443, + protocol: 'https', + isRemote: true, + }; + + expect(getCliproxyRetryManagementUrl(target, 'request-retry')).toBe( + 'https://proxy.example.com:443/v0/management/request-retry' + ); + expect(getCliproxyRetryManagementUrl(target, 'max-retry-interval')).toBe( + 'https://proxy.example.com:443/v0/management/max-retry-interval' + ); + }); + + it('sends retry updates as an integer value payload', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => new Response('{}')); + globalThis.fetch = fetchMock as typeof fetch; + const target: ProxyTarget = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }; + + try { + const { fetchCliproxyRetryResponse } = await loadRoutingHttpModule(); + await fetchCliproxyRetryResponse(target, 'request-retry', 'PUT', 4); + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:8317/v0/management/request-retry', + expect.objectContaining({ method: 'PUT', body: JSON.stringify({ value: 4 }) }) + ); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/src/cliproxy/routing/retry-settings.ts b/src/cliproxy/routing/retry-settings.ts new file mode 100644 index 00000000..9bd9da63 --- /dev/null +++ b/src/cliproxy/routing/retry-settings.ts @@ -0,0 +1,244 @@ +import { regenerateConfig } from '../config/generator'; +import { getAuthDir, getConfigPathForPort } from '../config/path-resolver'; +import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; +import type { ProxyTarget } from '../proxy/proxy-target-resolver'; +import { ConfigError, NetworkError } from '../../errors/error-types'; +import { + fetchCliproxyRetryResponse, + getCliproxyRoutingTarget, + getRoutingErrorMessage, + type CliproxyRetryManagementSetting, +} from './routing-strategy-http'; + +export interface CliproxyRetryValues { + request_retry: number; + max_retry_interval: number; +} + +export interface CliproxyRetryState extends CliproxyRetryValues { + source: 'live' | 'config'; + target: 'local' | 'remote'; + reachable: boolean; + manageable: boolean; + message?: string; +} + +export interface CliproxyRetryApplyResult extends CliproxyRetryState { + applied: 'live' | 'live-and-config' | 'config-only'; +} + +const DEFAULT_RETRY_VALUES: CliproxyRetryValues = { + request_retry: 0, + max_retry_interval: 0, +}; + +let retryOperationQueue: Promise = Promise.resolve(); + +export function normalizeCliproxyRetryValue(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function serializeRetryOperation(operation: () => Promise): Promise { + const result = retryOperationQueue.then(operation, operation); + retryOperationQueue = result.then( + () => undefined, + () => undefined + ); + return result; +} + +function getConfiguredRetryValues(): CliproxyRetryValues { + const retry = loadOrCreateUnifiedConfig().cliproxy?.retry; + return { + request_retry: + normalizeCliproxyRetryValue(retry?.request_retry) ?? DEFAULT_RETRY_VALUES.request_retry, + max_retry_interval: + normalizeCliproxyRetryValue(retry?.max_retry_interval) ?? + DEFAULT_RETRY_VALUES.max_retry_interval, + }; +} + +async function readLiveRetryValue( + target: ProxyTarget, + setting: CliproxyRetryManagementSetting +): Promise { + const response = await fetchCliproxyRetryResponse(target, setting, 'GET'); + if (!response.ok) { + throw new NetworkError( + await getRoutingErrorMessage( + response, + `Failed to read CLIProxy ${setting} (${response.status})` + ) + ); + } + + const data = (await response.json()) as Record; + const value = normalizeCliproxyRetryValue(data[setting] ?? data.value); + if (value === null) { + throw new NetworkError(`CLIProxy returned an invalid ${setting} value`); + } + return value; +} + +async function readLiveRetryValues(target: ProxyTarget): Promise { + const requestRetry = await readLiveRetryValue(target, 'request-retry'); + const maxRetryInterval = await readLiveRetryValue(target, 'max-retry-interval'); + return { + request_retry: requestRetry, + max_retry_interval: maxRetryInterval, + }; +} + +async function putLiveRetryValue( + target: ProxyTarget, + setting: CliproxyRetryManagementSetting, + value: number +): Promise { + const response = await fetchCliproxyRetryResponse(target, setting, 'PUT', value); + if (!response.ok) { + throw new NetworkError( + await getRoutingErrorMessage( + response, + `Failed to update CLIProxy ${setting} (${response.status})` + ) + ); + } +} + +async function updateLiveRetryValues( + target: ProxyTarget, + values: CliproxyRetryValues, + previousRequestRetry: number +): Promise { + await putLiveRetryValue(target, 'request-retry', values.request_retry); + try { + await putLiveRetryValue(target, 'max-retry-interval', values.max_retry_interval); + } catch (error) { + try { + await putLiveRetryValue(target, 'request-retry', previousRequestRetry); + } catch (rollbackError) { + throw new NetworkError( + `${(error as Error).message}. Failed to roll back request-retry: ${(rollbackError as Error).message}` + ); + } + throw error; + } +} + +function persistLocalRetryValues(target: ProxyTarget, values: CliproxyRetryValues): void { + const previousRetry = loadOrCreateUnifiedConfig().cliproxy?.retry; + const previous = previousRetry ? { ...previousRetry } : undefined; + const configPath = getConfigPathForPort(target.port); + const authDir = getAuthDir(); + + mutateConfig((config) => { + config.cliproxy = config.cliproxy ?? {}; + config.cliproxy.retry = { ...values }; + }); + + try { + regenerateConfig(target.port, { configPath, authDir }); + } catch (error) { + mutateConfig((config) => { + config.cliproxy = config.cliproxy ?? {}; + if (previous) { + config.cliproxy.retry = previous; + } else { + delete config.cliproxy.retry; + } + }); + + try { + regenerateConfig(target.port, { configPath, authDir }); + } catch (rollbackError) { + throw new ConfigError( + `Failed to regenerate CLIProxy config: ${(error as Error).message}. Rollback regeneration also failed: ${(rollbackError as Error).message}` + ); + } + throw new ConfigError( + `Failed to regenerate CLIProxy config: ${(error as Error).message}. Saved retry settings were rolled back.` + ); + } +} + +export function readCliproxyRetryState(): Promise { + return serializeRetryOperation(async () => { + const target = getCliproxyRoutingTarget(); + try { + const values = await readLiveRetryValues(target); + return { + ...values, + source: 'live', + target: target.isRemote ? 'remote' : 'local', + reachable: true, + manageable: true, + }; + } catch (error) { + if (target.isRemote) throw error; + return { + ...getConfiguredRetryValues(), + source: 'config', + target: 'local', + reachable: false, + manageable: true, + message: 'Local CLIProxy is not reachable. Showing the saved startup defaults.', + }; + } + }); +} + +export function applyCliproxyRetrySettings( + values: CliproxyRetryValues +): Promise { + return serializeRetryOperation(async () => { + const target = getCliproxyRoutingTarget(); + let previousLive: CliproxyRetryValues; + + try { + previousLive = await readLiveRetryValues(target); + } catch (error) { + if (target.isRemote) throw error; + persistLocalRetryValues(target, values); + return { + ...values, + source: 'config', + target: 'local', + reachable: false, + manageable: true, + applied: 'config-only', + message: 'Saved the local startup defaults. They will apply the next time CLIProxy starts.', + }; + } + + if (!target.isRemote) { + persistLocalRetryValues(target, values); + } + + try { + await updateLiveRetryValues(target, values, previousLive.request_retry); + } catch (error) { + if (target.isRemote) throw error; + return { + ...values, + source: 'config', + target: 'local', + reachable: true, + manageable: true, + applied: 'config-only', + message: `Saved the local startup defaults, but the running proxy rejected the live update: ${(error as Error).message}`, + }; + } + + return { + ...values, + source: 'live', + target: target.isRemote ? 'remote' : 'local', + reachable: true, + manageable: true, + applied: target.isRemote ? 'live' : 'live-and-config', + message: target.isRemote + ? 'Updated the running remote CLIProxy. Local CCS config was not changed.' + : 'Updated the running local CLIProxy and saved the startup defaults.', + }; + }); +} diff --git a/src/cliproxy/routing/routing-strategy-http.ts b/src/cliproxy/routing/routing-strategy-http.ts index f361eb7e..1de1b57e 100644 --- a/src/cliproxy/routing/routing-strategy-http.ts +++ b/src/cliproxy/routing/routing-strategy-http.ts @@ -9,16 +9,38 @@ import { const ROUTING_TIMEOUT_MS = 5000; const CLIPROXY_ROUTING_MANAGEMENT_PATH = '/v0/management/routing/strategy'; +export type CliproxyRetryManagementSetting = 'request-retry' | 'max-retry-interval'; + export function getCliproxyRoutingManagementUrl(target: ProxyTarget): string { return buildProxyUrl(target, CLIPROXY_ROUTING_MANAGEMENT_PATH); } +export function getCliproxyRetryManagementUrl( + target: ProxyTarget, + setting: CliproxyRetryManagementSetting +): string { + return buildProxyUrl(target, `/v0/management/${setting}`); +} + export async function fetchCliproxyRoutingResponse( target: ProxyTarget, method: 'GET' | 'PUT', body?: Record ): Promise { - const url = getCliproxyRoutingManagementUrl(target); + return fetchCliproxyManagementResponse( + target, + getCliproxyRoutingManagementUrl(target), + method, + body + ); +} + +async function fetchCliproxyManagementResponse( + target: ProxyTarget, + url: string, + method: 'GET' | 'PUT', + body?: Record +): Promise { const headers = buildManagementHeaders( target, body ? { 'Content-Type': 'application/json' } : {} @@ -115,6 +137,20 @@ export async function fetchCliproxyRoutingResponse( }); } +export async function fetchCliproxyRetryResponse( + target: ProxyTarget, + setting: CliproxyRetryManagementSetting, + method: 'GET' | 'PUT', + value?: number +): Promise { + return fetchCliproxyManagementResponse( + target, + getCliproxyRetryManagementUrl(target, setting), + method, + value === undefined ? undefined : { value } + ); +} + export function getCliproxyRoutingTarget(): ProxyTarget { return getProxyTarget(); } diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 0c6511cd..bfbf0560 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -221,6 +221,13 @@ function mapRemoteToModelEntry(provider: CLIProxyProvider, remote: RemoteModelIn name: remote.display_name || remote.id, }; if (remote.description) entry.description = remote.description; + if ( + typeof remote.context_length === 'number' && + Number.isInteger(remote.context_length) && + remote.context_length > 0 + ) { + entry.contextWindow = remote.context_length; + } // xAI context length is inherent to the model ID; its API does not accept // Claude's [1m] model suffix. if (provider !== 'xai' && remote.context_length && remote.context_length >= 1_000_000) { @@ -286,6 +293,7 @@ export function mergeCatalog( issueUrl: staticEntry.issueUrl, deprecated: staticEntry.deprecated, deprecationReason: staticEntry.deprecationReason, + contextWindow: remoteEntry.contextWindow ?? staticEntry.contextWindow, }); } else { mergedModels.push(remoteEntry); diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts index ccc59e4c..c211075d 100644 --- a/src/cliproxy/session-tracker.ts +++ b/src/cliproxy/session-tracker.ts @@ -17,10 +17,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; -import * as lockfile from 'proper-lockfile'; import { getCliproxyDir } from './config/config-generator'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import { CLIPROXY_DEFAULT_PORT } from './config/config-generator'; +import { withSyncLockRetry } from '../utils/sync-lock-retry'; /** Session lock file structure */ interface SessionLock { @@ -61,22 +61,9 @@ function ensureCliproxyDir(): string { function withSessionTrackerLock(fn: () => T): T { const dir = ensureCliproxyDir(); - let release: (() => void) | undefined; - - try { - release = lockfile.lockSync(dir, { - stale: 10000, - }) as () => void; - return fn(); - } finally { - if (release) { - try { - release(); - } catch { - // Best-effort release - } - } - } + return withSyncLockRetry(dir, fn, { + description: 'CLIProxy session tracker lock', + }); } /** Get path to session lock file (default port) - kept for future use */ diff --git a/src/cliproxy/types/config-types.ts b/src/cliproxy/types/config-types.ts index bf873439..b67e02d2 100644 --- a/src/cliproxy/types/config-types.ts +++ b/src/cliproxy/types/config-types.ts @@ -3,6 +3,10 @@ */ import type { CompositeTierConfig } from '../../config/unified-config-types'; +import type { + CLIProxyOAuthModelAliasConfig, + CLIProxyPayloadConfig, +} from '../../config/schemas/cliproxy'; import type { CliproxyRoutingStrategy } from './provider-types'; /** CLIProxy config.yaml structure (minimal) */ @@ -11,6 +15,8 @@ export interface CLIProxyConfig { 'api-keys': string[]; 'auth-dir': string; debug: boolean; + 'oauth-model-alias'?: CLIProxyOAuthModelAliasConfig; + payload?: CLIProxyPayloadConfig; routing?: { strategy?: CliproxyRoutingStrategy; 'session-affinity'?: boolean; diff --git a/src/codex-auth/shell-detect.ts b/src/codex-auth/shell-detect.ts index d9046775..a8698eb1 100644 --- a/src/codex-auth/shell-detect.ts +++ b/src/codex-auth/shell-detect.ts @@ -95,3 +95,17 @@ export function formatExport(shell: Shell, key: string, value: string): string { return `export ${key}=${posixSingleQuote(value)}`; } } + +/** Format removal of one environment variable for the target shell. */ +export function formatUnset(shell: Shell, key: string): string { + switch (shell) { + case 'fish': + return `set -e ${key};`; + case 'pwsh': + return `Remove-Item Env:${key} -ErrorAction SilentlyContinue`; + case 'cmd': + return `set "${key}="`; + default: + return `unset ${key}`; + } +} diff --git a/src/commands/bar/bar-process-control.ts b/src/commands/bar/bar-process-control.ts new file mode 100644 index 00000000..2a779dd6 --- /dev/null +++ b/src/commands/bar/bar-process-control.ts @@ -0,0 +1,269 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import { ConfigError } from '../../errors/error-types'; +import { getServerPidPath } from './bar-paths'; + +export interface BarServerProcessRecord { + pid: number; + birthIdentity: string; +} + +export type BarServerStopResult = + | 'stopped' + | 'stale' + | 'legacy-record' + | 'invalid-record' + | 'identity-mismatch' + | 'permission-denied' + | 'signal-failed' + | 'timeout'; + +export interface BarServerStopDeps { + getProcessBirthIdentity: (pid: number) => string | null; + killProcess: (pid: number, signal: 'SIGTERM') => void; + waitForProcessExit: ( + pid: number, + birthIdentity: string, + timeoutMs: number + ) => Promise<'exited' | 'identity-mismatch' | 'timeout'>; +} + +export function parseBarServerProcessRecord(raw: string): BarServerProcessRecord | null { + try { + const parsed = JSON.parse(raw) as Partial; + const pid = parsed.pid; + if (!Number.isSafeInteger(pid) || (pid ?? 0) <= 0) return null; + if (typeof parsed.birthIdentity !== 'string' || parsed.birthIdentity.trim() === '') return null; + return { pid: pid as number, birthIdentity: parsed.birthIdentity }; + } catch { + return null; + } +} + +export function parseLegacyServerPid(raw: string): number | null { + const trimmed = raw.trim(); + if (!/^[1-9]\d*$/.test(trimmed)) return null; + const pid = Number(trimmed); + return Number.isSafeInteger(pid) ? pid : null; +} + +export function serializeBarServerProcessRecord(record: BarServerProcessRecord): string { + return JSON.stringify(record, null, 2); +} + +/** + * Return the OS process birth marker for PID reuse protection. CCS Bar is a + * macOS app, where `ps lstart` is stable for a process lifetime. Linux uses the + * same portable ps surface in development and CI. + */ +export function getProcessBirthIdentity(pid: number): string | null { + if (!Number.isSafeInteger(pid) || pid <= 0) return null; + try { + const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return output || null; + } catch { + return null; + } +} + +export async function waitForProcessExit( + pid: number, + birthIdentity: string, + timeoutMs: number +): Promise<'exited' | 'identity-mismatch' | 'timeout'> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const currentIdentity = getProcessBirthIdentity(pid); + if (currentIdentity === null) return 'exited'; + if (currentIdentity !== birthIdentity) return 'identity-mismatch'; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return 'timeout'; +} + +export async function stopRecordedBarServer( + rawRecord: string, + deps: Partial = {} +): Promise<{ result: BarServerStopResult; record: BarServerProcessRecord | null; error?: Error }> { + const record = parseBarServerProcessRecord(rawRecord); + if (record === null) { + return { + result: parseLegacyServerPid(rawRecord) === null ? 'invalid-record' : 'legacy-record', + record: null, + }; + } + + const getIdentity = deps.getProcessBirthIdentity ?? getProcessBirthIdentity; + const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal)); + const waitForExit = deps.waitForProcessExit ?? waitForProcessExit; + + // Revalidate immediately before SIGTERM. A PID alone is unsafe because the OS + // can reuse it after the recorded CCS Bar process exits. + const currentIdentity = getIdentity(record.pid); + if (currentIdentity === null) return { result: 'stale', record }; + if (currentIdentity !== record.birthIdentity) return { result: 'identity-mismatch', record }; + + try { + killProcess(record.pid, 'SIGTERM'); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return { result: 'stale', record }; + if (code === 'EPERM') return { result: 'permission-denied', record, error }; + return { result: 'signal-failed', record, error }; + } + + const waitResult = await waitForExit(record.pid, record.birthIdentity, 3_000); + if (waitResult === 'exited') return { result: 'stopped', record }; + if (waitResult === 'identity-mismatch') return { result: 'identity-mismatch', record }; + return { result: 'timeout', record }; +} + +export interface ClaimedBarServerStopOutcome { + result: BarServerStopResult | 'no-record'; + record: BarServerProcessRecord | null; + legacyPid?: number; + error?: Error; + recoveryPath?: string; +} + +function isErrno(err: unknown, code: string): boolean { + return (err as NodeJS.ErrnoException).code === code; +} + +function unlinkIfPresent(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch (err) { + if (!isErrno(err, 'ENOENT')) throw err; + } +} + +function restoreClaimWithoutOverwrite( + claimPath: string, + canonicalPath: string +): string | undefined { + try { + fs.linkSync(claimPath, canonicalPath); + unlinkIfPresent(claimPath); + return undefined; + } catch (err) { + if (isErrno(err, 'EEXIST')) return claimPath; + throw err; + } +} + +/** + * Atomically claim server.pid before signaling. The serve process therefore + * sees ENOENT during its signal handler and cannot unlink a new owner's record. + */ +export async function stopBarServerProcessFile( + pidPath: string, + deps: Partial = {} +): Promise { + const claimPath = `${pidPath}.claim-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + try { + fs.renameSync(pidPath, claimPath); + } catch (err) { + if (isErrno(err, 'ENOENT')) return { result: 'no-record', record: null }; + throw err; + } + + let rawRecord: string; + try { + rawRecord = fs.readFileSync(claimPath, 'utf8').trim(); + } catch (err) { + const recoveryPath = restoreClaimWithoutOverwrite(claimPath, pidPath); + return { + result: 'invalid-record', + record: null, + error: err instanceof Error ? err : new Error(String(err)), + recoveryPath, + }; + } + + const outcome = await stopRecordedBarServer(rawRecord, deps); + if (outcome.result === 'stopped' || outcome.result === 'stale') { + unlinkIfPresent(claimPath); + return outcome; + } + + const recoveryPath = restoreClaimWithoutOverwrite(claimPath, pidPath); + return { + ...outcome, + legacyPid: + outcome.result === 'legacy-record' + ? (parseLegacyServerPid(rawRecord) ?? undefined) + : undefined, + recoveryPath, + }; +} + +/** Remove server.pid only when it still belongs to this exact serve process. */ +export function removeBarServerProcessRecordIfOwned( + pidPath: string, + expectedRecord: BarServerProcessRecord +): void { + const claimPath = `${pidPath}.cleanup-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + try { + fs.renameSync(pidPath, claimPath); + } catch (err) { + if (isErrno(err, 'ENOENT')) return; + throw err; + } + + let claimedRecord: BarServerProcessRecord | null = null; + try { + claimedRecord = parseBarServerProcessRecord(fs.readFileSync(claimPath, 'utf8')); + } catch { + // Preserve unreadable state below. + } + + if ( + claimedRecord?.pid === expectedRecord.pid && + claimedRecord.birthIdentity === expectedRecord.birthIdentity + ) { + unlinkIfPresent(claimPath); + return; + } + restoreClaimWithoutOverwrite(claimPath, pidPath); +} + +/** Claim stale discovery state so a replacement write can never be unlinked. */ +export function removeBarDiscoveryIfNoProcess(barJsonPath: string, pidPath: string): boolean { + const claimPath = `${barJsonPath}.cleanup-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + try { + fs.renameSync(barJsonPath, claimPath); + } catch (err) { + if (isErrno(err, 'ENOENT')) return true; + throw err; + } + if (fs.existsSync(pidPath)) { + restoreClaimWithoutOverwrite(claimPath, barJsonPath); + return false; + } + unlinkIfPresent(claimPath); + return true; +} + +export async function stopDetachedBarServer(ccsDir: string): Promise { + const pidPath = getServerPidPath(ccsDir); + try { + const outcome = await stopBarServerProcessFile(pidPath); + if (outcome.result !== 'stopped' && outcome.result !== 'stale') { + throw new ConfigError( + `Safe CCS Bar stop aborted (${outcome.result}); recovery state was preserved`, + outcome.recoveryPath ?? pidPath + ); + } + } catch (err) { + if (err instanceof ConfigError) throw err; + throw new ConfigError( + `Cannot safely stop CCS Bar process: ${err instanceof Error ? err.message : String(err)}`, + pidPath + ); + } +} diff --git a/src/commands/bar/bar-server-probe.ts b/src/commands/bar/bar-server-probe.ts index 496521e6..adc7a627 100644 --- a/src/commands/bar/bar-server-probe.ts +++ b/src/commands/bar/bar-server-probe.ts @@ -25,9 +25,16 @@ export interface DashboardInfo { authRequired?: boolean; } +function isValidPort(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= 65535; +} + /** - * Read the port recorded in an existing bar.json. - * Returns null when the file is absent or malformed. + * Read the port recorded in an existing bar.json, falling back to the --port + * in launch.json's args. `ccs bar stop` deletes bar.json but leaves + * launch.json, so the fallback is what keeps the sticky port (and the probe's + * ability to find a server on a non-default port) across a stop/start cycle. + * Returns null when neither file records a port. */ export function resolveBarPort(ccsDir: string): number | null { @@ -35,10 +42,28 @@ export function resolveBarPort(ccsDir: string): number | null { try { const raw = fs.readFileSync(barJsonPath, 'utf8'); const parsed = JSON.parse(raw) as Partial<{ port: number }>; - return typeof parsed.port === 'number' ? parsed.port : null; + if (isValidPort(parsed.port)) return parsed.port; } catch { - return null; + /* fall through to launch.json */ } + + const launchJsonPath = path.join(ccsDir, 'bar', 'launch.json'); + try { + const raw = fs.readFileSync(launchJsonPath, 'utf8'); + const parsed = JSON.parse(raw) as Partial<{ args: unknown[] }>; + const args = Array.isArray(parsed.args) ? parsed.args : []; + const idx = args.indexOf('--port'); + if (idx !== -1 && idx + 1 < args.length) { + const rawPort = args[idx + 1]; + if (typeof rawPort === 'string' && /^[1-9]\d{0,4}$/.test(rawPort)) { + const n = Number(rawPort); + if (isValidPort(n)) return n; + } + } + } catch { + /* absent or malformed -> null */ + } + return null; } /** @@ -84,18 +109,18 @@ export async function defaultFindRunningServer(ccsDir: string): Promise { [ 'Options:', [ + ['--port ', 'Run the server on this port (persists; later launches keep the same port)'], ['--help, -h', 'Show this help message'], ['--version', 'Show CLI and installed app versions'], ], @@ -44,6 +45,7 @@ export async function showHelp(): Promise { 'Examples:', [ ['ccs bar', 'Start the server detached and open CCS Bar'], + ['ccs bar --port 3999', 'Start (or move) the server on port 3999 instead of 3000'], ['ccs bar stop', 'Stop the detached CCS Bar server'], ['ccs bar status', 'Show server running state and PID'], ['ccs bar install', 'Download and install CCS Bar, then prompt to launch'], diff --git a/src/commands/bar/index.ts b/src/commands/bar/index.ts index f12d8cd4..35cd6360 100644 --- a/src/commands/bar/index.ts +++ b/src/commands/bar/index.ts @@ -10,6 +10,7 @@ */ import { hasAnyFlag } from '../arg-extractor'; +import { validatePortArgs } from './port-arg'; export async function handleBarCommand(args: string[]): Promise { const subcommand = args[0]; @@ -56,9 +57,18 @@ export async function handleBarCommand(args: string[]): Promise { }, }; - // Bare `ccs bar` → launch - if (!subcommand || subcommand === 'launch') { - await commandHandlers.launch(subcommand ? args.slice(1) : []); + // Bare `ccs bar` → launch. Bare flags (e.g. `ccs bar --port 3999`) also go to + // launch with the full arg list preserved (--help/--version were handled above). + if (!subcommand || subcommand === 'launch' || subcommand.startsWith('-')) { + const launchArgs = subcommand === 'launch' ? args.slice(1) : args; + const argError = validatePortArgs(launchArgs); + if (argError !== null) { + console.error(`[X] ${argError}`); + console.error('[i] Usage: ccs bar [--port N]'); + process.exitCode = 1; + return; + } + await commandHandlers.launch(launchArgs); return; } diff --git a/src/commands/bar/launch-descriptor.ts b/src/commands/bar/launch-descriptor.ts index 71e3753e..64d4eefa 100644 --- a/src/commands/bar/launch-descriptor.ts +++ b/src/commands/bar/launch-descriptor.ts @@ -27,6 +27,8 @@ export interface LaunchDescriptorOptions { runtime?: string; home?: string; ccsHome?: string; + /** Server port; recorded in args so the Swift app self-starts on the same port. */ + port?: number; } export function getLaunchShimPath(home: string = os.homedir()): string { @@ -84,7 +86,12 @@ export function createBarLaunchDescriptor(options: LaunchDescriptorOptions = {}) return { schema: LAUNCH_JSON_SCHEMA, runtime: options.runtime ?? process.execPath, - args: [entrypoint, 'bar', 'serve'], + args: [ + entrypoint, + 'bar', + 'serve', + ...(options.port !== undefined ? ['--port', String(options.port)] : []), + ], home, ...(ccsHome ? { ccsHome } : {}), }; diff --git a/src/commands/bar/launch-subcommand.ts b/src/commands/bar/launch-subcommand.ts index 2d1f1897..4952cd39 100644 --- a/src/commands/bar/launch-subcommand.ts +++ b/src/commands/bar/launch-subcommand.ts @@ -7,7 +7,11 @@ * Detached model (replaces the old in-process model): * 1. Probe candidate ports (bar.json port first, then 3000/3001/3002/8000/8080). * 2. If a live server is found → reuse it, write bar.json, open app, return. - * 3. Else → refresh launch.json, getPort to pick a free port, spawn + * With an explicit --port that differs from the running server's port, + * stop that server first and fall through to a fresh start instead. + * 3. Else → pick a port (--port exactly when given; otherwise bar.json's + * recorded port first, then the default candidates), refresh launch.json + * (including --port so the Swift app self-starts on the same port), spawn * `ccs bar serve --port N` detached with stdio → serve.log, poll * /api/bar/summary until 200 (timeout ~10 s), write bar.json, open app, * return. The CLI process exits; the server continues as a detached child. @@ -31,11 +35,13 @@ import { import { getBarDir, getBarJsonPath, getLaunchJsonPath, getServeLogPath } from './bar-paths'; import type { LaunchJson } from './bar-paths'; import { createBarLaunchDescriptor } from './launch-descriptor'; +import { parsePortFlag, validatePortArgs } from './port-arg'; import { defaultFindRunningServer as _defaultFindRunningServer, resolveBarPort as _resolveBarPort, } from './bar-server-probe'; import type { DashboardInfo as _DashboardInfo } from './bar-server-probe'; +import { stopDetachedBarServer } from './bar-process-control'; const BAR_PROBE_TIMEOUT_MS = 1500; const MAX_BAR_PROBE_RESPONSE_BYTES = 8192; @@ -83,10 +89,21 @@ export interface LaunchDeps { * Returns the live baseUrl on success, throws on timeout. */ waitForServerLive: (baseUrl: string) => Promise; + /** + * Build the launch.json descriptor (includes the chosen --port so the Swift + * app self-starts the server on the same port). + */ + createLaunchDescriptor: (opts?: { port?: number }) => LaunchJson; /** * Write launch.json so the Swift app can spawn the server independently. */ writeLaunchDescriptor: (jsonPath: string, descriptor: LaunchJson) => void; + /** + * Stop the detached CCS Bar server recorded in server.pid and wait briefly + * for the port to free. Used when an explicit --port differs from the port + * the running server occupies. + */ + stopDetachedServer: (ccsDir: string) => Promise; /** Open the installed .app bundle. Throws if the app is not found. */ openApp: (appPath: string) => Promise; /** Returns path to ~/.ccs (respects CCS_HOME for test isolation). */ @@ -169,15 +186,11 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise { settled = true; clearTimeout(absoluteDeadline); socket.destroy(); - if (statusCode === 200) { - const echoMatch = headerSection.match( - new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i') - ); - const proof = echoMatch ? echoMatch[1].trim() : ''; - resolve({ statusCode, tokenMatched: isMatchingBarAuthProof(token, nonce, proof) }); - return; - } - resolve({ statusCode, tokenMatched: false }); + const echoMatch = headerSection.match( + new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i') + ); + const proof = echoMatch ? echoMatch[1].trim() : ''; + resolve({ statusCode, tokenMatched: isMatchingBarAuthProof(token, nonce, proof) }); }; const socket = net.connect( { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) }, @@ -199,7 +212,7 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise { const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); if (statusMatch) { const code = Number(statusMatch[1]); - if (code !== 200) { + if (code !== 200 && code !== 401 && code !== 403) { finish(code, rawResponse); return; } @@ -221,7 +234,7 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise { const { statusCode, tokenMatched } = await probe(); if (statusCode === 200 && tokenMatched) return; - if (statusCode !== null && isAuthRequiredStatus(statusCode)) { + if (statusCode !== null && isAuthRequiredStatus(statusCode) && tokenMatched) { throw new BarServerAuthRequiredError(baseUrl, statusCode); } @@ -258,13 +271,21 @@ export async function handleBarLaunch( _args: string[], deps: Partial = {} ): Promise { + const argError = validatePortArgs(_args); + if (argError !== null) { + console.error(`[X] ${argError}`); + process.exitCode = 1; + return; + } const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); const openApp = deps.openApp ?? defaultOpenApp; const appInstallPath = deps.appInstallPath ?? DEFAULT_APP_INSTALL_PATH; const getPortFn = deps.getPort ?? defaultGetPort; const spawnDetachedServer = deps.spawnDetachedServer ?? defaultSpawnDetachedServer; const waitForServerLive = deps.waitForServerLive ?? defaultWaitForServerLive; + const createLaunchDescriptor = deps.createLaunchDescriptor ?? createBarLaunchDescriptor; const writeLaunchDescriptor = deps.writeLaunchDescriptor ?? defaultWriteLaunchDescriptor; + const stopDetachedServer = deps.stopDetachedServer ?? stopDetachedBarServer; // Wire findRunningServer after ccsDir is resolved. const findRunningServer = deps.findRunningServer ?? (() => _defaultFindRunningServer(ccsDir)); @@ -272,6 +293,16 @@ export async function handleBarLaunch( const barJsonPath = getBarJsonPath(ccsDir); const launchJsonPath = getLaunchJsonPath(ccsDir); + // 0. Parse --port. A present-but-invalid value is a hard error (silently + // launching on a different port than the user asked for is worse). + const portFlag = parsePortFlag(_args); + if (portFlag.present && portFlag.port === null) { + console.error('[X] Invalid --port value. Use a number between 1 and 65535.'); + process.exitCode = 1; + return; + } + const requestedPort = portFlag.port; + // 1. Probe for an already-running server. let running: DashboardInfo | null = null; try { @@ -280,6 +311,9 @@ export async function handleBarLaunch( /* any probe error counts as null */ } + let movingFrom: DashboardInfo | null = null; + let port: number | null = null; + if (running !== null) { if (running.authRequired) { console.error( @@ -291,59 +325,127 @@ export async function handleBarLaunch( return; } - // Reuse the live server — write bar.json and open the app. - const barJson: BarDiscoveryJson = { - baseUrl: running.baseUrl, - port: running.port, - authMode: 'loopback', - }; - try { - fs.mkdirSync(ccsDir, { recursive: true }); - fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2)); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`[X] Failed to write bar.json: ${msg}`); + if (requestedPort === null || running.port === requestedPort) { + // Reuse the live server — write bar.json and open the app. + const barJson: BarDiscoveryJson = { + baseUrl: running.baseUrl, + port: running.port, + authMode: 'loopback', + }; + try { + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2)); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Failed to write bar.json: ${msg}`); + return; + } + console.log(`[OK] Reusing running CCS web-server at ${running.baseUrl}`); + console.log(`[i] Discovery file written: ${barJsonPath}`); + await _openAppWithFallback(appInstallPath, openApp); return; } - console.log(`[OK] Reusing running CCS web-server at ${running.baseUrl}`); - console.log(`[i] Discovery file written: ${barJsonPath}`); - await _openAppWithFallback(appInstallPath, openApp); - return; + + // Explicit --port that differs from the running server: preflight the + // destination before disrupting the healthy current service. + console.log( + `[i] CCS Bar server is running on port ${running.port}; moving to port ${requestedPort}...` + ); + if (requestedPort === null) return; + try { + const availablePort = await getPortFn({ port: [requestedPort], host: '127.0.0.1' }); + if (availablePort !== requestedPort) { + console.error(`[X] Port ${requestedPort} is already in use by another process.`); + console.error('[i] The existing CCS Bar server was left running.'); + process.exitCode = 1; + return; + } + port = requestedPort; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not preflight port ${requestedPort}: ${msg}`); + console.error('[i] The existing CCS Bar server was left running.'); + process.exitCode = 1; + return; + } + try { + await stopDetachedServer(ccsDir); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not safely stop the running server: ${msg}`); + console.error('[i] Recovery state was preserved; resolve the stop error, then retry.'); + process.exitCode = 1; + return; + } + movingFrom = running; + // Fall through to the fresh-start path below. } - // 2. No live server — pick a port, write/refresh launch.json, spawn detached. + // 2. No live server (or moving ports) — pick a port, write/refresh + // launch.json, spawn detached. - // 2a. Pick a free port. - let port: number; + // 2a. Pick a free port. An explicit --port must be honored exactly; without + // it, the port recorded in bar.json is preferred so the server keeps + // coming back on the port the user last chose (sticky port). try { - port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' }); + if (port !== null) { + // Destination was already preflighted before stopping the prior server. + } else if (requestedPort !== null) { + const got = await getPortFn({ port: [requestedPort], host: '127.0.0.1' }); + if (got !== requestedPort) { + console.error(`[X] Port ${requestedPort} is already in use by another process.`); + console.error('[i] Choose a different port or free it, then retry.'); + process.exitCode = 1; + return; + } + port = requestedPort; + } else { + const stickyPort = _resolveBarPort(ccsDir); + const base = [3000, 3001, 3002, 8000, 8080]; + const candidates = + stickyPort !== null ? [stickyPort, ...base.filter((p) => p !== stickyPort)] : base; + port = await getPortFn({ port: candidates, host: '127.0.0.1' }); + } } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[X] Could not find a free port: ${msg}`); return; } - // 2b. Write/refresh launch.json so the Swift app can self-start next time. - try { - const launchDescriptor = createBarLaunchDescriptor(); - writeLaunchDescriptor(launchJsonPath, launchDescriptor); - } catch (err) { - // Non-fatal — the Swift app falls back to resolving `ccs` via PATH. - const msg = err instanceof Error ? err.message : String(err); - console.error(`[!] Could not write launch.json: ${msg}`); + if (port === null) { + console.error('[X] Could not resolve a valid CCS Bar port.'); + process.exitCode = 1; + return; } + const selectedPort = port; - // 2c. Spawn the detached server. + const rollbackPriorServer = async (): Promise => { + if (movingFrom === null) return; + try { + spawnDetachedServer(movingFrom.port, serveLogPath); + await waitForServerLive(movingFrom.baseUrl); + console.log(`[OK] Restored CCS Bar server at ${movingFrom.baseUrl}.`); + } catch (rollbackErr) { + const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); + console.error(`[X] Failed to restore CCS Bar at ${movingFrom.baseUrl}: ${message}`); + console.error('[i] Existing discovery and launch state was preserved for manual recovery.'); + } + }; + + // 2b. Spawn the detached server. launch.json is not replaced until the new + // server is proven live, preserving the prior recovery path during a move. const serveLogPath = getServeLogPath(ccsDir); - const baseUrl = `http://127.0.0.1:${port}`; + const baseUrl = `http://127.0.0.1:${selectedPort}`; let spawnedChild: ChildProcess | void; try { fs.mkdirSync(getBarDir(ccsDir), { recursive: true }); - spawnedChild = spawnDetachedServer(port, serveLogPath); + spawnedChild = spawnDetachedServer(selectedPort, serveLogPath); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[X] Could not start CCS web-server: ${msg}`); - console.error('[i] Run `ccs config` to start the dashboard manually.'); + await rollbackPriorServer(); + if (movingFrom === null) console.error('[i] Run `ccs config` to start the dashboard manually.'); + process.exitCode = 1; return; } @@ -361,18 +463,30 @@ export async function handleBarLaunch( console.error( '[i] Disable dashboard authentication for CCS Bar or start the dashboard manually.' ); - return; + } else { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not connect to CCS web-server: ${msg}`); + console.error(`[i] Check logs at ${serveLogPath}`); } - const msg = err instanceof Error ? err.message : String(err); - console.error(`[X] Could not connect to CCS web-server: ${msg}`); - console.error(`[i] Check logs at ${serveLogPath}`); + spawnedChild?.kill(); + await rollbackPriorServer(); + process.exitCode = 1; return; } + // 2d. Persist the proven-good recovery descriptor. + try { + const launchDescriptor = createLaunchDescriptor({ port: selectedPort }); + writeLaunchDescriptor(launchJsonPath, launchDescriptor); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[!] Could not write launch.json: ${msg}`); + } + // 2e. Write bar.json. const barJson: BarDiscoveryJson = { baseUrl, - port, + port: selectedPort, authMode: 'loopback', }; try { diff --git a/src/commands/bar/port-arg.ts b/src/commands/bar/port-arg.ts new file mode 100644 index 00000000..f52f6443 --- /dev/null +++ b/src/commands/bar/port-arg.ts @@ -0,0 +1,40 @@ +/** + * Shared `--port N` flag parsing for the `ccs bar` command family. + * + * `present` distinguishes "flag not given" from "flag given with a bad value" + * so launch can reject typos loudly instead of silently falling back to the + * default port list. + */ + +export interface PortFlag { + /** True when `--port` appears in args at all. */ + present: boolean; + /** The parsed port (1-65535), or null when absent or invalid. */ + port: number | null; +} + +export function validatePortArgs(args: string[]): string | null { + let foundPort = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg !== '--port') return `Unknown option: ${arg}`; + if (foundPort) return 'Duplicate option: --port'; + foundPort = true; + const raw = args[index + 1]; + if (raw === undefined) return 'Missing value for --port'; + index += 1; + } + return null; +} + +export function parsePortFlag(args: string[]): PortFlag { + const idx = args.indexOf('--port'); + if (idx === -1) return { present: false, port: null }; + const raw = args[idx + 1]; + if (raw === undefined || !/^[1-9]\d{0,4}$/.test(raw)) { + return { present: true, port: null }; + } + const n = Number(raw); + const valid = Number.isSafeInteger(n) && n <= 65535; + return { present: true, port: valid ? n : null }; +} diff --git a/src/commands/bar/serve-subcommand.ts b/src/commands/bar/serve-subcommand.ts index e0769baa..ee47f681 100644 --- a/src/commands/bar/serve-subcommand.ts +++ b/src/commands/bar/serve-subcommand.ts @@ -16,9 +16,16 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../../config/config-loader-facade'; import { getBarJsonPath, getServerPidPath } from './bar-paths'; -import { defaultFindRunningServer } from './bar-server-probe'; +import { defaultFindRunningServer, resolveBarPort } from './bar-server-probe'; +import { parsePortFlag, validatePortArgs } from './port-arg'; import type { DashboardInfo } from './bar-server-probe'; import type { BarDiscoveryJson } from './launch-subcommand'; +import { + getProcessBirthIdentity, + removeBarServerProcessRecordIfOwned, + serializeBarServerProcessRecord, +} from './bar-process-control'; +import type { BarServerProcessRecord } from './bar-process-control'; // --------------------------------------------------------------------------- // Types — injectable deps for testability @@ -44,10 +51,14 @@ export interface ServeDeps { writeFile: (filePath: string, content: string) => void; /** Remove a file if it exists (for cleanup on exit). */ removeFile: (filePath: string) => void; + /** Remove server.pid only if it still identifies this process. */ + removeProcessRecordIfOwned: (filePath: string, record: BarServerProcessRecord) => void; /** Register process signal handlers. */ onSignal: (signal: 'SIGINT' | 'SIGTERM', handler: () => void) => void; /** Exit the process. */ exit: (code: number) => never; + /** Read a stable OS birth marker so later stop commands cannot signal a reused PID. */ + getProcessBirthIdentity: (pid: number) => string | null; } // --------------------------------------------------------------------------- @@ -73,14 +84,6 @@ function defaultWriteFile(filePath: string, content: string): void { fs.writeFileSync(filePath, content); } -function defaultRemoveFile(filePath: string): void { - try { - fs.unlinkSync(filePath); - } catch { - /* ignore — file may already be gone */ - } -} - function defaultOnSignal(signal: 'SIGINT' | 'SIGTERM', handler: () => void): void { process.on(signal, handler); } @@ -93,32 +96,27 @@ function defaultGetCcsDir(): string { return getCcsDir(); } -// --------------------------------------------------------------------------- -// Argument parsing helpers -// --------------------------------------------------------------------------- - -function parsePortArg(args: string[]): number | null { - const idx = args.indexOf('--port'); - if (idx !== -1 && idx + 1 < args.length) { - const n = parseInt(args[idx + 1], 10); - return Number.isFinite(n) && n > 0 && n < 65536 ? n : null; - } - return null; -} - // --------------------------------------------------------------------------- // Implementation // --------------------------------------------------------------------------- export async function handleBarServe(args: string[], deps: Partial = {}): Promise { + const argError = validatePortArgs(args); + if (argError !== null) { + console.error(`[X] ${argError}`); + (deps.exit ?? defaultExit)(1); + return; + } const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); const findRunningServer = deps.findRunningServer ?? (() => defaultFindRunningServer(ccsDir)); const startServerFn = deps.startServer ?? defaultStartServer; const getPortFn = deps.getPort ?? defaultGetPort; const writeFile = deps.writeFile ?? defaultWriteFile; - const removeFile = deps.removeFile ?? defaultRemoveFile; + const removeProcessRecordIfOwned = + deps.removeProcessRecordIfOwned ?? removeBarServerProcessRecordIfOwned; const onSignal = deps.onSignal ?? defaultOnSignal; const exit = deps.exit ?? defaultExit; + const readProcessBirthIdentity = deps.getProcessBirthIdentity ?? getProcessBirthIdentity; const barJsonPath = getBarJsonPath(ccsDir); const serverPidPath = getServerPidPath(ccsDir); @@ -151,12 +149,24 @@ export async function handleBarServe(args: string[], deps: Partial = // 2. No live server found — start one. // Honor --port N from the launcher (it pre-selected via getPort to avoid races). - const requestedPort = parsePortArg(args); + // Without it, prefer the port recorded in bar.json so the server keeps coming + // back on the port the user last chose (sticky port). + const portFlag = parsePortFlag(args); + if (portFlag.present && portFlag.port === null) { + console.error('[X] Invalid --port value. Use an integer between 1 and 65535.'); + exit(1); + return; + } + const requestedPort = portFlag.port; let port: number; if (requestedPort !== null) { port = requestedPort; } else { - port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' }); + const stickyPort = resolveBarPort(ccsDir); + const base = [3000, 3001, 3002, 8000, 8080]; + const candidates = + stickyPort !== null ? [stickyPort, ...base.filter((p) => p !== stickyPort)] : base; + port = await getPortFn({ port: candidates, host: '127.0.0.1' }); } // TypeScript cannot infer that exit(1) is `never` when it is injected as a dep, @@ -171,26 +181,43 @@ export async function handleBarServe(args: string[], deps: Partial = exit(1); } - // 3. Write bar.json and server.pid. + // 3. Publish the owned process record before discovery. Stop cleanup uses + // server.pid as the ownership barrier, so discovery must never appear first. const barJson: BarDiscoveryJson = { baseUrl: dashboardInfo.baseUrl, port: dashboardInfo.port, authMode: 'loopback', }; + const birthIdentity = readProcessBirthIdentity(process.pid); + if (birthIdentity === null) { + console.error('[X] Failed to record CCS Bar process identity; stopping unmanaged server.'); + exit(1); + return; + } + + const processRecord = { pid: process.pid, birthIdentity }; try { - writeFile(barJsonPath, JSON.stringify(barJson, null, 2)); + writeFile(serverPidPath, serializeBarServerProcessRecord(processRecord)); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - console.error(`[X] Failed to write bar.json: ${msg}`); + console.error(`[X] Failed to write server.pid: ${msg}`); exit(1); + return; } try { - writeFile(serverPidPath, String(process.pid)); + writeFile(barJsonPath, JSON.stringify(barJson, null, 2)); } catch (err) { - // Non-fatal — stop/status will just degrade gracefully. + try { + removeProcessRecordIfOwned(serverPidPath, processRecord); + } catch (cleanupErr) { + const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr); + console.error(`[!] Failed to roll back server.pid: ${cleanupMessage}`); + } const msg = err instanceof Error ? err.message : String(err); - console.error(`[!] Failed to write server.pid: ${msg}`); + console.error(`[X] Failed to write bar.json: ${msg}`); + exit(1); + return; } console.log(`[OK] CCS Bar server started at ${dashboardInfo.baseUrl}`); @@ -198,7 +225,7 @@ export async function handleBarServe(args: string[], deps: Partial = // 4. Clean shutdown on SIGINT / SIGTERM. const shutdown = (): void => { - removeFile(serverPidPath); + removeProcessRecordIfOwned(serverPidPath, processRecord); // bar.json is intentionally left in place on clean shutdown so // the Swift app self-heal poll can detect the server is gone via // the liveness check, not a stale discovery file. diff --git a/src/commands/bar/status-subcommand.ts b/src/commands/bar/status-subcommand.ts index 24e25602..4c21bfc4 100644 --- a/src/commands/bar/status-subcommand.ts +++ b/src/commands/bar/status-subcommand.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import { getCcsDir } from '../../config/config-loader-facade'; import { getBarJsonPath, getServerPidPath } from './bar-paths'; +import { parseBarServerProcessRecord, parseLegacyServerPid } from './bar-process-control'; // --------------------------------------------------------------------------- // Types — injectable deps @@ -115,11 +116,21 @@ export async function handleBarStatus( return; } - const pid = parseInt(pidRaw, 10); - if (!Number.isFinite(pid) || pid <= 0) { + const processRecord = parseBarServerProcessRecord(pidRaw); + if (processRecord === null) { + const legacyPid = parseLegacyServerPid(pidRaw); + if (legacyPid !== null) { + console.log( + `[!] CCS Bar server: legacy server.pid has unverified PID ${legacyPid}; status cannot safely identify it.` + ); + console.log(`[i] Verify manually with: ps -p ${legacyPid} -o command=`); + console.log('[i] If it is CCS Bar, stop it manually, remove server.pid, then restart.'); + return; + } console.log(`[!] CCS Bar server: server.pid is invalid ("${pidRaw}")`); return; } + const { pid } = processRecord; // 2. Check process liveness. const alive = isProcessAlive(pid); diff --git a/src/commands/bar/stop-subcommand.ts b/src/commands/bar/stop-subcommand.ts index edca8033..71f558df 100644 --- a/src/commands/bar/stop-subcommand.ts +++ b/src/commands/bar/stop-subcommand.ts @@ -8,6 +8,15 @@ import * as fs from 'fs'; import { getCcsDir } from '../../config/config-loader-facade'; import { getBarJsonPath, getServerPidPath } from './bar-paths'; +import { + getProcessBirthIdentity, + parseLegacyServerPid, + removeBarDiscoveryIfNoProcess, + stopBarServerProcessFile, + stopRecordedBarServer, + waitForProcessExit, +} from './bar-process-control'; +import type { ClaimedBarServerStopOutcome } from './bar-process-control'; // --------------------------------------------------------------------------- // Types — injectable deps @@ -26,6 +35,9 @@ export interface StopDeps { * Throws if the signal cannot be delivered (e.g. ESRCH — no such process). */ killProcess: (pid: number, signal: 'SIGTERM') => void; + getProcessBirthIdentity: (pid: number) => string | null; + waitForProcessExit: typeof waitForProcessExit; + stopProcessFile: (pidPath: string) => Promise; /** Remove a file, ignoring errors if absent. */ removeFile: (filePath: string) => void; } @@ -67,43 +79,92 @@ export async function handleBarStop(_args: string[], deps: Partial = { const readPidFile = deps.readPidFile ?? defaultReadPidFile; const killProcess = deps.killProcess ?? defaultKillProcess; const removeFile = deps.removeFile ?? defaultRemoveFile; + const readProcessBirthIdentity = deps.getProcessBirthIdentity ?? getProcessBirthIdentity; + const waitForExit = deps.waitForProcessExit ?? waitForProcessExit; const pidPath = getServerPidPath(ccsDir); const barJsonPath = getBarJsonPath(ccsDir); - // 1. Read the PID file. - const pidRaw = readPidFile(pidPath); - if (pidRaw === null) { - console.log('[i] CCS Bar server is not running (no server.pid found).'); - return; - } - - const pid = parseInt(pidRaw, 10); - if (!Number.isFinite(pid) || pid <= 0) { - console.error(`[X] server.pid contains an invalid PID: "${pidRaw}"`); - // Clean up the corrupted file so subsequent runs start fresh. - removeFile(pidPath); - return; - } - - // 2. Send SIGTERM. - try { - killProcess(pid, 'SIGTERM'); - console.log(`[OK] Sent SIGTERM to CCS Bar server (PID ${pid}).`); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ESRCH') { - // Process no longer exists — stale PID file, clean up silently. - console.log(`[i] Server PID ${pid} is no longer running. Cleaning up stale files.`); - } else { - const msg = err instanceof Error ? err.message : String(err); - console.error(`[X] Failed to stop server (PID ${pid}): ${msg}`); - // Still remove the pid file so the user is not blocked. + let outcome: ClaimedBarServerStopOutcome; + if (deps.stopProcessFile !== undefined || deps.readPidFile === undefined) { + const stopProcessFile = + deps.stopProcessFile ?? + ((filePath: string) => + stopBarServerProcessFile(filePath, { + getProcessBirthIdentity: readProcessBirthIdentity, + killProcess, + waitForProcessExit: waitForExit, + })); + outcome = await stopProcessFile(pidPath); + if (outcome.result === 'no-record') { + console.log('[i] CCS Bar server is not running (no server.pid found).'); + return; } + } else { + // Injected record reads remain available for isolated unit tests. Production + // always uses the atomic file claim above. + const pidRaw = readPidFile(pidPath); + if (pidRaw === null) { + console.log('[i] CCS Bar server is not running (no server.pid found).'); + return; + } + const recordedOutcome = await stopRecordedBarServer(pidRaw, { + getProcessBirthIdentity: readProcessBirthIdentity, + killProcess, + waitForProcessExit: waitForExit, + }); + outcome = { + ...recordedOutcome, + legacyPid: + recordedOutcome.result === 'legacy-record' + ? (parseLegacyServerPid(pidRaw) ?? undefined) + : undefined, + }; + } + const pid = outcome.record?.pid; + + if (outcome.result === 'stopped' || outcome.result === 'stale') { + if (outcome.result === 'stopped') { + console.log(`[OK] CCS Bar server stopped (PID ${pid}).`); + } else { + console.log(`[i] Server PID ${pid} is no longer running. Cleaning up stale files.`); + } + if (deps.readPidFile !== undefined) { + removeFile(pidPath); + removeFile(barJsonPath); + console.log('[i] Removed server.pid and bar.json.'); + } else { + const removedDiscovery = removeBarDiscoveryIfNoProcess(barJsonPath, pidPath); + if (removedDiscovery) console.log('[i] Removed server.pid and bar.json.'); + else + console.log('[i] A replacement server record appeared; its recovery state was preserved.'); + } + return; } - // 3. Remove pid + bar.json regardless of kill result. - removeFile(pidPath); - removeFile(barJsonPath); - console.log('[i] Removed server.pid and bar.json.'); + if (outcome.result === 'legacy-record') { + const legacyPid = outcome.legacyPid; + console.error( + `[X] Legacy server.pid records PID ${legacyPid} without process identity; no signal was sent.` + ); + console.error(`[i] Verify manually with: ps -p ${legacyPid} -o command=`); + console.error( + `[i] If it is CCS Bar, stop it manually, then remove ${outcome.recoveryPath ?? pidPath} and ${barJsonPath}.` + ); + process.exitCode = 1; + return; + } + + const reason = + outcome.result === 'invalid-record' + ? 'server.pid does not contain a verified process record' + : outcome.result === 'identity-mismatch' + ? `PID ${pid} belongs to a different process` + : outcome.result === 'permission-denied' + ? `permission denied while signaling PID ${pid}` + : outcome.result === 'timeout' + ? `PID ${pid} did not exit within 3 seconds` + : `failed to signal PID ${pid}: ${outcome.error?.message ?? 'unknown error'}`; + console.error(`[X] Refusing to remove CCS Bar recovery state: ${reason}.`); + process.exitCode = 1; } diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 10449eb9..56505589 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -181,6 +181,7 @@ interface CatalogJsonModel { name: string; tier?: 'free' | 'pro' | 'ultra'; description?: string; + contextWindow?: number; deprecated?: boolean; deprecationReason?: string; broken?: boolean; @@ -206,6 +207,7 @@ export function handleCatalogJson(): void { const entry: CatalogJsonModel = { id: m.id, name: m.name }; if (m.tier !== undefined) entry.tier = m.tier; if (m.description !== undefined) entry.description = m.description; + if (m.contextWindow !== undefined) entry.contextWindow = m.contextWindow; if (m.deprecated !== undefined) entry.deprecated = m.deprecated; if (m.deprecationReason !== undefined) entry.deprecationReason = m.deprecationReason; if (m.broken !== undefined) entry.broken = m.broken; diff --git a/src/commands/cliproxy/quota-subcommand/sections/claude.ts b/src/commands/cliproxy/quota-subcommand/sections/claude.ts index 3a80c74e..88b19f8e 100644 --- a/src/commands/cliproxy/quota-subcommand/sections/claude.ts +++ b/src/commands/cliproxy/quota-subcommand/sections/claude.ts @@ -34,7 +34,9 @@ export function displayClaudeQuotaSection( const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; if (!quota.success) { - console.log(` ${fail(accountLabel)}${defaultMark}`); + const statusIcon = + quota.errorCode === 'usage_probe_unavailable' ? warn(accountLabel) : fail(accountLabel); + console.log(` ${statusIcon}${defaultMark}`); displayQuotaFailure(quota); console.log(''); continue; diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index 60e23b83..4f31130f 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -283,7 +283,7 @@ async function runSetupWizard(force: boolean = false): Promise { return; } - console.log('Welcome to CCS (Claude Code Switch)!'); + console.log('Welcome to CCS (Claude Codex Switch)!'); console.log('This wizard will help you configure CCS for first-time use.'); console.log(''); diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index e355fbc8..b31439c1 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -17,7 +17,7 @@ import { getCcsDir } from '../config/config-loader-facade'; */ export async function handleVersionCommand(): Promise { await initUI(); - console.log(header(`CCS (Claude Code Switch) v${getVersion()}`)); + console.log(header(`CCS (Claude Codex Switch) v${getVersion()}`)); console.log(''); console.log(subheader('Installation:')); diff --git a/src/config/schemas/__tests__/cliproxy-retry.test.ts b/src/config/schemas/__tests__/cliproxy-retry.test.ts new file mode 100644 index 00000000..6d89baac --- /dev/null +++ b/src/config/schemas/__tests__/cliproxy-retry.test.ts @@ -0,0 +1,48 @@ +/** + * Tests: CLIProxy retry config schema bounds (request_retry, max_retry_interval). + * + * Verifies the opt-in contract: both fields accept only non-negative integers. + */ + +import { describe, it, expect } from 'bun:test'; +import { + isValidCliproxyRetryValue, + CLIPROXY_RETRY_MIN_VALUE, + CLIPROXY_RETRY_RANGE_MESSAGE, +} from '../cliproxy'; + +describe('isValidCliproxyRetryValue', () => { + it('accepts 0 (the disabled default)', () => { + expect(isValidCliproxyRetryValue(0)).toBe(true); + }); + + it('accepts positive integers', () => { + expect(isValidCliproxyRetryValue(3)).toBe(true); + expect(isValidCliproxyRetryValue(30)).toBe(true); + }); + + it('rejects negative numbers', () => { + expect(isValidCliproxyRetryValue(-1)).toBe(false); + }); + + it('rejects non-integer numbers', () => { + expect(isValidCliproxyRetryValue(1.5)).toBe(false); + }); + + it('rejects non-number types', () => { + expect(isValidCliproxyRetryValue('3')).toBe(false); + expect(isValidCliproxyRetryValue(undefined)).toBe(false); + expect(isValidCliproxyRetryValue(null)).toBe(false); + expect(isValidCliproxyRetryValue(true)).toBe(false); + }); + + it('rejects NaN and Infinity', () => { + expect(isValidCliproxyRetryValue(Number.NaN)).toBe(false); + expect(isValidCliproxyRetryValue(Number.POSITIVE_INFINITY)).toBe(false); + }); + + it('exposes the accepted-range constants used in recoverable error messages', () => { + expect(CLIPROXY_RETRY_MIN_VALUE).toBe(0); + expect(CLIPROXY_RETRY_RANGE_MESSAGE).toContain('0'); + }); +}); diff --git a/src/config/schemas/__tests__/cliproxy-user-routing.test.ts b/src/config/schemas/__tests__/cliproxy-user-routing.test.ts new file mode 100644 index 00000000..935ec5d7 --- /dev/null +++ b/src/config/schemas/__tests__/cliproxy-user-routing.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test'; +import * as yaml from 'js-yaml'; +import { mergeWithDefaults } from '../../loader/defaults-merger'; +import { generateYamlWithComments } from '../../loader/yaml-serializer'; +import type { UnifiedConfig } from '../unified-config'; + +describe('CLIProxy user routing schema', () => { + it('survives default merging and YAML serialization', () => { + const configuredRouting = { + oauth_model_alias: { + codex: [ + { + name: 'gpt-5.6-sol', + alias: 'gpt-5.6-sol-fast', + fork: true, + }, + ], + }, + payload: { + override: [ + { + models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }], + params: { service_tier: 'priority' }, + }, + ], + }, + }; + const merged = mergeWithDefaults({ + version: 14, + cliproxy: configuredRouting, + } as Partial); + const serialized = yaml.load(generateYamlWithComments(merged)) as UnifiedConfig; + + expect(serialized.cliproxy.oauth_model_alias).toEqual(configuredRouting.oauth_model_alias); + expect(serialized.cliproxy.payload).toEqual(configuredRouting.payload); + }); +}); diff --git a/src/config/schemas/__tests__/schemas-reexport.test.ts b/src/config/schemas/__tests__/schemas-reexport.test.ts index 5bdda5d9..41f07a3d 100644 --- a/src/config/schemas/__tests__/schemas-reexport.test.ts +++ b/src/config/schemas/__tests__/schemas-reexport.test.ts @@ -33,6 +33,13 @@ import type { CLIProxyLoggingConfig, CLIProxySafetyConfig, CLIProxyRoutingConfig, + CLIProxyRetryConfig, + CLIProxyPoolRoutingConfig, + CLIProxyOAuthModelAliasEntry, + CLIProxyOAuthModelAliasConfig, + CLIProxyPayloadModelSelector, + CLIProxyPayloadOverrideRule, + CLIProxyPayloadConfig, CLIProxyConfig, AutoQuotaConfig, RuntimeMonitorConfig, @@ -77,12 +84,40 @@ import type { } from '../../unified-config-types'; describe('config schemas backward compatibility', () => { + it('re-exports structured CLIProxy user routing types', () => { + const alias: CLIProxyOAuthModelAliasEntry = { + name: 'gpt-5.6-sol', + alias: 'gpt-5.6-sol-fast', + }; + const aliases: CLIProxyOAuthModelAliasConfig = { codex: [alias] }; + const selector: CLIProxyPayloadModelSelector = { + name: alias.alias, + protocol: 'codex', + 'from-protocol': 'openai', + }; + const rule: CLIProxyPayloadOverrideRule = { + models: [selector], + params: { service_tier: 'priority' }, + headers: { 'x-tenant': 'alpha' }, + }; + const payload: CLIProxyPayloadConfig = { override: [rule] }; + const retry: CLIProxyRetryConfig = { request_retry: 0 }; + const pool: CLIProxyPoolRoutingConfig = { enabled: false }; + + expect({ aliases, payload, retry, pool }).toEqual({ + aliases: { codex: [alias] }, + payload: { override: [rule] }, + retry: { request_retry: 0 }, + pool: { enabled: false }, + }); + }); + // ------------------------------------------------------------------------- // Constants // ------------------------------------------------------------------------- it('re-exports UNIFIED_CONFIG_VERSION', () => { - expect(barrel.UNIFIED_CONFIG_VERSION).toBe(13); - expect(schemas.UNIFIED_CONFIG_VERSION).toBe(13); + expect(barrel.UNIFIED_CONFIG_VERSION).toBe(14); + expect(schemas.UNIFIED_CONFIG_VERSION).toBe(14); }); it('re-exports CLIPROXY_SUPPORTED_PROVIDERS', () => { @@ -129,7 +164,7 @@ describe('config schemas backward compatibility', () => { expect(typeof schemas.createEmptyUnifiedConfig).toBe('function'); const config = barrel.createEmptyUnifiedConfig(); - expect(config.version).toBe(13); + expect(config.version).toBe(14); expect(config.accounts).toEqual({}); expect(config.profiles).toEqual({}); expect(config.cliproxy).toBeDefined(); diff --git a/src/config/schemas/auth.ts b/src/config/schemas/auth.ts index 2b4eb944..1b9287e7 100644 --- a/src/config/schemas/auth.ts +++ b/src/config/schemas/auth.ts @@ -27,9 +27,9 @@ export interface AccountConfig { context_group?: string; /** Shared continuity depth when context_mode='shared' */ continuity_mode?: 'standard' | 'deeper'; - /** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */ + /** Account-level shared resource behavior for plugins, commands, skills, agents, settings.json, and CLAUDE.md */ shared_resource_mode?: 'shared' | 'profile-local'; - /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ + /** Bare profile: no shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */ bare?: boolean; } diff --git a/src/config/schemas/cliproxy.ts b/src/config/schemas/cliproxy.ts index dfc50bd7..d3d1f6c7 100644 --- a/src/config/schemas/cliproxy.ts +++ b/src/config/schemas/cliproxy.ts @@ -122,6 +122,77 @@ export interface CLIProxyRoutingConfig { session_affinity_ttl?: string; } +/** + * CLIProxy request-retry configuration. + * Controls CLIProxy's own retry-on-transient-error behavior + * (403, 408, 500, 502, 503, 504). Defaults to disabled (0/0): retrying can + * burn quota on multi-account pools, so this is opt-in. + */ +export interface CLIProxyRetryConfig { + /** Number of times to retry a request on a transient error (integer >= 0, default: 0 = disabled) */ + request_retry?: number; + /** Maximum wait time in seconds for a cooled-down credential before retrying (integer >= 0, default: 0) */ + max_retry_interval?: number; +} + +/** One OAuth model alias exposed by CLIProxy to compatible clients. */ +export interface CLIProxyOAuthModelAliasEntry { + /** Upstream model ID used after alias resolution. */ + name: string; + /** Client-visible model ID accepted by CLIProxy. */ + alias: string; + /** Keep both the upstream name and alias visible when supported upstream. */ + fork?: boolean; +} + +/** Provider/protocol keyed OAuth model aliases (for example `codex`). */ +export type CLIProxyOAuthModelAliasConfig = Record; + +/** Model selector for a CLIProxy payload rule. */ +export interface CLIProxyPayloadModelSelector { + /** Client-visible model ID to match before OAuth alias resolution. */ + name: string; + /** Optional protocol boundary, such as `codex`. */ + protocol?: string; + /** Additional CLIProxy selector constraints retained across regeneration. */ + [constraint: string]: unknown; +} + +/** A scoped CLIProxy payload override rule. */ +export interface CLIProxyPayloadOverrideRule { + /** All client model/protocol selectors covered by this rule. */ + models: CLIProxyPayloadModelSelector[]; + /** Upstream request parameters added when a selector matches. */ + params: Record; + /** Additional CLIProxy match constraints retained across regeneration. */ + [constraint: string]: unknown; +} + +/** + * User-owned CLIProxy payload configuration. + * + * Unknown supported payload subsections are retained so CCS regeneration does + * not erase options introduced by newer CLIProxy releases. + */ +export interface CLIProxyPayloadConfig { + override?: CLIProxyPayloadOverrideRule[]; + [section: string]: unknown; +} + +/** Lower bound (inclusive) accepted for CLIProxy retry config fields. */ +export const CLIPROXY_RETRY_MIN_VALUE = 0; + +/** Human-readable accepted-range description for CLIProxy retry config fields. */ +export const CLIPROXY_RETRY_RANGE_MESSAGE = `must be a whole number ${CLIPROXY_RETRY_MIN_VALUE} or greater`; + +/** + * Validate a single CLIProxy retry config field value (request_retry or + * max_retry_interval). Both fields share the same bounds: non-negative integer. + */ +export function isValidCliproxyRetryValue(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= CLIPROXY_RETRY_MIN_VALUE; +} + /** * Pool routing configuration for multi-account CLIProxy rotation. * @@ -188,4 +259,10 @@ export interface CLIProxyConfig { routing?: CLIProxyRoutingConfig; /** Pool routing opt-in state and configuration */ pool_routing?: CLIProxyPoolRoutingConfig; + /** Request-retry behavior for transient upstream errors (opt-in, default: disabled) */ + retry?: CLIProxyRetryConfig; + /** User-defined OAuth model aliases emitted into managed CLIProxy config. */ + oauth_model_alias?: CLIProxyOAuthModelAliasConfig; + /** Scoped payload rules emitted into managed CLIProxy config. */ + payload?: CLIProxyPayloadConfig; } diff --git a/src/config/schemas/index.ts b/src/config/schemas/index.ts index 43a437b5..6bbcede2 100644 --- a/src/config/schemas/index.ts +++ b/src/config/schemas/index.ts @@ -29,6 +29,13 @@ export type { CLIProxyLoggingConfig, CLIProxySafetyConfig, CLIProxyRoutingConfig, + CLIProxyRetryConfig, + CLIProxyPoolRoutingConfig, + CLIProxyOAuthModelAliasEntry, + CLIProxyOAuthModelAliasConfig, + CLIProxyPayloadModelSelector, + CLIProxyPayloadOverrideRule, + CLIProxyPayloadConfig, CLIProxyConfig, } from './cliproxy'; diff --git a/src/config/schemas/version.ts b/src/config/schemas/version.ts index 98979314..9f060cd9 100644 --- a/src/config/schemas/version.ts +++ b/src/config/schemas/version.ts @@ -19,5 +19,6 @@ * Version 11 = Discord Channels runtime auto-enable preferences * Version 12 = Official Channels multi-provider support (Telegram, Discord, iMessage) * Version 13 = Browser automation defaults to safe manual/off exposure + * Version 14 = User-defined CLIProxy OAuth aliases and scoped payload rules */ -export const UNIFIED_CONFIG_VERSION = 13; +export const UNIFIED_CONFIG_VERSION = 14; diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 011414d2..0fd667c6 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -18,11 +18,11 @@ import { normalizeCopilotConfigWithWarnings } from './copilot-model-normalizer'; import { CopilotStatus } from './types'; import { fail, info, ok, warn } from '../utils/ui'; import { - getWebSearchHookEnv, appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; +import type { WebSearchLaunchState } from '../utils/websearch-manager'; import { appendThirdPartyImageAnalysisToolArgs, ensureImageAnalysisMcpOrThrow, @@ -71,6 +71,17 @@ export async function getCopilotStatus(config: CopilotConfig): Promise { const { config: normalizedConfig, warnings } = normalizeCopilotConfigWithWarnings(config); @@ -276,7 +288,7 @@ export async function executeCopilotProfile( syncImageAnalysisMcpToConfigDir(claudeConfigDir); // Merge with current environment (global env first, copilot overrides, then hook env vars) - const webSearchEnv = getWebSearchHookEnv(); + const webSearchEnv = webSearchLaunch?.hookEnv ?? {}; const imageAnalysisResolution = await resolveCopilotImageAnalysisEnv(); const imageAnalysisProvisioningFailed = !imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1'; @@ -305,10 +317,11 @@ export async function executeCopilotProfile( // Spawn Claude CLI const spawnStartedAt = Date.now(); return new Promise((resolve) => { - const imageAnalysisArgs = imageAnalysisMcpReady - ? appendThirdPartyImageAnalysisToolArgs(claudeArgs) - : claudeArgs; - const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs); + const launchArgs = buildCopilotClaudeLaunchArgs( + claudeArgs, + imageAnalysisMcpReady, + webSearchLaunch?.enabled ?? true + ); const traceEnv = createWebSearchTraceContext({ launcher: 'copilot.executor', args: launchArgs, diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts index 5716ace2..e88a59e0 100644 --- a/src/cursor/cursor-profile-executor.ts +++ b/src/cursor/cursor-profile-executor.ts @@ -9,9 +9,9 @@ import { fail, info, ok } from '../utils/ui'; import { appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, - getWebSearchHookEnv, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; +import type { WebSearchLaunchState } from '../utils/websearch-manager'; import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; import { checkAuthStatus } from './cursor-auth'; @@ -106,11 +106,19 @@ export async function resolveCursorImageAnalysisEnv( return { env, warning: null }; } +export function buildCursorClaudeLaunchArgs( + claudeArgs: string[], + webSearchEnabled: boolean +): string[] { + return appendThirdPartyWebSearchToolArgs(claudeArgs, webSearchEnabled); +} + export async function executeCursorProfile( config: CursorConfig, claudeArgs: string[], claudeConfigDir?: string, - claudeCliPath = 'claude' + claudeCliPath = 'claude', + webSearchLaunch?: WebSearchLaunchState ): Promise { if (!config.enabled) { process.stderr.write(fail('Cursor integration is not enabled.') + '\n'); @@ -163,7 +171,7 @@ export async function executeCursorProfile( const cursorEnv = generateCursorEnv(config, daemonToken, claudeConfigDir); const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; - const webSearchEnv = getWebSearchHookEnv(); + const webSearchEnv = webSearchLaunch?.hookEnv ?? {}; const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = await resolveCursorImageAnalysisEnv(); const env = stripClaudeCodeEnv({ @@ -184,7 +192,7 @@ export async function executeCursorProfile( syncWebSearchMcpToConfigDir(claudeConfigDir); return new Promise((resolve) => { - const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs); + const launchArgs = buildCursorClaudeLaunchArgs(claudeArgs, webSearchLaunch?.enabled ?? true); const traceEnv = createWebSearchTraceContext({ launcher: 'cursor.executor', args: launchArgs, diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 3bb0d813..58d43b01 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -55,8 +55,8 @@ import { appendWebSearchTrace, createWebSearchTraceContext, ensureWebSearchMcpForLaunch, - getWebSearchHookEnv, readWebSearchTraceRecords, + resolveWebSearchLaunchState, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; import { getCcsDir, getGlobalEnvConfig, loadSettings } from '../config/config-loader-facade'; @@ -159,7 +159,8 @@ export class HeadlessExecutor { ); } - ensureWebSearchMcpForLaunch(); + const webSearchLaunch = resolveWebSearchLaunchState(); + ensureWebSearchMcpForLaunch(webSearchLaunch.config); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); @@ -257,6 +258,7 @@ export class HeadlessExecutor { let runtimeEnvVars: NodeJS.ProcessEnv = { ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }, settingsEnv), + ...webSearchLaunch.hookEnv, ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), CCS_PROFILE_TYPE: 'settings', CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', @@ -380,7 +382,10 @@ export class HeadlessExecutor { const imageAnalysisArgs = imageAnalysisMcpReady ? appendThirdPartyImageAnalysisToolArgs(args) : args; - const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs); + const launchArgs = appendThirdPartyWebSearchToolArgs( + imageAnalysisArgs, + webSearchLaunch.enabled + ); const traceEnv = createWebSearchTraceContext({ launcher: 'delegation.headless-executor', args: launchArgs, @@ -467,7 +472,6 @@ export class HeadlessExecutor { const cleanEnv = stripClaudeCodeEnv({ ...stripAnthropicRoutingEnv(process.env), ...getClaudeLaunchEnvOverrides(), - ...getWebSearchHookEnv(), ...runtimeEnvVars, ...imageAnalysisEnv, ...traceEnv, diff --git a/src/dispatcher/flows/copilot-flow.ts b/src/dispatcher/flows/copilot-flow.ts index d92d31e8..7afb9874 100644 --- a/src/dispatcher/flows/copilot-flow.ts +++ b/src/dispatcher/flows/copilot-flow.ts @@ -5,7 +5,10 @@ */ import { fail, info } from '../../utils/ui'; -import { ensureWebSearchMcpForLaunch } from '../../utils/websearch-manager'; +import { + ensureWebSearchMcpForLaunch, + resolveWebSearchLaunchState, +} from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import { ensureProfileHooks as ensureImageAnalyzerHooks, @@ -23,7 +26,8 @@ export async function runCopilotFlow(ctx: ProfileDispatchContext): Promise resolveProfileContinuityInheritance, } = ctx; - ensureWebSearchMcpForLaunch(); + const webSearchLaunch = resolveWebSearchLaunchState(); + ensureWebSearchMcpForLaunch(webSearchLaunch.config); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); if (resolvedTarget === 'claude') { if (imageAnalysisMcpReady) { @@ -60,7 +64,8 @@ export async function runCopilotFlow(ctx: ProfileDispatchContext): Promise copilotConfig, remainingArgs, continuityInheritance.claudeConfigDir, - claudeCli + claudeCli, + webSearchLaunch ); process.exit(exitCode); } diff --git a/src/dispatcher/flows/cursor-flow.ts b/src/dispatcher/flows/cursor-flow.ts index 0d703e52..cc5af5ae 100644 --- a/src/dispatcher/flows/cursor-flow.ts +++ b/src/dispatcher/flows/cursor-flow.ts @@ -5,7 +5,10 @@ */ import { fail, info } from '../../utils/ui'; -import { ensureWebSearchMcpForLaunch } from '../../utils/websearch-manager'; +import { + ensureWebSearchMcpForLaunch, + resolveWebSearchLaunchState, +} from '../../utils/websearch-manager'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { installImageAnalyzerHook } from '../../utils/hooks'; import type { ProfileDispatchContext } from '../dispatcher-context'; @@ -19,7 +22,8 @@ export async function runCursorFlow(ctx: ProfileDispatchContext): Promise resolveProfileContinuityInheritance, } = ctx; - ensureWebSearchMcpForLaunch(); + const webSearchLaunch = resolveWebSearchLaunchState(); + ensureWebSearchMcpForLaunch(webSearchLaunch.config); installImageAnalyzerHook(); ensureImageAnalyzerHooks({ profileName: profileInfo.name, @@ -48,7 +52,8 @@ export async function runCursorFlow(ctx: ProfileDispatchContext): Promise cursorConfig, remainingArgs, continuityInheritance.claudeConfigDir, - claudeCli + claudeCli, + webSearchLaunch ); process.exit(exitCode); } diff --git a/src/dispatcher/flows/settings-flow.ts b/src/dispatcher/flows/settings-flow.ts index 83b8dc26..ef08ca51 100644 --- a/src/dispatcher/flows/settings-flow.ts +++ b/src/dispatcher/flows/settings-flow.ts @@ -16,10 +16,10 @@ import { import { ensureWebSearchMcpForLaunch, displayWebSearchStatus, - getWebSearchHookEnv, syncWebSearchMcpToConfigDir, appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, + resolveWebSearchLaunchState, } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow, @@ -94,18 +94,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise exit-code mapping is the contract this - * epic relies on. Migrating `throw new Error` to typed subclasses changes the - * process exit code (via handleError -> getExitCode); these tests lock the - * mapping so a future change is caught. See + * The typed-error -> exit-code mapping is a runtime compatibility contract. + * Migrating `throw new Error` to typed subclasses changes the process exit code + * through handleError; these tests lock the mapping so a future change is + * caught. See * docs/reports/typed-error-exit-code-compat-audit.md. */ -describe('typed-error taxonomy -> exit-code mapping (P4 contract)', () => { +describe('typed-error taxonomy -> exit-code compatibility contract', () => { test('each typed class carries its documented ExitCode', () => { expect(new ConfigError('m').code).toBe(ExitCode.CONFIG_ERROR); expect(new NetworkError('m').code).toBe(ExitCode.NETWORK_ERROR); diff --git a/src/management/instance-directory.ts b/src/management/instance-directory.ts index 56b0b384..82ca0e96 100644 --- a/src/management/instance-directory.ts +++ b/src/management/instance-directory.ts @@ -5,6 +5,149 @@ export function isAccountInstanceName(name: string): boolean { return !name.startsWith('.'); } +export function normalizeAccountInstanceName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); +} + +function unsafeInstancePath(targetPath: string): never { + throw Object.assign(new TypeError(`Unsafe account instance path: ${targetPath}`), { + code: 'EINVAL', + }); +} + +export interface DirectoryIdentity { + device: number; + inode: number; + realPath: string; +} + +function readDirectoryIdentity(directoryPath: string): DirectoryIdentity { + const stats = fs.lstatSync(directoryPath); + if (stats.isSymbolicLink() || !stats.isDirectory()) unsafeInstancePath(directoryPath); + return { + device: stats.dev, + inode: stats.ino, + realPath: fs.realpathSync.native(directoryPath), + }; +} + +function identitiesMatch(left: DirectoryIdentity, right: DirectoryIdentity): boolean { + return ( + left.device === right.device && left.inode === right.inode && left.realPath === right.realPath + ); +} + +function createDirectoryUnderStableParent(directoryPath: string): void { + const parentPath = path.dirname(directoryPath); + const parentBefore = readDirectoryIdentity(parentPath); + + try { + fs.mkdirSync(directoryPath, { mode: 0o700 }); + readDirectoryIdentity(directoryPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + + let parentAfter: DirectoryIdentity; + try { + parentAfter = readDirectoryIdentity(parentPath); + } catch { + unsafeInstancePath(parentPath); + } + + if (!identitiesMatch(parentBefore, parentAfter)) { + unsafeInstancePath(parentPath); + } + + readDirectoryIdentity(directoryPath); +} + +function isPathWithinDirectory(candidatePath: string, directoryPath: string): boolean { + const relative = path.relative(directoryPath, candidatePath); + return relative.length > 0 && relative !== '..' && !relative.startsWith(`..${path.sep}`); +} + +/** Reject symlink roots/entries and require the real instance path to stay under the real root. */ +export function isSafeAccountInstancePath(instancesDir: string, instancePath: string): boolean { + try { + const rootStats = fs.lstatSync(instancesDir); + if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) return false; + + const instanceStats = fs.lstatSync(instancePath); + if (instanceStats.isSymbolicLink() || !instanceStats.isDirectory()) return false; + + const realRoot = fs.realpathSync.native(instancesDir); + const realInstance = fs.realpathSync.native(instancePath); + return isPathWithinDirectory(realInstance, realRoot); + } catch { + return false; + } +} + +/** Create a missing managed instances root without traversing symlink parents. */ +export function ensureSafeAccountInstancesDirectory(instancesDir: string): void { + const missingPaths: string[] = []; + let currentPath = instancesDir; + + while (true) { + try { + const stats = fs.lstatSync(currentPath); + if (stats.isSymbolicLink() || !stats.isDirectory()) unsafeInstancePath(currentPath); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + missingPaths.push(currentPath); + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) unsafeInstancePath(instancesDir); + currentPath = parentPath; + } + } + + for (const directoryPath of missingPaths.reverse()) { + createDirectoryUnderStableParent(directoryPath); + } + + const rootStats = fs.lstatSync(instancesDir); + if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) unsafeInstancePath(instancesDir); +} + +export function assertSafeAccountInstancePath(instancesDir: string, instancePath: string): void { + if (!isSafeAccountInstancePath(instancesDir, instancePath)) unsafeInstancePath(instancePath); +} + +/** + * Capture the validated directory identity used by later mutation guards. + * This is defense-in-depth for static and ordinary concurrent replacement, + * not a hostile same-UID race boundary; that would require dirfd/openat-style APIs. + */ +export function captureSafeAccountInstanceIdentity( + instancesDir: string, + instancePath: string +): DirectoryIdentity { + assertSafeAccountInstancePath(instancesDir, instancePath); + const identity = readDirectoryIdentity(instancePath); + assertAccountInstanceIdentity(instancesDir, instancePath, identity); + return identity; +} + +/** Require the lexical instance path to still name the validated directory. */ +export function assertAccountInstanceIdentity( + instancesDir: string, + instancePath: string, + expectedIdentity: DirectoryIdentity +): void { + if (!isSafeAccountInstancePath(instancesDir, instancePath)) unsafeInstancePath(instancePath); + + let currentIdentity: DirectoryIdentity; + try { + currentIdentity = readDirectoryIdentity(instancePath); + } catch { + unsafeInstancePath(instancePath); + } + + if (!identitiesMatch(expectedIdentity, currentIdentity)) unsafeInstancePath(instancePath); +} + export function listAccountInstanceNames(instancesDir: string): string[] { if (!fs.existsSync(instancesDir)) { return []; @@ -15,11 +158,7 @@ export function listAccountInstanceNames(instancesDir: string): string[] { return false; } - try { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - } catch { - return false; - } + return isSafeAccountInstancePath(instancesDir, path.join(instancesDir, name)); }); } diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index ba23138e..5667784e 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -15,7 +15,13 @@ import type { AccountContextPolicy } from '../auth/account-context'; import { getCcsHome } from '../utils/config-manager'; import { createLogger } from '../services/logging'; import { getCcsDir } from '../config/config-loader-facade'; -import { listAccountInstanceNames } from './instance-directory'; +import { ProfileError } from '../errors/error-types'; +import { + assertSafeAccountInstancePath, + ensureSafeAccountInstancesDirectory, + listAccountInstanceNames, + normalizeAccountInstanceName, +} from './instance-directory'; const logger = createLogger('management:instance-manager'); @@ -23,7 +29,7 @@ const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis', /** Options for instance creation */ export interface InstanceOptions { - /** Skip shared symlinks (commands, skills, agents, settings.json) */ + /** Skip shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */ bare?: boolean; } @@ -52,11 +58,18 @@ class InstanceManager { options: InstanceOptions = {} ): Promise { const instancePath = this.getInstancePath(profileName); + const initialInstanceStats = this.getInstanceStats(instancePath); + if (initialInstanceStats) { + assertSafeAccountInstancePath(this.instancesDir, instancePath); + } else { + ensureSafeAccountInstancesDirectory(this.instancesDir); + } // Serialize context sync operations per profile across processes. await this.contextSyncLock.withLock(profileName, async () => { // Lazy initialization - if (!fs.existsSync(instancePath)) { + if (!this.getInstanceStats(instancePath)) { + ensureSafeAccountInstancesDirectory(this.instancesDir); logger.stage('route', 'instance.init', 'Initializing new profile instance', { profile: profileName, bare: options.bare === true, @@ -64,6 +77,9 @@ class InstanceManager { this.initializeInstance(profileName, instancePath, options); } + // Re-check after locking/creation before any operation can mutate the instance. + assertSafeAccountInstancePath(this.instancesDir, instancePath); + // Validate structure (auto-fix missing dirs) this.validateInstance(instancePath); @@ -94,7 +110,7 @@ class InstanceManager { * Get instance path for profile */ getInstancePath(profileName: string): string { - const safeName = this.sanitizeName(profileName); + const safeName = normalizeAccountInstanceName(profileName); return path.join(this.instancesDir, safeName); } @@ -108,30 +124,14 @@ class InstanceManager { ): void { try { // Create base directory - fs.mkdirSync(instancePath, { recursive: true, mode: 0o700 }); + fs.mkdirSync(instancePath, { mode: 0o700 }); - // Create Claude-expected subdirectories (profile-specific only) - const subdirs = [ - 'session-env', - 'todos', - 'logs', - 'file-history', - 'shell-snapshots', - 'debug', - '.anthropic', - ]; - - subdirs.forEach((dir) => { - const dirPath = path.join(instancePath, dir); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); - } - }); - - // Shared links are created during ensureInstance() under the plugin layout lock. + // Expected subdirectories and shared links are created only after the + // caller validates that this new root stayed inside the managed parent. } catch (error) { - throw new Error( - `Failed to initialize instance for ${profileName}: ${(error as Error).message}` + throw new ProfileError( + `Failed to initialize instance for ${profileName}: ${(error as Error).message}`, + profileName ); } } @@ -268,12 +268,13 @@ class InstanceManager { } } - /** - * Sanitize profile name for filesystem - */ - private sanitizeName(name: string): string { - // Replace unsafe characters with dash - return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + private getInstanceStats(instancePath: string): fs.Stats | null { + try { + return fs.lstatSync(instancePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } } } diff --git a/src/management/shared-manager/canonical-first-file-claims.ts b/src/management/shared-manager/canonical-first-file-claims.ts new file mode 100644 index 00000000..ed7d11fb --- /dev/null +++ b/src/management/shared-manager/canonical-first-file-claims.ts @@ -0,0 +1,123 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { warn } from '../../utils/ui'; +import { getLstatSync } from './fs-helpers'; + +let claimSequence = 0; + +export function readRegularFileContent(filePath: string): Buffer | null { + const stats = getLstatSync(filePath); + return stats?.isFile() ? fs.readFileSync(filePath) : null; +} + +function preserveClaim(filePath: string, claimPath: string, reason: string): string { + const basePath = `${filePath}.ccs-shared-conflict`; + let sequence = 0; + while (true) { + const recoveryPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; + try { + fs.linkSync(claimPath, recoveryPath); + try { + fs.unlinkSync(claimPath); + } catch { + // Both names preserve the same inode, so leaving the claim is safe. + } + console.log(warn(`${reason}; preserved content at ${recoveryPath}`)); + return recoveryPath; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'EEXIST') { + sequence++; + continue; + } + + try { + fs.copyFileSync(claimPath, recoveryPath, fs.constants.COPYFILE_EXCL); + fs.chmodSync(recoveryPath, fs.statSync(claimPath).mode & 0o777); + fs.unlinkSync(claimPath); + console.log( + warn(`${reason}; copied content to ${recoveryPath} (${code ?? 'link failed'})`) + ); + return recoveryPath; + } catch (copyError) { + if ((copyError as NodeJS.ErrnoException).code === 'EEXIST') { + sequence++; + continue; + } + + if (!getLstatSync(filePath)) { + try { + fs.renameSync(claimPath, filePath); + } catch { + // The claim remains the only recoverable copy if restoration loses a race. + } + } + console.log(warn(`${reason}; unable to publish recovery, restored original when possible`)); + throw err; + } + } + } +} + +function interruptedClaimPaths(filePath: string): string[] { + const directory = path.dirname(filePath); + const prefix = `${path.basename(filePath)}.ccs-shared-claim-`; + try { + return fs + .readdirSync(directory) + .filter((entry) => entry.startsWith(prefix)) + .map((entry) => path.join(directory, entry)) + .filter((entryPath) => fs.lstatSync(entryPath).isFile()) + .sort(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } +} + +export function recoverInterruptedClaims(filePath: string): void { + const claims = interruptedClaimPaths(filePath); + if (claims.length === 0) return; + + if (claims.length === 1 && !getLstatSync(filePath)) { + fs.renameSync(claims[0], filePath); + console.log(warn(`Recovered interrupted shared-file claim at ${filePath}`)); + return; + } + + for (const claimPath of claims) { + preserveClaim(filePath, claimPath, `Quarantined interrupted shared-file claim for ${filePath}`); + } +} + +export function claimCanonicalFirstCopy( + filePath: string, + canonicalContent: Buffer | null, + reason: string +): boolean { + const initialStats = getLstatSync(filePath); + if (!initialStats?.isFile()) return false; + + const claimPath = `${filePath}.ccs-shared-claim-${process.pid}-${Date.now()}-${claimSequence++}`; + fs.renameSync(filePath, claimPath); + if (getLstatSync(filePath)) { + preserveClaim(filePath, claimPath, `Concurrent replacement detected at ${filePath}`); + throw Object.assign(new TypeError(`Concurrent replacement detected at ${filePath}`), { + code: 'EEXIST', + }); + } + + const claimedContent = fs.readFileSync(claimPath); + if (canonicalContent?.equals(claimedContent)) { + fs.unlinkSync(claimPath); + } else { + preserveClaim(filePath, claimPath, reason); + } + if (getLstatSync(filePath)) { + throw Object.assign(new TypeError(`Path reappeared during reconciliation: ${filePath}`), { + code: 'EEXIST', + }); + } + return true; +} diff --git a/src/management/shared-manager/canonical-first-file-reconciler.ts b/src/management/shared-manager/canonical-first-file-reconciler.ts new file mode 100644 index 00000000..86496b9a --- /dev/null +++ b/src/management/shared-manager/canonical-first-file-reconciler.ts @@ -0,0 +1,142 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { info, warn } from '../../utils/ui'; +import ProfileRegistry from '../../auth/profile-registry'; +import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy'; +import { + isSafeAccountInstancePath, + listAccountInstancePaths, + normalizeAccountInstanceName, +} from '../instance-directory'; +import { adoptDivergedFileContent } from './diverged-file-adopter'; +import { getLstatSync } from './fs-helpers'; +import { + claimCanonicalFirstCopy, + readRegularFileContent, + recoverInterruptedClaims, +} from './canonical-first-file-claims'; + +interface CanonicalFirstRoots { + claudeDir: string; + sharedDir: string; + instancesDir: string; +} + +function candidatePaths(roots: CanonicalFirstRoots, fileName: string): string[] { + const profiles = new ProfileRegistry().getAllProfilesMerged(); + const profilesByInstanceName = new Map>(); + for (const [profileName, profile] of Object.entries(profiles)) { + const instanceName = normalizeAccountInstanceName(profileName); + const matches = profilesByInstanceName.get(instanceName) ?? []; + matches.push(profile); + profilesByInstanceName.set(instanceName, matches); + } + + return [ + path.join(roots.sharedDir, fileName), + ...listAccountInstancePaths(roots.instancesDir) + .filter((instancePath) => { + const instanceName = normalizeAccountInstanceName(path.basename(instancePath)); + const matches = profilesByInstanceName.get(instanceName) ?? []; + if (matches.length > 1) return false; + return matches.length === 0 || !isProfileLocalSharedResourceMode(matches[0]); + }) + .map((instancePath) => path.join(instancePath, fileName)), + ]; +} + +function reconcileCandidatesAgainstCanonical( + canonicalPath: string, + candidates: readonly string[] +): void { + let canonicalContent: Buffer | null = null; + try { + canonicalContent = fs.readFileSync(canonicalPath); + } catch { + // A present but unreadable or dangling canonical path still owns precedence. + } + + for (const candidatePath of candidates) { + claimCanonicalFirstCopy( + candidatePath, + canonicalContent, + `Canonical ${path.basename(canonicalPath)} wins over divergent copy at ${candidatePath}` + ); + } +} + +/** + * Reconcile a canonical-first shared file before any seed or symlink mutation. + * Existing canonical content always wins. With no canonical, one unique byte + * variant is adopted; conflicting variants are all quarantined for recovery. + */ +export function reconcileCanonicalFirstFile(roots: CanonicalFirstRoots, fileName: string): void { + const canonicalPath = path.join(roots.claudeDir, fileName); + const candidates = candidatePaths(roots, fileName); + for (const candidatePath of candidates) { + if (candidatePath !== path.join(roots.sharedDir, fileName)) { + const instancePath = path.dirname(candidatePath); + if (!isSafeAccountInstancePath(roots.instancesDir, instancePath)) continue; + } + recoverInterruptedClaims(candidatePath); + } + + if (getLstatSync(canonicalPath)) { + reconcileCandidatesAgainstCanonical(canonicalPath, candidates); + return; + } + + const variants: Array<{ path: string; content: Buffer }> = []; + for (const candidatePath of candidates) { + const content = readRegularFileContent(candidatePath); + if (content) variants.push({ path: candidatePath, content }); + } + if (variants.length === 0) return; + + const uniqueContents: Buffer[] = []; + for (const variant of variants) { + if (!uniqueContents.some((content) => content.equals(variant.content))) { + uniqueContents.push(variant.content); + } + } + + if (uniqueContents.length === 1) { + adoptDivergedFileContent(variants[0].path, canonicalPath); + reconcileCandidatesAgainstCanonical(canonicalPath, candidates); + console.log(info(`Adopted the only existing ${fileName} variant as canonical content`)); + return; + } + + for (const variant of variants) { + claimCanonicalFirstCopy( + variant.path, + null, + `Conflicting ${fileName} variants found while no canonical file exists` + ); + } + console.log( + warn( + `No ${fileName} variant was selected; recover one of the .ccs-shared-conflict files explicitly` + ) + ); +} + +/** Preserve a later divergent copy without allowing it to replace canonical content. */ +export function preserveCanonicalFirstDivergence( + divergedPath: string, + canonicalPath: string +): boolean { + let canonicalContent: Buffer | null = null; + try { + canonicalContent = fs.readFileSync(canonicalPath); + } catch { + // Preserve the divergence when canonical cannot be compared safely. + } + + return claimCanonicalFirstCopy( + divergedPath, + canonicalContent, + `Canonical ${path.basename(canonicalPath)} wins over divergent copy at ${divergedPath}` + ); +} diff --git a/src/management/shared-manager/diverged-file-adopter.ts b/src/management/shared-manager/diverged-file-adopter.ts new file mode 100644 index 00000000..43829a8f --- /dev/null +++ b/src/management/shared-manager/diverged-file-adopter.ts @@ -0,0 +1,367 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { info, warn } from '../../utils/ui'; +import { getLstatSync } from './fs-helpers'; + +export type DivergedFileAdoption = 'not-claimed' | 'claimed'; + +let adoptionClaimSequence = 0; + +function resolveLexicalSymlinkChain(targetPath: string): string { + let currentPath = path.resolve(targetPath); + const visited = new Set(); + while (true) { + if (visited.has(currentPath)) { + throw Object.assign(new TypeError(`Symlink loop while resolving ${targetPath}`), { + code: 'ELOOP', + }); + } + visited.add(currentPath); + + const stats = getLstatSync(currentPath); + if (!stats?.isSymbolicLink()) return currentPath; + currentPath = path.resolve(path.dirname(currentPath), fs.readlinkSync(currentPath)); + } +} + +export function assertAdoptionPathAbsent( + managedPath: string, + adoption: DivergedFileAdoption +): void { + if (adoption === 'claimed' && getLstatSync(managedPath)) { + throw Object.assign(new TypeError(`Path reappeared during reconciliation: ${managedPath}`), { + code: 'EEXIST', + }); + } +} + +export function recoverOrphanedCanonicalClaim(canonicalPath: string): boolean { + const writePath = resolveLexicalSymlinkChain(canonicalPath); + const directory = path.dirname(writePath); + const prefix = `${path.basename(writePath)}.ccs-canonical-claim-`; + let candidates: string[]; + try { + candidates = fs + .readdirSync(directory) + .filter((entry) => entry.startsWith(prefix)) + .map((entry) => path.join(directory, entry)) + .filter((entryPath) => fs.lstatSync(entryPath).isFile()) + .sort((left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw err; + } + const claimPath = candidates[0]; + if (!claimPath) return false; + + let recovered = false; + if (!getLstatSync(writePath)) { + fs.linkSync(claimPath, writePath); + fs.unlinkSync(claimPath); + candidates.shift(); + recovered = true; + console.log(warn(`Recovered interrupted canonical adoption at ${canonicalPath}`)); + } + + for (const leftoverClaim of candidates) { + const recoveryBase = `${writePath}.ccs-canonical-recovery`; + let sequence = 0; + while (true) { + const recoveryPath = sequence === 0 ? recoveryBase : `${recoveryBase}-${sequence}`; + try { + fs.linkSync(leftoverClaim, recoveryPath); + fs.unlinkSync(leftoverClaim); + console.log(warn(`Quarantined interrupted canonical claim at ${recoveryPath}`)); + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + sequence++; + } + } + } + return recovered; +} + +function preserveClaim(claimPath: string, divergedPath: string, reason: string): string { + const recoveryBase = `${divergedPath}.ccs-adopt-recovery`; + let sequence = 0; + while (true) { + const recoveryPath = sequence === 0 ? recoveryBase : `${recoveryBase}-${sequence}`; + try { + // link() is an atomic no-replace operation, so concurrent CCS + // processes cannot overwrite each other's recovery artifacts. + fs.linkSync(claimPath, recoveryPath); + try { + fs.unlinkSync(claimPath); + } catch { + // Both names preserve the same bytes; leaving the claim is safe. + } + console.log(warn(`${reason}; preserved content at ${recoveryPath}`)); + return recoveryPath; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'EEXIST') { + sequence++; + continue; + } + console.log(warn(`${reason}; content remains at ${claimPath}${code ? ` (${code})` : ''}`)); + return claimPath; + } + } +} + +function validateManagedJson(filePath: string, content: Buffer): boolean { + if (path.extname(filePath).toLowerCase() !== '.json') { + return true; + } + + try { + const parsed = JSON.parse(content.toString('utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return false; + } + + if (path.basename(filePath) === 'installed_plugins.json') { + const registry = parsed as Record; + return ( + typeof registry.plugins === 'object' && + registry.plugins !== null && + !Array.isArray(registry.plugins) + ); + } + + return true; + } catch { + return false; + } +} + +function atomicWriteFile(targetPath: string, content: Buffer, mode: number): void { + const tempPath = `${targetPath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; + let descriptor: number | null = null; + try { + descriptor = fs.openSync(tempPath, 'wx', mode); + fs.fchmodSync(descriptor, mode); + fs.writeFileSync(descriptor, content); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + fs.linkSync(tempPath, targetPath); + fs.unlinkSync(tempPath); + } catch (err) { + if (descriptor !== null) { + fs.closeSync(descriptor); + } + try { + fs.unlinkSync(tempPath); + } catch { + // The temp file may not have been created or may already have been renamed. + } + throw err; + } +} + +function publishBackupNoReplace(sourcePath: string, canonicalPath: string): string { + const basePath = `${canonicalPath}.bak-ccs-adopt`; + const content = fs.readFileSync(sourcePath); + const mode = fs.statSync(sourcePath).mode & 0o777; + let sequence = 0; + while (true) { + const backupPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; + try { + atomicWriteFile(backupPath, content, mode); + return backupPath; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + sequence++; + } + } +} + +function publishAdoptedRecoveryNoReplace(sourcePath: string, divergedPath: string): string { + const basePath = `${divergedPath}.ccs-adopted-recovery`; + const content = fs.readFileSync(sourcePath); + const mode = fs.statSync(sourcePath).mode & 0o777; + let sequence = 0; + while (true) { + const recoveryPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; + try { + atomicWriteFile(recoveryPath, content, mode); + return recoveryPath; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + sequence++; + } + } +} + +function restoreCanonicalClaim(claimPath: string, writePath: string, canonicalPath: string): void { + try { + fs.linkSync(claimPath, writePath); + fs.unlinkSync(claimPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + publishBackupNoReplace(claimPath, canonicalPath); + fs.unlinkSync(claimPath); + } +} + +function getCanonicalFile(canonicalPath: string): { + content: Buffer | null; + mode: number; + mtimeMs: number | null; + writePath: string; +} { + const canonicalLstat = getLstatSync(canonicalPath); + if (!canonicalLstat) { + return { content: null, mode: 0o600, mtimeMs: null, writePath: canonicalPath }; + } + + let writePath = canonicalPath; + if (canonicalLstat.isSymbolicLink()) { + writePath = fs.realpathSync.native(canonicalPath); + } + + const canonicalStats = fs.statSync(canonicalPath); + if (!canonicalStats.isFile()) { + throw Object.assign(new TypeError(`Canonical path is not a regular file: ${canonicalPath}`), { + code: 'EINVAL', + }); + } + + return { + content: fs.readFileSync(canonicalPath), + mode: canonicalStats.mode & 0o777, + mtimeMs: canonicalStats.mtimeMs, + writePath, + }; +} + +export function adoptDivergedFileContent( + divergedPath: string, + canonicalPath: string +): DivergedFileAdoption { + const initialStats = getLstatSync(divergedPath); + if (!initialStats?.isFile()) { + return 'not-claimed'; + } + + const claimPath = `${divergedPath}.ccs-adopt-claim-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; + try { + fs.renameSync(divergedPath, claimPath); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + console.log( + warn( + `Unable to claim diverged ${divergedPath}; preserving original${code ? ` (${code})` : ''}` + ) + ); + throw err; + } + + let canonicalClaimPath: string | null = null; + let canonicalWritePath: string | null = null; + let divergencePreserved = false; + try { + const claimedStats = fs.lstatSync(claimPath); + if (!claimedStats.isFile()) { + preserveClaim(claimPath, divergedPath, `Refusing non-regular divergence at ${divergedPath}`); + divergencePreserved = true; + throw Object.assign(new TypeError(`Refusing non-regular divergence at ${divergedPath}`), { + code: 'EINVAL', + }); + } + + if (getLstatSync(divergedPath)) { + preserveClaim(claimPath, divergedPath, `Concurrent replacement detected at ${divergedPath}`); + divergencePreserved = true; + throw Object.assign(new TypeError(`Concurrent replacement detected at ${divergedPath}`), { + code: 'EEXIST', + }); + } + + const diverged = fs.readFileSync(claimPath); + if (!validateManagedJson(divergedPath, diverged)) { + preserveClaim(claimPath, divergedPath, `Refusing malformed managed JSON at ${divergedPath}`); + return 'claimed'; + } + + const canonical = getCanonicalFile(canonicalPath); + let current = canonical.content; + let publishMode = canonical.mode; + if (current) { + canonicalWritePath = canonical.writePath; + canonicalClaimPath = `${canonical.writePath}.ccs-canonical-claim-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; + fs.renameSync(canonical.writePath, canonicalClaimPath); + const currentStats = fs.statSync(canonicalClaimPath); + publishMode = currentStats.mode & 0o777; + current = fs.readFileSync(canonicalClaimPath); + if (diverged.equals(current)) { + restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath); + canonicalClaimPath = null; + fs.unlinkSync(claimPath); + return 'claimed'; + } + if (claimedStats.mtimeMs <= currentStats.mtimeMs) { + restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath); + canonicalClaimPath = null; + preserveClaim( + claimPath, + divergedPath, + `Refusing stale or ambiguously-timed divergence at ${divergedPath}` + ); + return 'claimed'; + } + } + + if (canonicalClaimPath) { + publishBackupNoReplace(canonicalClaimPath, canonicalPath); + } + publishAdoptedRecoveryNoReplace(claimPath, divergedPath); + + atomicWriteFile(canonical.writePath, diverged, publishMode); + if (!fs.readFileSync(canonicalPath).equals(diverged)) { + throw Object.assign( + new TypeError(`Canonical adoption postcondition failed: ${canonicalPath}`), + { + code: 'EAGAIN', + } + ); + } + if (canonicalClaimPath) { + fs.unlinkSync(canonicalClaimPath); + canonicalClaimPath = null; + } + if (getLstatSync(divergedPath)) { + preserveClaim(claimPath, divergedPath, `Concurrent replacement detected at ${divergedPath}`); + divergencePreserved = true; + throw Object.assign(new TypeError(`Concurrent replacement detected at ${divergedPath}`), { + code: 'EEXIST', + }); + } + fs.unlinkSync(claimPath); + console.log( + info(`Adopted diverged ${path.basename(divergedPath)} content into ${canonicalPath}`) + ); + return 'claimed'; + } catch (err) { + if (canonicalClaimPath && canonicalWritePath) { + restoreCanonicalClaim(canonicalClaimPath, canonicalWritePath, canonicalPath); + canonicalClaimPath = null; + } + if (divergencePreserved) { + throw err; + } + if (!getLstatSync(divergedPath)) { + try { + fs.renameSync(claimPath, divergedPath); + } catch { + preserveClaim(claimPath, divergedPath, `Unable to restore diverged ${divergedPath}`); + } + } else { + preserveClaim(claimPath, divergedPath, `Unable to restore diverged ${divergedPath}`); + } + throw err; + } +} diff --git a/src/management/shared-manager/fs-helpers.ts b/src/management/shared-manager/fs-helpers.ts index ab8d82bb..0a40f5e9 100644 --- a/src/management/shared-manager/fs-helpers.ts +++ b/src/management/shared-manager/fs-helpers.ts @@ -11,8 +11,18 @@ import * as fs from 'fs'; import * as path from 'path'; + import type { SharedItem } from './types'; +/** + * Claude Code saves managed files (settings.json, plugins/installed_plugins.json) + * with an atomic write (temp file + rename), which replaces a managed symlink + * with a regular file holding the user's latest changes — see #57 and #1681. + * When reconciliation finds such a diverged regular file, adopt its content + * into the canonical ~/.claude file before re-creating the symlink, instead + * of discarding the user's changes. The previous canonical content is kept + * in a `.bak-ccs-adopt` backup alongside it. + */ /** * Return canonical realpath for a path. Falls back to the lexical resolve * when realpath fails (e.g. path does not exist). diff --git a/src/management/shared-manager/plugin-layout-internals.ts b/src/management/shared-manager/plugin-layout-internals.ts index 5e4566d8..c215536b 100644 --- a/src/management/shared-manager/plugin-layout-internals.ts +++ b/src/management/shared-manager/plugin-layout-internals.ts @@ -14,7 +14,17 @@ import * as fs from 'fs'; import * as path from 'path'; import { warn } from '../../utils/ui'; -import { copyDirectoryFallback, removeExistingPath, symlinkPointsTo } from './fs-helpers'; +import { + adoptDivergedFileContent, + assertAdoptionPathAbsent, + recoverOrphanedCanonicalClaim, +} from './diverged-file-adopter'; +import { + copyDirectoryFallback, + getLstatSync, + removeExistingPath, + symlinkPointsTo, +} from './fs-helpers'; import type { PluginMetadataRoots } from './plugin-metadata-normalizer'; import { reconcileLocalMarketplaceRegistry } from './plugin-metadata-normalizer'; import { @@ -29,6 +39,18 @@ import type { SharedItem } from './types'; */ export type PluginLayoutRoots = PluginMetadataRoots; +function runGuardedInstanceMutation( + assertInstanceIdentity: (() => void) | undefined, + operation: () => T +): T { + assertInstanceIdentity?.(); + try { + return operation(); + } finally { + assertInstanceIdentity?.(); + } +} + /** * Ensure the plugin layout default directories (cache, marketplaces) and * registry files (installed_plugins.json, known_marketplaces.json) exist @@ -40,6 +62,9 @@ export function ensureSharedPluginLayoutDefaults(claudeDir: string): void { for (const entry of SHARED_PLUGIN_ENTRIES) { const entryPath = path.join(pluginsDir, entry.name); + if (entry.type === 'file') { + recoverOrphanedCanonicalClaim(entryPath); + } if (fs.existsSync(entryPath)) { continue; } @@ -62,7 +87,11 @@ export function ensureSharedPluginLayoutDefaults(claudeDir: string): void { * Link shared plugins directory entries into an instance, creating the * instance plugins directory first if needed. */ -export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: string): void { +export function linkInstancePlugins( + roots: PluginLayoutRoots, + instancePath: string, + assertInstanceIdentity?: () => void +): void { const linkPath = path.join(instancePath, 'plugins'); const targetPath = path.join(roots.sharedDir, 'plugins'); let linkStats: fs.Stats | null = null; @@ -74,30 +103,70 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri throw err; } } + assertInstanceIdentity?.(); if (linkStats?.isSymbolicLink() || (linkStats && !linkStats.isDirectory())) { - removeExistingPath(linkPath, linkStats.isDirectory() ? 'directory' : 'file'); + const existingType = linkStats.isDirectory() ? 'directory' : 'file'; + runGuardedInstanceMutation(assertInstanceIdentity, () => + removeExistingPath(linkPath, existingType) + ); } if (!linkStats || !linkStats.isDirectory()) { - fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 }); + runGuardedInstanceMutation(assertInstanceIdentity, () => + fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 }) + ); } - for (const item of getSharedPluginLinkItems(roots.sharedDir)) { + const linkItems = getSharedPluginLinkItems(roots.sharedDir); + assertInstanceIdentity?.(); + + for (const item of linkItems) { const targetEntryPath = path.join(targetPath, item.name); const linkEntryPath = path.join(linkPath, item.name); + let adoption: ReturnType = 'not-claimed'; - removeExistingPath(linkEntryPath, item.type); + if (item.type === 'file') { + adoption = runGuardedInstanceMutation(assertInstanceIdentity, () => + adoptDivergedFileContent(linkEntryPath, path.join(roots.claudeDir, 'plugins', item.name)) + ); + if (adoption === 'not-claimed') { + runGuardedInstanceMutation(assertInstanceIdentity, () => + removeExistingPath(linkEntryPath, item.type) + ); + } + } else { + runGuardedInstanceMutation(assertInstanceIdentity, () => + removeExistingPath(linkEntryPath, item.type) + ); + } + assertAdoptionPathAbsent(linkEntryPath, adoption); + if (getLstatSync(linkEntryPath)) { + console.log(warn(`Skipping plugins/${item.name}: path reappeared during reconciliation`)); + continue; + } + + assertInstanceIdentity?.(); try { const symlinkType = item.type === 'directory' ? 'dir' : 'file'; fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType); } catch (_err) { + assertInstanceIdentity?.(); + assertAdoptionPathAbsent(linkEntryPath, adoption); + if (getLstatSync(linkEntryPath)) { + console.log(warn(`Skipping plugins/${item.name}: path reappeared during reconciliation`)); + continue; + } if (process.platform === 'win32') { if (item.type === 'directory') { - copyDirectoryFallback(targetEntryPath, linkEntryPath); + runGuardedInstanceMutation(assertInstanceIdentity, () => + copyDirectoryFallback(targetEntryPath, linkEntryPath) + ); } else { - fs.copyFileSync(targetEntryPath, linkEntryPath); + runGuardedInstanceMutation(assertInstanceIdentity, () => + fs.copyFileSync(targetEntryPath, linkEntryPath, fs.constants.COPYFILE_EXCL) + ); } console.log( warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`) @@ -106,6 +175,7 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri throw _err; } } + assertInstanceIdentity?.(); } } @@ -121,7 +191,16 @@ export function getSharedPluginLinkItems(sharedDir: string): SharedItem[] { ); for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) { - if (items.has(entry.name) || INSTANCE_LOCAL_PLUGIN_METADATA_FILES.has(entry.name)) { + if ( + items.has(entry.name) || + INSTANCE_LOCAL_PLUGIN_METADATA_FILES.has(entry.name) || + entry.name.includes('.bak-ccs-adopt') || + entry.name.includes('.ccs-adopt-') || + entry.name.includes('.ccs-adopted-recovery') || + entry.name.includes('.ccs-canonical-claim-') || + entry.name.includes('.ccs-canonical-recovery') || + entry.name.includes('.ccs-write-') + ) { continue; } diff --git a/src/management/shared-manager/shared-dir-linker.ts b/src/management/shared-manager/shared-dir-linker.ts index 00acef38..b339f619 100644 --- a/src/management/shared-manager/shared-dir-linker.ts +++ b/src/management/shared-manager/shared-dir-linker.ts @@ -18,6 +18,15 @@ import * as fs from 'fs'; import * as path from 'path'; import { info, warn } from '../../utils/ui'; +import { + assertAccountInstanceIdentity, + captureSafeAccountInstanceIdentity, +} from '../instance-directory'; +import { + adoptDivergedFileContent, + assertAdoptionPathAbsent, + recoverOrphanedCanonicalClaim, +} from './diverged-file-adopter'; import { copyDirectoryFallback, getLstatSync, @@ -36,6 +45,10 @@ import { ensureSharedPluginLayoutDefaults, linkInstancePlugins, } from './plugin-layout-internals'; +import { + preserveCanonicalFirstDivergence, + reconcileCanonicalFirstFile, +} from './canonical-first-file-reconciler'; import { SHARED_ITEMS } from './types'; /** @@ -44,6 +57,18 @@ import { SHARED_ITEMS } from './types'; */ export type LinkerRoots = PluginMetadataRoots; +function runInstanceMutationStage( + assertInstanceIdentity: (() => void) | undefined, + operation: () => T +): T { + assertInstanceIdentity?.(); + try { + return operation(); + } finally { + assertInstanceIdentity?.(); + } +} + /** * Detect a circular symlink before creation. A symlink is circular when its * target (raw or canonical) points back inside the shared root. @@ -92,76 +117,115 @@ export function detectCircularSymlink(target: string, sharedDir: string): boolea * Ensure shared directories exist as symlinks to ~/.claude/ and that the * plugin layout default directories and registry files are present. */ -export function ensureSharedDirectories(roots: LinkerRoots): void { +export function ensureSharedDirectories( + roots: LinkerRoots, + assertInstanceIdentity?: () => void +): void { const claudeDir = roots.claudeDir; const sharedDir = roots.sharedDir; - if (!getLstatSync(claudeDir)) { - console.log(info('Creating ~/.claude/ directory structure')); - fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); - } + runInstanceMutationStage(assertInstanceIdentity, () => { + if (!getLstatSync(claudeDir)) { + console.log(info('Creating ~/.claude/ directory structure')); + fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); + } + }); - if (!getLstatSync(sharedDir)) { - fs.mkdirSync(sharedDir, { recursive: true, mode: 0o700 }); - } + runInstanceMutationStage(assertInstanceIdentity, () => { + if (!getLstatSync(sharedDir)) { + fs.mkdirSync(sharedDir, { recursive: true, mode: 0o700 }); + } + }); - ensureSharedPluginLayoutDefaults(claudeDir); + runInstanceMutationStage(assertInstanceIdentity, () => + ensureSharedPluginLayoutDefaults(claudeDir) + ); for (const item of SHARED_ITEMS) { - const claudePath = path.join(claudeDir, item.name); - const sharedPath = path.join(sharedDir, item.name); + runInstanceMutationStage(assertInstanceIdentity, () => { + const claudePath = path.join(claudeDir, item.name); + const sharedPath = path.join(sharedDir, item.name); - if (!getLstatSync(claudePath)) { - if (item.type === 'directory') { - fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); - } else if (item.type === 'file') { - fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8'); - } - } - - if (detectCircularSymlink(claudePath, sharedDir)) { - console.log(warn(`Skipping ${item.name}: circular symlink detected`)); - continue; - } - - if (getLstatSync(sharedPath)) { - try { - const stats = fs.lstatSync(sharedPath); - if (stats.isSymbolicLink()) { - const currentTarget = fs.readlinkSync(sharedPath); - const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget); - if (resolvedTarget === claudePath) { - continue; - } - } - } catch (_err) { - // Continue to recreate + if (item.type === 'file') { + recoverOrphanedCanonicalClaim(claudePath); } - if (item.type === 'directory') { - fs.rmSync(sharedPath, { recursive: true, force: true }); - } else { - fs.unlinkSync(sharedPath); + if (item.type === 'file' && item.divergencePolicy === 'canonical-first') { + reconcileCanonicalFirstFile(roots, item.name); } - } - try { - const symlinkType = item.type === 'directory' ? 'dir' : 'file'; - fs.symlinkSync(claudePath, sharedPath, symlinkType); - } catch (_err) { - if (process.platform === 'win32') { + if (!getLstatSync(claudePath)) { if (item.type === 'directory') { - copyDirectoryFallback(claudePath, sharedPath); + fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); } else if (item.type === 'file') { - fs.copyFileSync(claudePath, sharedPath); + fs.writeFileSync(claudePath, item.seedContent, 'utf8'); } - console.log( - warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) - ); - } else { - throw _err; } - } + + if (detectCircularSymlink(claudePath, sharedDir)) { + console.log(warn(`Skipping ${item.name}: circular symlink detected`)); + return; + } + + let sharedAdoption: ReturnType = 'not-claimed'; + if (getLstatSync(sharedPath)) { + let removeExisting = true; + try { + const stats = fs.lstatSync(sharedPath); + if (stats.isSymbolicLink()) { + const currentTarget = fs.readlinkSync(sharedPath); + const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget); + if (resolvedTarget === claudePath) { + return; + } + } + } catch (_err) { + // Continue to recreate + } + + if (item.type === 'file' && item.divergencePolicy === 'canonical-first') { + removeExisting = !preserveCanonicalFirstDivergence(sharedPath, claudePath); + } else if (item.type === 'file') { + sharedAdoption = adoptDivergedFileContent(sharedPath, claudePath); + removeExisting = sharedAdoption === 'not-claimed'; + } + + if (item.type === 'directory' && removeExisting) { + fs.rmSync(sharedPath, { recursive: true, force: true }); + } else if (removeExisting) { + fs.unlinkSync(sharedPath); + } + assertAdoptionPathAbsent(sharedPath, sharedAdoption); + } + + if (getLstatSync(sharedPath)) { + console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`)); + return; + } + + try { + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(claudePath, sharedPath, symlinkType); + } catch (_err) { + assertAdoptionPathAbsent(sharedPath, sharedAdoption); + if (getLstatSync(sharedPath)) { + console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`)); + return; + } + if (process.platform === 'win32') { + if (item.type === 'directory') { + copyDirectoryFallback(claudePath, sharedPath); + } else if (item.type === 'file') { + fs.copyFileSync(claudePath, sharedPath, fs.constants.COPYFILE_EXCL); + } + console.log( + warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) + ); + } else { + throw _err; + } + } + }); } } @@ -169,45 +233,79 @@ export function ensureSharedDirectories(roots: LinkerRoots): void { * Link shared directories into a specific instance path. */ export function linkSharedDirectories(roots: LinkerRoots, instancePath: string): void { - ensureSharedDirectories(roots); + const instanceIdentity = captureSafeAccountInstanceIdentity(roots.instancesDir, instancePath); + const assertInstanceIdentity = () => + assertAccountInstanceIdentity(roots.instancesDir, instancePath, instanceIdentity); + + ensureSharedDirectories(roots, assertInstanceIdentity); + assertInstanceIdentity(); const sharedDir = roots.sharedDir; for (const item of SHARED_ITEMS) { - if (item.name === 'plugins') { - linkInstancePlugins(roots, instancePath); - continue; - } - - const linkPath = path.join(instancePath, item.name); - const targetPath = path.join(sharedDir, item.name); - - removeExistingPath(linkPath, item.type); - - try { - const symlinkType = item.type === 'directory' ? 'dir' : 'file'; - fs.symlinkSync(targetPath, linkPath, symlinkType); - } catch (_err) { - if (process.platform === 'win32') { - if (item.type === 'directory') { - copyDirectoryFallback(targetPath, linkPath); - } else if (item.type === 'file') { - fs.copyFileSync(targetPath, linkPath); - } - console.log( - warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) - ); - } else { - throw _err; + runInstanceMutationStage(assertInstanceIdentity, () => { + if (item.name === 'plugins') { + linkInstancePlugins(roots, instancePath, assertInstanceIdentity); + return; } - } + + const linkPath = path.join(instancePath, item.name); + const targetPath = path.join(sharedDir, item.name); + let adoption: ReturnType = 'not-claimed'; + + if (item.type === 'file' && item.divergencePolicy === 'canonical-first') { + if (!preserveCanonicalFirstDivergence(linkPath, path.join(roots.claudeDir, item.name))) { + removeExistingPath(linkPath, item.type); + } + } else if (item.type === 'file') { + adoption = adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name)); + if (adoption === 'not-claimed') { + removeExistingPath(linkPath, item.type); + } + } else { + removeExistingPath(linkPath, item.type); + } + assertAdoptionPathAbsent(linkPath, adoption); + + if (getLstatSync(linkPath)) { + console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`)); + return; + } + + try { + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(targetPath, linkPath, symlinkType); + } catch (_err) { + assertAdoptionPathAbsent(linkPath, adoption); + if (getLstatSync(linkPath)) { + console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`)); + return; + } + if (process.platform === 'win32') { + if (item.type === 'directory') { + copyDirectoryFallback(targetPath, linkPath); + } else if (item.type === 'file') { + fs.copyFileSync(targetPath, linkPath, fs.constants.COPYFILE_EXCL); + } + console.log( + warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) + ); + } else { + throw _err; + } + } + }); } // Preserve original behavior: linkSharedDirectories always concludes by // normalizing plugin + marketplace metadata for the freshly linked // instance. migrateFromV311 relies on this side effect. - normalizePluginRegistryPaths(roots, instancePath); - normalizeMarketplaceRegistryPaths(roots, instancePath); + runInstanceMutationStage(assertInstanceIdentity, () => + normalizePluginRegistryPaths(roots, instancePath) + ); + runInstanceMutationStage(assertInstanceIdentity, () => + normalizeMarketplaceRegistryPaths(roots, instancePath) + ); } /** diff --git a/src/management/shared-manager/types.ts b/src/management/shared-manager/types.ts index 7555f1ed..6264b22c 100644 --- a/src/management/shared-manager/types.ts +++ b/src/management/shared-manager/types.ts @@ -16,6 +16,20 @@ export interface SharedItem { type: 'directory' | 'file'; } +export interface ManagedSharedDirectoryItem extends SharedItem { + type: 'directory'; +} + +export interface ManagedSharedFileItem extends SharedItem { + type: 'file'; + /** Exact bytes used when no canonical or adoptable source file exists. */ + seedContent: string; + /** Controls whether an instance may replace an existing canonical file. */ + divergencePolicy: 'newer-wins' | 'canonical-first'; +} + +export type ManagedSharedItem = ManagedSharedDirectoryItem | ManagedSharedFileItem; + /** * Default content for a freshly provisioned installed_plugins.json registry. * Version 2 schema with an empty plugins map. @@ -36,12 +50,23 @@ export const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify( * Order matters: consumers rely on a stable iteration order when reconciling * symlinks, and 'plugins' is special-cased by the linker. */ -export const SHARED_ITEMS: readonly SharedItem[] = [ +export const SHARED_ITEMS: readonly ManagedSharedItem[] = [ { name: 'commands', type: 'directory' }, { name: 'skills', type: 'directory' }, { name: 'agents', type: 'directory' }, { name: 'plugins', type: 'directory' }, - { name: 'settings.json', type: 'file' }, + { + name: 'settings.json', + type: 'file', + seedContent: JSON.stringify({}, null, 2), + divergencePolicy: 'newer-wins', + }, + { + name: 'CLAUDE.md', + type: 'file', + seedContent: '', + divergencePolicy: 'canonical-first', + }, ]; /** diff --git a/src/proxy/proxy-daemon-entry.ts b/src/proxy/proxy-daemon-entry.ts index 4db89231..29c34d88 100644 --- a/src/proxy/proxy-daemon-entry.ts +++ b/src/proxy/proxy-daemon-entry.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import { resolveOpenAICompatProfileConfig } from './profile-router'; import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; -import { startOpenAICompatProxyServer } from './server/proxy-server'; +import { closeOpenAICompatProxyServer, startOpenAICompatProxyServer } from './server/proxy-server'; import { loadSettings } from '../config/config-loader-facade'; interface RuntimeOptions { @@ -99,9 +99,17 @@ function startRuntime(options: RuntimeOptions): void { }); server.once('error', (error) => { process.stderr.write(String((error as Error).message) + '\n'); - process.exit(1); + void closeOpenAICompatProxyServer(server).finally(() => { + process.exitCode = 1; + }); }); - const shutdown = () => server.close(); + let shutdownPromise: Promise | undefined; + const shutdown = () => { + shutdownPromise ??= closeOpenAICompatProxyServer(server).catch((error) => { + process.stderr.write(`Proxy shutdown failed: ${String((error as Error).message)}\n`); + process.exitCode = 1; + }); + }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); } diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index 4a5f2f93..6cd08c15 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -1,5 +1,4 @@ import * as http from 'http'; -import { Agent } from 'undici'; import type { Dispatcher } from 'undici'; import type { OpenAICompatProfileConfig } from '../profile-router'; import { resolveProxyRequestRoute } from '../request-router'; @@ -10,11 +9,14 @@ import { import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer'; import { isAnthropicPassthroughProfile, resolveOpenAIChatCompletionsUrl } from '../upstream-url'; import { createLogger } from '../../services/logging'; -import { - createGlobalFetchProxyDispatcher, - type UpstreamAgentTimeoutOptions, -} from '../../utils/fetch-proxy-setup'; +import type { UpstreamAgentTimeoutOptions } from '../../utils/fetch-proxy-setup'; import { pipeWebResponseToNode, readRawBody, writeJson } from './http-helpers'; +import { + closeUpstreamDispatcher, + createUpstreamDispatcher, + fetchWithUpstreamTransport, + toLogErrorInfo, +} from './upstream-transport'; const REQUEST_TIMEOUT_MS = 600_000; // Keep undici's per-phase timeouts above the explicit request timeout so the @@ -421,17 +423,6 @@ export function buildUpstreamAgentTimeouts(): UpstreamAgentTimeoutOptions { return { headersTimeout: ceiling, bodyTimeout: ceiling }; } -let defaultUpstreamDispatcher: Dispatcher | null = null; - -function getDefaultUpstreamDispatcher(): Dispatcher { - if (!defaultUpstreamDispatcher) { - const timeouts = buildUpstreamAgentTimeouts(); - // Honor HTTP(S)_PROXY routing when configured; otherwise a plain Agent. - defaultUpstreamDispatcher = createGlobalFetchProxyDispatcher(timeouts) ?? new Agent(timeouts); - } - return defaultUpstreamDispatcher; -} - function formatTimeoutDuration(timeoutMs: number): string { return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000} seconds` : `${timeoutMs}ms`; } @@ -489,7 +480,10 @@ export async function handleProxyMessagesRequest( res: http.ServerResponse, profile: OpenAICompatProfileConfig, expectedAuthToken: string, - insecureDispatcher?: Dispatcher + insecureDispatcher?: Dispatcher, + sharedUpstreamDispatcher?: Dispatcher, + upstreamFetch?: typeof globalThis.fetch, + forceInsecureTls = false ): Promise { const transformer = new ProxySseStreamTransformer(); const startedAt = Date.now(); @@ -563,20 +557,26 @@ export async function handleProxyMessagesRequest( } ); + const routeStaysOnActiveProfile = upstream.route.profile.profileName === profile.profileName; const useSharedInsecureDispatcher = - insecureDispatcher !== undefined && - upstream.route.profile.profileName === profile.profileName; + insecureDispatcher !== undefined && routeStaysOnActiveProfile; + const useServerInsecureTls = forceInsecureTls && routeStaysOnActiveProfile; const useProfileInsecureTls = upstream.route.profile.insecure === true; const ephemeralInsecureDispatcher = - useProfileInsecureTls && !useSharedInsecureDispatcher - ? new Agent({ connect: { rejectUnauthorized: false }, ...buildUpstreamAgentTimeouts() }) + useProfileInsecureTls && !useSharedInsecureDispatcher && !useServerInsecureTls + ? createUpstreamDispatcher(buildUpstreamAgentTimeouts(), true) : undefined; - const insecureTls = useSharedInsecureDispatcher || useProfileInsecureTls; + const insecureTls = + useSharedInsecureDispatcher || useServerInsecureTls || useProfileInsecureTls; + const ownedUpstreamDispatcher = + sharedUpstreamDispatcher || insecureTls + ? undefined + : createUpstreamDispatcher(buildUpstreamAgentTimeouts()); const dispatcher = useSharedInsecureDispatcher ? insecureDispatcher : useProfileInsecureTls ? ephemeralInsecureDispatcher - : getDefaultUpstreamDispatcher(); + : (sharedUpstreamDispatcher ?? ownedUpstreamDispatcher); try { logger.stage('dispatch', 'upstream.dispatch', 'Dispatching upstream fetch', { @@ -588,17 +588,20 @@ export async function handleProxyMessagesRequest( const upstreamUrl = resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl, { passthrough, }); - const upstreamResponse = await fetch( - upstreamUrl, - buildFetchInit( - upstream.route.profile, - upstream.body, - controller.signal, - req.headers, - passthrough, - dispatcher - ) + const fetchInit = buildFetchInit( + upstream.route.profile, + upstream.body, + controller.signal, + req.headers, + passthrough, + dispatcher ); + const upstreamResponse = upstreamFetch + ? await upstreamFetch(upstreamUrl, fetchInit) + : await fetchWithUpstreamTransport(upstreamUrl, fetchInit, { + dispatcher, + insecureTls, + }); logger.stage('upstream', 'upstream.response', 'Received upstream response', { profileName: profile.profileName, routedProfileName: upstream.route.profile.profileName, @@ -620,7 +623,7 @@ export async function handleProxyMessagesRequest( cleanupDisconnectHandlers(); if (ephemeralInsecureDispatcher) { try { - await ephemeralInsecureDispatcher.close(); + await closeUpstreamDispatcher(ephemeralInsecureDispatcher); } catch (closeError) { logger.stage( 'cleanup', @@ -635,13 +638,27 @@ export async function handleProxyMessagesRequest( ); } } + if (ownedUpstreamDispatcher) { + try { + await closeUpstreamDispatcher(ownedUpstreamDispatcher); + } catch (closeError) { + logger.stage( + 'cleanup', + 'request.dispatcher_close_failed', + 'Failed to close per-request upstream dispatcher', + { + profileName: profile.profileName, + routedProfileName: upstream.route.profile.profileName, + error: closeError instanceof Error ? closeError.message : String(closeError), + }, + { level: 'warn' } + ); + } + } } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown proxy error'; - const errInfo = { - name: error instanceof Error ? error.name : 'Error', - message, - }; + const errInfo = toLogErrorInfo(error); logger.stage( 'cleanup', 'request.failed', diff --git a/src/proxy/server/proxy-server.ts b/src/proxy/server/proxy-server.ts index f9af6885..84dd4b2a 100644 --- a/src/proxy/server/proxy-server.ts +++ b/src/proxy/server/proxy-server.ts @@ -1,6 +1,5 @@ import * as http from 'http'; import { randomUUID } from 'crypto'; -import { Agent } from 'undici'; import type { OpenAICompatProfileConfig } from '../profile-router'; import { OPENAI_COMPAT_PROXY_SERVICE_NAME } from '../proxy-daemon-paths'; import { createLogger, withRequestContext } from '../../services/logging'; @@ -11,10 +10,19 @@ import { validateIncomingProxyAuth, } from './messages-route'; import { writeJson } from './http-helpers'; +import { closeUpstreamDispatcher, createUpstreamDispatcher } from './upstream-transport'; const REQUEST_ID_HEADER = 'x-ccs-request-id'; // Loose UUID-ish guard: accepts UUIDs and similar opaque ids; rejects empty / control chars. const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{8,128}$/; +const DEFAULT_SHUTDOWN_FORCE_MS = 1_000; + +type ProxyServerLifecycle = { + cleanup: () => Promise; + shutdownPromise?: Promise; +}; + +const proxyServerLifecycles = new WeakMap(); function resolveInboundRequestId(headers: http.IncomingHttpHeaders): string { const raw = headers[REQUEST_ID_HEADER]; @@ -40,8 +48,17 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt port: options.port, }); const insecureDispatcher = options.insecure - ? new Agent({ connect: { rejectUnauthorized: false }, ...buildUpstreamAgentTimeouts() }) + ? createUpstreamDispatcher(buildUpstreamAgentTimeouts(), true) : undefined; + const upstreamDispatcher = createUpstreamDispatcher(buildUpstreamAgentTimeouts()); + let cleanupPromise: Promise | undefined; + const cleanup = () => { + cleanupPromise ??= Promise.all([ + closeUpstreamDispatcher(upstreamDispatcher), + closeUpstreamDispatcher(insecureDispatcher), + ]).then(() => undefined); + return cleanupPromise; + }; const server = http.createServer((req, res) => { const requestId = resolveInboundRequestId(req.headers); res.setHeader(REQUEST_ID_HEADER, requestId); @@ -121,7 +138,10 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt res, options.profile, options.authToken, - insecureDispatcher + insecureDispatcher, + upstreamDispatcher, + undefined, + options.insecure === true ); return; } @@ -138,9 +158,55 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt }); server.on('close', () => { logger.info('server.stop', 'OpenAI-compatible proxy server stopped'); - void insecureDispatcher?.close(); + void cleanup().catch((error) => { + logger.warn('server.dispatcher_close_failed', 'Failed to close upstream transport', { + error: error instanceof Error ? error.message : String(error), + }); + }); }); + proxyServerLifecycles.set(server, { cleanup }); server.listen(options.port, host); return server; } + +export function closeOpenAICompatProxyServer( + server: http.Server, + forceAfterMs = DEFAULT_SHUTDOWN_FORCE_MS +): Promise { + const lifecycle = proxyServerLifecycles.get(server); + if (lifecycle?.shutdownPromise) { + return lifecycle.shutdownPromise; + } + + const shutdownPromise = (async () => { + if (server.listening) { + await new Promise((resolve, reject) => { + let forceTimer: NodeJS.Timeout | undefined; + server.close((error) => { + if (forceTimer) { + clearTimeout(forceTimer); + } + if (error) { + reject(error); + } else { + resolve(); + } + }); + + // Node 18 does not reap idle keep-alive sockets as part of close(). + server.closeIdleConnections?.(); + if (forceAfterMs > 0) { + forceTimer = setTimeout(() => server.closeAllConnections?.(), forceAfterMs); + forceTimer.unref(); + } + }); + } + await lifecycle?.cleanup(); + })(); + + if (lifecycle) { + lifecycle.shutdownPromise = shutdownPromise; + } + return shutdownPromise; +} diff --git a/src/proxy/server/upstream-transport.ts b/src/proxy/server/upstream-transport.ts new file mode 100644 index 00000000..440decf2 --- /dev/null +++ b/src/proxy/server/upstream-transport.ts @@ -0,0 +1,93 @@ +import { Agent, fetch as undiciFetch, type Dispatcher } from 'undici'; +import type { LogErrorInfo } from '../../services/logging'; +import { + createGlobalFetchProxyDispatcher, + type UpstreamAgentTimeoutOptions, +} from '../../utils/fetch-proxy-setup'; +import { getProxyResolution, shouldBypassProxy } from '../../utils/proxy-env'; + +type BunRequestInit = RequestInit & { + proxy?: string; + tls?: { rejectUnauthorized: boolean }; +}; + +const dispatcherClosePromises = new WeakMap>(); + +export function isBunRuntime(): boolean { + return typeof process.versions.bun === 'string'; +} + +export function createUpstreamDispatcher( + options: UpstreamAgentTimeoutOptions, + insecure = false +): Dispatcher | undefined { + if (isBunRuntime()) { + return undefined; + } + if (insecure) { + return new Agent({ connect: { rejectUnauthorized: false }, ...options }); + } + return createGlobalFetchProxyDispatcher(options) ?? new Agent(options); +} + +export function closeUpstreamDispatcher(dispatcher?: Dispatcher): Promise { + if (!dispatcher) { + return Promise.resolve(); + } + const existing = dispatcherClosePromises.get(dispatcher); + if (existing) { + return existing; + } + + const close = (dispatcher as unknown as { close?: () => Promise }).close; + const closing = + typeof close === 'function' ? Promise.resolve(close.call(dispatcher)) : Promise.resolve(); + dispatcherClosePromises.set(dispatcher, closing); + return closing; +} + +export async function fetchWithUpstreamTransport( + input: string | URL | Request, + init: RequestInit, + options: { dispatcher?: Dispatcher; insecureTls?: boolean } = {} +): Promise { + if (!isBunRuntime()) { + return undiciFetch(input, init as Parameters[1]) as Promise; + } + + const requestUrl = new URL( + typeof input === 'string' || input instanceof URL ? input.toString() : input.url + ); + const bunInit: BunRequestInit = { ...init }; + delete (bunInit as Record).dispatcher; + + if (!shouldBypassProxy(requestUrl.hostname)) { + const proxy = getProxyResolution(requestUrl.protocol === 'https:', process.env, { + allowedProtocols: ['http:', 'https:'], + }); + if (proxy.url) { + bunInit.proxy = proxy.url; + } + } + if (options.insecureTls) { + bunInit.tls = { rejectUnauthorized: false }; + } + + return globalThis.fetch(input, bunInit); +} + +export function toLogErrorInfo(error: unknown, depth = 0): LogErrorInfo { + if (!(error instanceof Error)) { + return { name: 'Error', message: String(error) }; + } + + const errorWithDetails = error as Error & { code?: unknown; cause?: unknown }; + const info: LogErrorInfo = { name: error.name, message: error.message }; + if (typeof errorWithDetails.code === 'string') { + info.code = errorWithDetails.code; + } + if (depth < 3 && errorWithDetails.cause !== undefined && errorWithDetails.cause !== error) { + info.cause = toLogErrorInfo(errorWithDetails.cause, depth + 1); + } + return info; +} diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index 9083c1da..bde13890 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -671,6 +671,52 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { return translatedMessages; } +/** + * Hoist every accepted `role: "system"` message to one leading system message. + * + * Claude Code sends the system prompt as the top-level `system` field *and*, + * separately, sends skill/plugin listings as `role: "system"` entries inside + * `messages` (see #1459). `ProxyRequestTransformer.transform` prepends the + * top-level `system` field unconditionally, so once both are present the + * resulting array holds two `system` messages that are not adjacent — + * `coalesceMessages` only merges *consecutive* same-role messages, so it + * cannot fix this case even if it did coalesce `system` (which it explicitly + * excludes below). + * + * Strict OpenAI-compatible backends (LiteLLM among them) reject any request + * where a `system` message is not alone at index 0: + * `400 A 'system' message can only appear at index 0 of the messages array.` + * + * Inline system messages may appear between complete turns, including after + * tool results. They may not interrupt a pending assistant tool-call/result + * sequence; `transformMessages` rejects that ambiguous placement before this + * pass. Accepted system messages are extracted in encounter order, joined with + * a blank line, and reinserted as the sole leading message. Everything else + * keeps its relative order before normal same-role coalescing. + */ +function hoistSystemMessages(messages: OpenAIMessage[]): OpenAIMessage[] { + const systemParts: string[] = []; + const rest: OpenAIMessage[] = []; + + for (const message of messages) { + if (message.role !== 'system') { + rest.push(message); + continue; + } + const content = message.content; + const text = typeof content === 'string' ? content : ''; + if (text.trim().length > 0) { + systemParts.push(text); + } + } + + if (systemParts.length === 0) { + return rest; + } + + return [{ role: 'system', content: systemParts.join('\n\n') }, ...rest]; +} + /** * Coalesce consecutive messages of the same role. * OpenAI/vLLM/Ollama/Mistral require strict user<->assistant alternation. @@ -741,7 +787,7 @@ export class ProxyRequestTransformer { // was billed. See: // https://platform.openai.com/docs/api-reference/chat-streaming ...(source.stream === true ? { stream_options: { include_usage: true } } : {}), - messages: coalesceMessages(allMessages), + messages: coalesceMessages(hoistSystemMessages(allMessages)), max_tokens: asNumber(source.max_tokens), temperature: asNumber(source.temperature), top_p: asNumber(source.top_p), diff --git a/src/services/logging/log-redaction.ts b/src/services/logging/log-redaction.ts index 8218e4e4..b268e113 100644 --- a/src/services/logging/log-redaction.ts +++ b/src/services/logging/log-redaction.ts @@ -50,6 +50,8 @@ function maskAuthSchemeValue(value: string): string { const SECRET_TOKEN_PATTERN = /(?:Bearer|Basic|Token)\s+\S{8,}|sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{32,}|xox[bpoa]-[A-Za-z0-9-]{10,}|gh[opsu]_[A-Za-z0-9]{36,}|glpat-[A-Za-z0-9_-]{18,}|AIza[0-9A-Za-z_-]{35}|eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|secret)(?:=|%3D)[A-Za-z0-9._~+/=-]{8,}/g; +const URL_USERINFO_PATTERN = /([a-z][a-z\d+.-]*:\/\/)([^\s/@]+)@/gi; + /** * Scrub known credential shapes from an arbitrary string. Preserves the * Bearer/Basic/Token scheme prefix when present so the entry stays readable. @@ -57,10 +59,12 @@ const SECRET_TOKEN_PATTERN = * (defense-in-depth for the hotpath console.error sweep). */ export function maskSecretTokens(value: string): string { - return value.replace(SECRET_TOKEN_PATTERN, (match) => { - const scheme = /^(Bearer|Basic|Token)\s+/.exec(match); - return scheme ? `${scheme[1]} [redacted]` : '[redacted]'; - }); + return value + .replace(URL_USERINFO_PATTERN, '$1[redacted]@') + .replace(SECRET_TOKEN_PATTERN, (match) => { + const scheme = /^(Bearer|Basic|Token)\s+/.exec(match); + return scheme ? `${scheme[1]} [redacted]` : '[redacted]'; + }); } function sanitizeValue(value: unknown, depth: number): unknown { diff --git a/src/services/logging/log-types.ts b/src/services/logging/log-types.ts index 10f29d9d..c48892e1 100644 --- a/src/services/logging/log-types.ts +++ b/src/services/logging/log-types.ts @@ -41,6 +41,7 @@ export interface LogErrorInfo { message: string; code?: string; stack?: string; + cause?: LogErrorInfo; } export interface LogEntry { diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index d8008462..57468cef 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -9,6 +9,7 @@ export const ANTHROPIC_MODEL_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_FABLE_MODEL', ] as const; export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; diff --git a/src/types/config.ts b/src/types/config.ts index 32c7f286..74a0ec7f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -121,9 +121,9 @@ export interface ProfileMetadata { context_group?: string; /** Shared continuity depth when context_mode='shared' */ continuity_mode?: 'standard' | 'deeper'; - /** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */ + /** Account-level shared resource behavior for plugins, commands, skills, agents, settings.json, and CLAUDE.md */ shared_resource_mode?: 'shared' | 'profile-local'; - /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ + /** Bare profile: no shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */ bare?: boolean; } diff --git a/src/utils/fetch-proxy-setup.ts b/src/utils/fetch-proxy-setup.ts index d71b4039..894da2eb 100644 --- a/src/utils/fetch-proxy-setup.ts +++ b/src/utils/fetch-proxy-setup.ts @@ -47,9 +47,12 @@ class RoutingProxyDispatcher extends Dispatcher { close(): Promise; close(callback: () => void): void; close(callback?: () => void): Promise | void { - const promise = Promise.all(this.getDispatchers().map((dispatcher) => dispatcher.close())).then( - () => undefined - ); + const promise = Promise.all( + this.getDispatchers().map((dispatcher) => { + const close = (dispatcher as unknown as { close?: () => Promise }).close; + return typeof close === 'function' ? close.call(dispatcher) : Promise.resolve(); + }) + ).then(() => undefined); if (callback) { promise.then( @@ -73,7 +76,14 @@ class RoutingProxyDispatcher extends Dispatcher { const error = typeof errOrCallback === 'function' ? null : errOrCallback; const done = typeof errOrCallback === 'function' ? errOrCallback : callback; const promise = Promise.all( - this.getDispatchers().map((dispatcher) => dispatcher.destroy(error ?? null)) + this.getDispatchers().map((dispatcher) => { + const destroy = ( + dispatcher as unknown as { destroy?: (error: Error | null) => Promise } + ).destroy; + return typeof destroy === 'function' + ? destroy.call(dispatcher, error ?? null) + : Promise.resolve(); + }) ).then(() => undefined); if (done) { @@ -158,11 +168,6 @@ export function applyGlobalFetchProxy(): { enabled: boolean; error?: string } { } } -const setupResult = applyGlobalFetchProxy(); -if (setupResult.error) { - console.error(`[!] Skipping global fetch proxy setup: ${setupResult.error}`); -} - function resolveGlobalFetchProxyConfig(): GlobalFetchProxyConfig { const httpProxy = getProxyResolution(false, process.env, { allowedProtocols: FETCH_PROXY_PROTOCOLS, diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 68a9f1f6..b68b10af 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -18,6 +18,7 @@ import { import SharedManager from '../management/shared-manager'; import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; +import { ANTHROPIC_MODEL_ENV_KEYS as SHARED_ANTHROPIC_MODEL_ENV_KEYS } from '../shared/extended-context-utils'; /** * Strip ANTHROPIC_* env vars from an environment object. @@ -40,17 +41,13 @@ export const ANTHROPIC_ROUTING_ENV_KEYS = [ 'ANTHROPIC_API_KEY', ]; const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS); -// NOTE: This is the intentional routing-overlay SUPERSET of model env keys -// (includes ANTHROPIC_SMALL_FAST_MODEL). A separate 4-key `ANTHROPIC_MODEL_ENV_KEYS` -// exists in src/shared/extended-context-utils.ts (re-exported as MODEL_ENV_VAR_KEYS). -// The two are NOT interchangeable — import deliberately by purpose. See issue #1609. +// NOTE: This is the intentional routing-overlay SUPERSET of shared model env keys +// (includes legacy ANTHROPIC_SMALL_FAST_MODEL). Keep the shared list authoritative +// for current tier mappings while preserving the legacy runtime alias. See issue #1609. export const ANTHROPIC_MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ...SHARED_ANTHROPIC_MODEL_ENV_KEYS, 'ANTHROPIC_SMALL_FAST_MODEL', -]; +] as const; const TMUX_SYNC_ENV_KEYS = [ 'CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', diff --git a/src/utils/sync-lock-retry.ts b/src/utils/sync-lock-retry.ts new file mode 100644 index 00000000..1e984fc9 --- /dev/null +++ b/src/utils/sync-lock-retry.ts @@ -0,0 +1,115 @@ +import * as fs from 'fs'; +import { performance } from 'perf_hooks'; +import * as lockfile from 'proper-lockfile'; + +const DEFAULT_STALE_MS = 10000; +const DEFAULT_RETRY_DELAY_MS = 200; +const DEFAULT_RETRY_TIMEOUT_MS = 10000; + +interface SyncLockRetryOptions { + description: string; + staleMs?: number; + retryDelayMs?: number; + retryTimeoutMs?: number; +} + +export function withSyncLockRetry( + lockTarget: string, + callback: () => T, + options: SyncLockRetryOptions +): T { + const staleMs = options.staleMs ?? DEFAULT_STALE_MS; + const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + const retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS; + const startedAt = performance.now(); + let release: (() => void) | undefined; + let contendedLockPath: string | undefined; + let lastContentionError: unknown; + + for (;;) { + if (contendedLockPath && fs.existsSync(contendedLockPath)) { + sleepBeforeRetry( + startedAt, + retryDelayMs, + retryTimeoutMs, + options.description, + lastContentionError + ); + continue; + } + + if (contendedLockPath && performance.now() - startedAt >= retryTimeoutMs) { + throw buildLockTimeoutError(options.description, retryTimeoutMs, lastContentionError); + } + + try { + release = lockfile.lockSync(lockTarget, { stale: staleMs }) as () => void; + break; + } catch (error) { + if (!isLockContentionError(error)) { + throw error; + } + contendedLockPath ??= `${fs.realpathSync(lockTarget)}.lock`; + lastContentionError = error; + sleepBeforeRetry(startedAt, retryDelayMs, retryTimeoutMs, options.description, error); + } + } + + try { + return callback(); + } finally { + try { + release(); + } catch { + // Best-effort release. + } + } +} + +function isLockContentionError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === 'ELOCKED' || code === 'ENOTACQUIRED'; +} + +function sleepSync(ms: number): void { + if (ms <= 0) { + return; + } + + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + } catch { + const deadline = performance.now() + ms; + while (performance.now() < deadline) { + // Fall back for runtimes without Atomics.wait. + } + } +} + +function sleepBeforeRetry( + startedAt: number, + retryDelayMs: number, + retryTimeoutMs: number, + description: string, + cause: unknown +): void { + const elapsedMs = performance.now() - startedAt; + if (elapsedMs >= retryTimeoutMs) { + throw buildLockTimeoutError(description, retryTimeoutMs, cause); + } + sleepSync(Math.min(retryDelayMs, retryTimeoutMs - elapsedMs)); +} + +function buildLockTimeoutError( + description: string, + timeoutMs: number, + cause: unknown +): NodeJS.ErrnoException { + const causeCode = (cause as NodeJS.ErrnoException | undefined)?.code; + const error = new Error( + `Failed to acquire ${description} after ${timeoutMs}ms; another CCS process may still be updating CLIProxy state. Wait for it to finish, then retry.` + ) as NodeJS.ErrnoException & { cause?: unknown }; + error.code = causeCode === 'ENOTACQUIRED' ? 'ENOTACQUIRED' : 'ELOCKED'; + error.cause = cause; + return error; +} diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 8b021ca2..b542cb2e 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -73,6 +73,8 @@ export { // Re-export Claude launch arg helpers export { appendThirdPartyWebSearchToolArgs } from './websearch/claude-tool-args'; +export { resolveWebSearchLaunchState } from './websearch/launch-state'; +export type { WebSearchConfigSnapshot, WebSearchLaunchState } from './websearch/launch-state'; // Re-export trace helpers export { diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index 0ff85d04..6a860c39 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -135,9 +135,14 @@ function ensureWebSearchSteeringPrompt(args: string[]): string[] { return [...optionArgs, ...steeringArgs, ...trailingArgs]; } -export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] { +export function appendThirdPartyWebSearchToolArgs( + args: string[], + webSearchEnabled = true +): string[] { // Claude subcommands (agents, doctor, mcp, ...) reject top-level session flags // like `--append-system-prompt` and `--disallowedTools`. Issue #1218. if (isClaudeSubcommandInvocation(args)) return args; - return ensureWebSearchSteeringPrompt(ensureDisallowedNativeWebSearchTool(args)); + + const disallowedArgs = ensureDisallowedNativeWebSearchTool(args); + return webSearchEnabled ? ensureWebSearchSteeringPrompt(disallowedArgs) : disallowedArgs; } diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index c1342df4..7640dd02 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -14,6 +14,7 @@ import { getCcsHooksDir } from '../config-manager'; import { getClaudeSettingsPath } from '../claude-config-path'; import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; import { getWebSearchConfig } from '../../config/config-loader-facade'; +import type { WebSearchConfigSnapshot } from './launch-state'; // Hook file name const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; @@ -35,9 +36,10 @@ export function getHookPath(): string { * Get WebSearch hook configuration for settings.json * Timeout is computed from max provider timeout in config.yaml + buffer */ -export function getWebSearchHookConfig(): Record { +export function getWebSearchHookConfig( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): Record { const hookPath = getHookPath(); - const wsConfig = getWebSearchConfig(); // Compute max timeout from enabled providers const timeouts: number[] = []; diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index 8a95bcbe..8eb5a83e 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -9,6 +9,7 @@ import { normalizeSearxngBaseUrl } from './types'; import { resolveAllowedWebSearchTraceFile } from './trace'; import { getWebSearchConfig } from '../../config/config-loader-facade'; +import type { WebSearchConfigSnapshot } from './launch-state'; /** * Get environment variables for WebSearch hook configuration. @@ -17,8 +18,9 @@ import { getWebSearchConfig } from '../../config/config-loader-facade'; * * @returns Record of environment variables to set before spawning Claude */ -export function getWebSearchHookEnv(): Record { - const wsConfig = getWebSearchConfig(); +export function getWebSearchHookEnv( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): Record { const env: Record = { CCS_WEBSEARCH_ENABLED: '0', CCS_WEBSEARCH_SKIP: '0', diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index 64a18797..02d1429c 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -13,6 +13,7 @@ import { info, warn } from '../ui'; import { getCcsDir, getCcsHooksDir } from '../config-manager'; import { getHookPath } from './hook-config'; import { getWebSearchConfig } from '../../config/config-loader-facade'; +import type { WebSearchConfigSnapshot } from './launch-state'; // Re-export from hook-config for backward compatibility export { getHookPath, getWebSearchHookConfig } from './hook-config'; @@ -76,10 +77,10 @@ export function hasWebSearchHook(): boolean { * * @returns true if hook installed successfully */ -export function installWebSearchHook(): boolean { +export function installWebSearchHook( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): boolean { try { - const wsConfig = getWebSearchConfig(); - // Skip if disabled if (!wsConfig.enabled) { if (process.env.CCS_DEBUG) { diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts index 8d746801..c69b9b7f 100644 --- a/src/utils/websearch/index.ts +++ b/src/utils/websearch/index.ts @@ -71,6 +71,8 @@ export { // Claude launch args export { appendThirdPartyWebSearchToolArgs } from './claude-tool-args'; +export { resolveWebSearchLaunchState } from './launch-state'; +export type { WebSearchConfigSnapshot, WebSearchLaunchState } from './launch-state'; // Trace helpers export { diff --git a/src/utils/websearch/launch-state.ts b/src/utils/websearch/launch-state.ts new file mode 100644 index 00000000..1d1c606b --- /dev/null +++ b/src/utils/websearch/launch-state.ts @@ -0,0 +1,19 @@ +import { getWebSearchConfig } from '../../config/config-loader-facade'; +import { getWebSearchHookEnv } from './hook-env'; + +export type WebSearchConfigSnapshot = ReturnType; + +export interface WebSearchLaunchState { + config: WebSearchConfigSnapshot; + enabled: boolean; + hookEnv: Record; +} + +export function resolveWebSearchLaunchState(): WebSearchLaunchState { + const config = getWebSearchConfig(); + return { + config, + enabled: config.enabled, + hookEnv: getWebSearchHookEnv(config), + }; +} diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts index 5944e500..e8965756 100644 --- a/src/utils/websearch/mcp-installer.ts +++ b/src/utils/websearch/mcp-installer.ts @@ -16,6 +16,7 @@ import { isClaudeUserConfigLockUnavailableError as isLockUnavailableError, withClaudeUserConfigLock, } from '../claude-user-config-lock'; +import type { WebSearchConfigSnapshot } from './launch-state'; const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs'; const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch'; @@ -189,14 +190,15 @@ function removeManagedServerConfig(configPath: string): boolean { } } -export function installWebSearchMcpServer(): boolean { - const wsConfig = getWebSearchConfig(); +export function installWebSearchMcpServer( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): boolean { if (!wsConfig.enabled) { appendWebSearchTrace('websearch_mcp_install_skipped', { reason: 'disabled' }); return false; } - if (!installWebSearchHook()) { + if (!installWebSearchHook(wsConfig)) { appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'hook_unavailable' }); if (process.env.CCS_DEBUG) { process.stderr.write( @@ -267,8 +269,9 @@ export function installWebSearchMcpServer(): boolean { } } -export function ensureWebSearchMcpConfig(): boolean { - const wsConfig = getWebSearchConfig(); +export function ensureWebSearchMcpConfig( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): boolean { if (!wsConfig.enabled) { appendWebSearchTrace('websearch_mcp_config_skipped', { reason: 'disabled' }); return false; @@ -366,15 +369,16 @@ export function ensureWebSearchMcpConfig(): boolean { } } -export function ensureWebSearchMcp(): boolean { - const wsConfig = getWebSearchConfig(); +export function ensureWebSearchMcp( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): boolean { if (!wsConfig.enabled) { appendWebSearchTrace('websearch_mcp_ensure_skipped', { reason: 'disabled' }); return false; } - const installed = installWebSearchMcpServer(); - const configured = installed && ensureWebSearchMcpConfig(); + const installed = installWebSearchMcpServer(wsConfig); + const configured = installed && ensureWebSearchMcpConfig(wsConfig); appendWebSearchTrace('websearch_mcp_ensure_result', { installed, configured }); return installed && configured; } @@ -428,13 +432,14 @@ export function uninstallWebSearchMcp(): boolean { return removedConfig || removedServer; } -export function ensureWebSearchMcpOrThrow(): void { - const wsConfig = getWebSearchConfig(); +export function ensureWebSearchMcpOrThrow( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): void { if (!wsConfig.enabled) { return; } - if (!ensureWebSearchMcp()) { + if (!ensureWebSearchMcp(wsConfig)) { throw new Error('WebSearch is enabled, but CCS could not prepare the local WebSearch tool.'); } } @@ -447,7 +452,6 @@ export function ensureWebSearchMcpOrThrow(): void { * native WebSearch and inject fallback steering while the constrained MCP * search path is unavailable. */ -export function ensureWebSearchMcpForLaunch(): boolean { - ensureWebSearchMcpOrThrow(); - return true; +export function ensureWebSearchMcpForLaunch(wsConfig: WebSearchConfigSnapshot): void { + ensureWebSearchMcpOrThrow(wsConfig); } diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index af16138c..946fd28f 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -18,6 +18,7 @@ import { getOpenCodeCliStatus } from './opencode-cli'; import { getWebSearchApiKeyStates } from './provider-secrets'; import { normalizeSearxngBaseUrl, type WebSearchCliInfo, type WebSearchStatus } from './types'; import { getWebSearchConfig } from '../../config/config-loader-facade'; +import type { WebSearchConfigSnapshot } from './launch-state'; const PROVIDER_STATE_FILE = 'websearch-provider-state.json'; @@ -112,8 +113,7 @@ function applyCooldownStatus( }; } -function getLegacyProviderStatuses(): WebSearchCliInfo[] { - const wsConfig = getWebSearchConfig(); +function getLegacyProviderStatuses(wsConfig: WebSearchConfigSnapshot): WebSearchCliInfo[] { const agyStatus = getAgyCliStatus(); const geminiStatus = getGeminiCliStatus(); const grokStatus = getGrokCliStatus(); @@ -197,8 +197,9 @@ function getLegacyProviderStatuses(): WebSearchCliInfo[] { /** * Get all WebSearch providers with their current status. */ -export function getWebSearchCliProviders(): WebSearchCliInfo[] { - const wsConfig = getWebSearchConfig(); +export function getWebSearchCliProviders( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): WebSearchCliInfo[] { const apiKeyStates = getWebSearchApiKeyStates(); const cooldowns = readProviderCooldowns(); const providers: WebSearchCliInfo[] = [ @@ -283,7 +284,7 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] { }, ]; - return [...providers, ...getLegacyProviderStatuses()].map((provider) => + return [...providers, ...getLegacyProviderStatuses(wsConfig)].map((provider) => applyCooldownStatus(provider, cooldowns) ); } @@ -355,17 +356,20 @@ export function buildWebSearchReadiness( /** * Get WebSearch readiness status for display. */ -export function getWebSearchReadiness(): WebSearchStatus { - const wsConfig = getWebSearchConfig(); - const providers = getWebSearchCliProviders(); +export function getWebSearchReadiness( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): WebSearchStatus { + const providers = getWebSearchCliProviders(wsConfig); return buildWebSearchReadiness(wsConfig.enabled, providers); } /** * Display WebSearch status (single line, equilibrium UX). */ -export function displayWebSearchStatus(): void { - const status = getWebSearchReadiness(); +export function displayWebSearchStatus( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): void { + const status = getWebSearchReadiness(wsConfig); switch (status.readiness) { case 'ready': diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index c07f4e3a..b4eec3e5 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -331,6 +331,28 @@ const PRICING_REGISTRY: Record = { fast: OPUS_48_FAST_RATES, }, }, + // Claude Opus 5 ($5/$25) — Claude 5 generation Opus (released 2026-07-24). + // Rates mirror Opus 4.8 (same Opus tier); fast mode uses Anthropic's documented + // $10/$50 rates. Registered explicitly so it resolves to + // Opus-tier pricing instead of the unknown-model fallback. + 'claude-opus-5': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_48_FAST_RATES, + }, + }, + 'claude-opus-5-thinking': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_48_FAST_RATES, + }, + }, // Claude Fable 5 ($10/$50) — most powerful tier, above Opus 'claude-fable-5': { inputPerMillion: 10.0, diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index f68328fc..2cc99fb0 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -243,6 +243,11 @@ function withTimeout(p: Promise, ms: number): Promise { }); } +/** Remaining milliseconds before one absolute request deadline. */ +function remainingRequestBudget(deadlineAt: number): number { + return Math.max(0, deadlineAt - Date.now()); +} + /** * Map a row to its wire shape. The native-only additions use snake_case parent * keys ("quota_windows" / "stale_as_of") to match the existing payload's mixed @@ -486,6 +491,7 @@ export function createBarRouter(deps: BarRouterDeps): Router { */ router.get('/summary', async (req: Request, res: Response): Promise => { try { + const deadlineAt = Date.now() + REQUEST_DEADLINE_MS; const wantsRefresh = req.query['refresh'] === 'true'; // Determine effective refresh mode after applying debounce. @@ -503,10 +509,20 @@ export function createBarRouter(deps: BarRouterDeps): Router { // else: debounce active — fall through to cache path } + // Start the native side-load immediately. It shares the same absolute + // response deadline as cost and CLIProxy quota work, so their individual + // fallback waits cannot stack into a multi-second tail. + const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]); + const getCachedNative = deps.getCachedNativeRows ?? (() => [] as BarSummaryRow[]); + const nativePromise = Promise.resolve().then(() => getNative({ force: doForceRefresh })); + // Cost side-load is bounded so a slow usage-snapshot read can't stall the // glance. (Health is per-account, derived from each quota result below — // no blocking system audit on the request path.) - const details = await withTimeout(deps.loadCliproxyDetails(), SIDELOAD_TIMEOUT_MS); + const details = await withTimeout( + deps.loadCliproxyDetails(), + Math.min(SIDELOAD_TIMEOUT_MS, remainingRequestBudget(deadlineAt)) + ); const costByAccount: Record = details ? deps.getTodayCostByAccount(details) : {}; @@ -565,24 +581,20 @@ export function createBarRouter(deps: BarRouterDeps): Router { return rows; })(); - const deadline = new Promise((resolve) => { - setTimeout(() => resolve(cacheRows()), REQUEST_DEADLINE_MS); - }); + const rows = (await withTimeout(gather, remainingRequestBudget(deadlineAt))) ?? cacheRows(); - const rows = await Promise.race([gather, deadline]); - - // Native subscription rows (Claude Code + Codex) are side-loaded AFTER the + // Native subscription rows (Claude Code + Codex) are joined after the // CLIProxy rows resolve, bounded so a slow/failed native fetch degrades // rather than blocking or erroring the response. Pass force so a // debounce-passing refresh also re-pulls native rows live. On timeout fall // back to the last-known cached native rows (NOT []) so a slow forced // re-pull never momentarily drops the Claude/Codex cards; the in-flight // fetch keeps warming the cache for the next poll. - const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]); - const getCachedNative = deps.getCachedNativeRows ?? (() => [] as BarSummaryRow[]); const nativeRows = - (await withTimeout(getNative({ force: doForceRefresh }), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? - getCachedNative(); + (await withTimeout( + nativePromise, + Math.min(NATIVE_SIDELOAD_TIMEOUT_MS, remainingRequestBudget(deadlineAt)) + )) ?? getCachedNative(); res.json([...rows, ...nativeRows].map(serializeBarRow)); } catch (err) { diff --git a/src/web-server/routes/cliproxy-routing-routes.ts b/src/web-server/routes/cliproxy-routing-routes.ts index e9bf9e22..d104bd43 100644 --- a/src/web-server/routes/cliproxy-routing-routes.ts +++ b/src/web-server/routes/cliproxy-routing-routes.ts @@ -8,6 +8,11 @@ import { readCliproxyRoutingState, readCliproxySessionAffinityState, } from '../../cliproxy/routing/routing-strategy'; +import { + applyCliproxyRetrySettings, + normalizeCliproxyRetryValue, + readCliproxyRetryState, +} from '../../cliproxy/routing/retry-settings'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); @@ -84,4 +89,34 @@ router.put('/routing/session-affinity', async (req: Request, res: Response): Pro } }); +router.get('/retry', async (_req: Request, res: Response): Promise => { + try { + res.json(await readCliproxyRetryState()); + } catch (error) { + res.status(502).json({ error: (error as Error).message }); + } +}); + +router.put('/retry', async (req: Request, res: Response): Promise => { + const requestRetry = normalizeCliproxyRetryValue(req.body?.request_retry); + const maxRetryInterval = normalizeCliproxyRetryValue(req.body?.max_retry_interval); + if (requestRetry === null || maxRetryInterval === null) { + res.status(400).json({ + error: 'Invalid retry payload. Use non-negative safe integers for both retry fields.', + }); + return; + } + + try { + res.json( + await applyCliproxyRetrySettings({ + request_retry: requestRetry, + max_retry_interval: maxRetryInterval, + }) + ); + } catch (error) { + res.status(502).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 7f26df4e..27b17f26 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -60,6 +60,7 @@ const MODEL_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_FABLE_MODEL', ] as const; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; const SETTINGS_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/; diff --git a/src/web-server/usage/claude-native-credentials.ts b/src/web-server/usage/claude-native-credentials.ts index c4da718f..c2e84a1a 100644 --- a/src/web-server/usage/claude-native-credentials.ts +++ b/src/web-server/usage/claude-native-credentials.ts @@ -15,7 +15,8 @@ */ import { existsSync, readFileSync } from 'node:fs'; -import { execSync } from 'node:child_process'; +import { execFile } from 'node:child_process'; +import * as crypto from 'node:crypto'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -36,10 +37,20 @@ export interface CredentialReaderDeps { homedir?: string; existsSyncImpl?: (p: string) => boolean; readFileSyncImpl?: (p: string) => string; - execSyncImpl?: (cmd: string, opts: Record) => string | Buffer; + execFileImpl?: ExecFileImpl; + keychainTimeoutMs?: number; } +type ExecFileCallback = (error: Error | null, stdout: string | Buffer) => void; +type ExecFileImpl = ( + file: string, + args: readonly string[], + options: Record, + callback: ExecFileCallback +) => { kill?: () => void } | void; + const KEYCHAIN_SERVICE = 'Claude Code-credentials'; +const SECURITY_PATH = '/usr/bin/security'; const KEYCHAIN_TIMEOUT_MS = 5000; /** Subscription types that mean "no real subscription" -> skip the fetch. */ @@ -67,14 +78,13 @@ function parseCredentials(raw: string): ClaudeNativeCredentials | null { * Keychain as a fallback. Returns null when neither source yields a parseable * object. */ -export function readClaudeCredentials( +export async function readClaudeCredentials( deps: CredentialReaderDeps = {} -): ClaudeNativeCredentials | null { +): Promise { const platform = deps.platform ?? os.platform(); const homedir = deps.homedir ?? os.homedir(); const existsImpl = deps.existsSyncImpl ?? existsSync; const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8')); - const execImpl = deps.execSyncImpl ?? execSync; const credentialsPath = path.join(homedir, '.claude', '.credentials.json'); if (existsImpl(credentialsPath)) { @@ -87,20 +97,97 @@ export function readClaudeCredentials( } if (platform === 'darwin') { + const parsed = await readCredentialsFromKeychainService(KEYCHAIN_SERVICE, deps); + if (parsed) return parsed; + } + + return null; +} + +/** Read + parse one Keychain generic-password item. Returns null on any failure. */ +async function readCredentialsFromKeychainService( + service: string, + deps: CredentialReaderDeps +): Promise { + const timeoutMs = deps.keychainTimeoutMs ?? KEYCHAIN_TIMEOUT_MS; + const execImpl: ExecFileImpl = + deps.execFileImpl ?? + ((file, args, options, callback) => + execFile(file, [...args], options, (error, stdout) => callback(error, stdout))); + + return new Promise((resolve) => { + let settled = false; + let child: { kill?: () => void } | void; + const finish = (credentials: ClaudeNativeCredentials | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(credentials); + }; + const timer = setTimeout(() => { + child?.kill?.(); + finish(null); + }, timeoutMs); try { - const out = execImpl(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`, { - timeout: KEYCHAIN_TIMEOUT_MS, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - }); - const raw = (typeof out === 'string' ? out : out.toString('utf8')).trim(); - if (raw) { - const parsed = parseCredentials(raw); - if (parsed) return parsed; - } + child = execImpl( + SECURITY_PATH, + ['find-generic-password', '-s', service, '-w'], + { + timeout: timeoutMs, + encoding: 'utf8', + windowsHide: true, + maxBuffer: 1024 * 1024, + }, + (error, stdout) => { + if (error) return finish(null); + const raw = (typeof stdout === 'string' ? stdout : stdout.toString('utf8')).trim(); + finish(raw ? parseCredentials(raw) : null); + } + ); } catch { - // no Keychain entry / access denied -> null + finish(null); } + }); +} + +/** + * Keychain service name Claude Code uses for a non-default CLAUDE_CONFIG_DIR: + * "Claude Code-credentials-". + */ +export function claudeKeychainServiceForConfigDir(configDir: string): string { + const hash = crypto.createHash('sha256').update(configDir).digest('hex').slice(0, 8); + return `${KEYCHAIN_SERVICE}-${hash}`; +} + +/** + * Read the Claude Code credentials for a specific CLAUDE_CONFIG_DIR (e.g. an + * isolated `ccs auth` instance directory). + * + * File-first (/.credentials.json, no prompt), then the macOS + * Keychain item derived from the config dir path. On macOS Claude Code stores + * OAuth tokens in the Keychain by default, so without the Keychain fallback + * every isolated profile looks permanently logged-out to the bar. + */ +export async function readClaudeCredentialsForConfigDir( + configDir: string, + deps: CredentialReaderDeps = {} +): Promise { + const platform = deps.platform ?? os.platform(); + const existsImpl = deps.existsSyncImpl ?? existsSync; + const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8')); + + const credentialsPath = path.join(configDir, '.credentials.json'); + if (existsImpl(credentialsPath)) { + try { + const parsed = parseCredentials(readImpl(credentialsPath)); + if (parsed) return parsed; + } catch { + // fall through to Keychain + } + } + + if (platform === 'darwin') { + return readCredentialsFromKeychainService(claudeKeychainServiceForConfigDir(configDir), deps); } return null; diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index ecf942c3..a5b87fb2 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -14,9 +14,9 @@ * - circuit breaker stops calling after repeated 429s for a cooldown * - serve-stale-on-failure; only omit a row when there is genuinely no data * - * Claude path: reads per-profile .credentials.json (file-only, NO keychain) - * and polls api.anthropic.com/api/oauth/usage. If the file is absent the - * profile is emitted as a parked row (paused:true) — never a keychain call. + * Claude path: reads per-profile .credentials.json, then the per-config-dir + * Keychain item, and polls api.anthropic.com/api/oauth/usage. If neither source + * yields credentials the profile is emitted as a parked row (paused:true). * * Codex path: PRIMARY = live network (chatgpt.com/backend-api/wham/usage, via * fetchCodexQuota), FALLBACK = local session logs (getCodexLocalQuota), mirroring @@ -29,13 +29,15 @@ * is maintained — at most 2 live upstream calls per /summary regardless of * profile count. * - * NO macOS Keychain access anywhere in this module. The old global-default - * Claude reader (readClaudeCredentials) is kept for back-compat but is no longer - * used by the multi-profile path. + * Claude per-profile reads are file-first with a per-config-dir macOS Keychain + * fallback (Claude Code stores OAuth tokens in the Keychain by default on + * macOS). The old global-default Claude reader (readClaudeCredentials) is kept + * for back-compat but is no longer used by the multi-profile path. */ import { readClaudeCredentials, + readClaudeCredentialsForConfigDir, getAccessToken, getSubscriptionTier, hasSupportedSubscription, @@ -108,13 +110,15 @@ const CODEX_PROVIDER = CODEX_NATIVE_PROVIDER; export interface NativeQuotaDeps { /** Read the native Claude Code credentials (global default path). */ - readCredentials?: () => ClaudeNativeCredentials | null; + readCredentials?: () => ClaudeNativeCredentials | null | Promise; /** - * Read credentials for a specific Claude profile (file-only, no keychain). + * Read credentials for a specific Claude profile (file-first, Keychain fallback). * Injected so tests never touch real fs or Keychain. * profile: the profile name (e.g. "work"); returns null when absent/unparseable. */ - readClaudeCredentialsForProfile?: (profile: string) => ClaudeNativeCredentials | null; + readClaudeCredentialsForProfile?: ( + profile: string + ) => ClaudeNativeCredentials | null | Promise; /** Fetch Claude quota with a directly-supplied native token. */ fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise; /** @@ -487,43 +491,39 @@ function serveCached(state: ProviderState): BarSummaryRow | null { } // ============================================================================ -// File-only Claude credentials reader for per-profile paths (NO keychain) +// Per-profile Claude credentials reader (file-first, Keychain fallback) // ============================================================================ /** - * Read credentials for a specific Claude Code profile (file-only, no keychain). + * Read credentials for a specific Claude Code profile (file-first, Keychain fallback). * - * Looks for .credentials.json in the profile's instance directory. If the file - * is absent or unparseable, returns null — the caller emits a parked row. - * Never calls security/Keychain — zero new keychain access from this feature. + * Looks for .credentials.json in the profile's instance directory, then uses + * the bounded per-config-dir macOS Keychain fallback. If neither yields a + * parseable credential object, the caller emits a parked row. */ -function readClaudeCredentialsForProfileFromDisk( +async function readClaudeCredentialsForProfileFromDisk( profile: string, - readDefaultCredentials: () => ClaudeNativeCredentials | null = readClaudeCredentials -): ClaudeNativeCredentials | null { + readDefaultCredentials: () => + | ClaudeNativeCredentials + | null + | Promise = readClaudeCredentials +): Promise { try { const instanceDir = path.join(getCcsDir(), 'instances', profile); - const credFile = path.join(instanceDir, '.credentials.json'); - if (fs.existsSync(credFile)) { - const raw = fs.readFileSync(credFile, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as ClaudeNativeCredentials; - } - return null; - } // The bare `ccs` default login uses the standard global credential lookup: // ~/.claude/.credentials.json, falling back to the single global // "Claude Code-credentials" Keychain item that Claude Code itself maintains. - // This is the ONE pre-existing global read the shipped Bar already performs -- - // NOT a per-profile Keychain scan. Isolated `ccs auth` profiles stay - // file-only and never touch the Keychain; a real instance directory named - // "default" is therefore parked when its file is absent. if (profile === DEFAULT_PROFILE && !fs.existsSync(instanceDir)) { - return readDefaultCredentials(); + return await readDefaultCredentials(); } - return null; + + // Isolated `ccs auth` instance: /.credentials.json first, then + // the per-config-dir Keychain item Claude Code maintains for this + // CLAUDE_CONFIG_DIR ("Claude Code-credentials-"). On + // macOS Claude Code stores tokens in the Keychain by default, so without + // the Keychain read every isolated profile is permanently parked. + return await readClaudeCredentialsForConfigDir(instanceDir); } catch { return null; } @@ -787,6 +787,72 @@ function markDefault(row: BarSummaryRow, isDefault: boolean): BarSummaryRow { return { ...row, is_default: isDefault }; } +/** + * Force paused:true on a non-default row AND its cached copy. Non-default rows + * always render dimmed (only the default profile is "active"), including the + * pass where the rotating live slot refreshed them — otherwise the row would + * flicker active for one poll and dim again on the next. + */ +function markPausedAndSyncCache( + map: Map, + profile: string, + row: BarSummaryRow +): BarSummaryRow { + const state = map.get(profile); + if (state?.cachedRow) { + state.cachedRow = { ...state.cachedRow, paused: true }; + } + return { ...row, paused: true }; +} + +/** + * True when the cached row was fetched BEFORE its own next_reset boundary and + * that boundary has now passed — the row describes the previous quota window, + * so its values (and the reset time itself) are visibly wrong in the bar. + * The cachedAt guard means a post-reset payload that still reports a past + * reset cannot cause a refetch loop: once re-fetched, normal TTL applies. + */ +function isCachedRowStaleByReset(state: ProviderState, now: number): boolean { + const nextReset = state.cachedRow?.next_reset; + if (!nextReset) return false; + const resetMs = Date.parse(nextReset); + if (!Number.isFinite(resetMs)) return false; + return resetMs <= now && state.cachedAt < resetMs; +} + +/** + * Pick the non-default profile the rotating live slot should refresh this pass: + * the stalest profile whose cached row is missing, past its TTL, or past its + * own quota reset, skipping profiles inside a breaker/cooldown window (their + * collector would refuse the fetch anyway, wasting the slot). Returns null + * when every profile is fresh. + */ +function pickRotatingLiveProfile( + map: Map, + profiles: string[], + defaultProfile: string | null, + now: number +): string | null { + let picked: string | null = null; + let pickedAt = Number.POSITIVE_INFINITY; + for (const p of profiles) { + if (p === defaultProfile) continue; + const state = map.get(p); + if (state && (now < state.breakerOpenUntil || now < state.cooldownUntil)) continue; + const cachedRow = state?.cachedRow ?? null; + const cachedAt = state?.cachedAt ?? 0; + if (cachedRow && state) { + const ttl = cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; + if (now - cachedAt < ttl && !isCachedRowStaleByReset(state, now)) continue; + } + if (cachedAt < pickedAt) { + pickedAt = cachedAt; + picked = p; + } + } + return picked; +} + /** * Tag the row with is_default AND write the flag back onto the cached copy. The * collector caches a row before the default profile is known (the default is @@ -819,9 +885,13 @@ async function collectClaudeRowForProfile( // Serve from cache while within TTL — force bypasses the short-circuit. Parked // rows (no creds -> quotaStatus 'unsupported') use a short TTL so a fresh login // is picked up within seconds instead of staying dimmed for the full quota TTL. + // A row whose own next_reset has passed is stale regardless of TTL — the + // quota snapped back at the boundary and the cached values are visibly wrong. if (!force && state.cachedRow) { const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; - if (now - state.cachedAt < ttl) return serveCached(state); + if (now - state.cachedAt < ttl && !isCachedRowStaleByReset(state, now)) { + return serveCached(state); + } } // Breaker open or cooldown active -> zero network, serve stale (may be null). @@ -835,7 +905,7 @@ async function collectClaudeRowForProfile( return state.pending; } - // For per-profile reads: use the injected seam (file-only, no keychain). + // For per-profile reads: use the injected seam (file-first, Keychain fallback). const readDefaultCredentials = deps.readCredentials ?? readClaudeCredentials; const readCreds = deps.readClaudeCredentialsForProfile ?? @@ -851,12 +921,12 @@ async function collectClaudeRowForProfile( // pending DURING assignment, leaving a stale resolved promise that the // next call's coalescing check would return instead of re-evaluating. await Promise.resolve(); - const creds = readCreds(profile); + const creds = await readCreds(profile); // No credentials file found -> emit parked row (needs auth, file absent). // This is the expected case when the profile exists in the registry but the - // user has not logged in via 'ccs auth' for this machine or the credentials - // are stored only in keychain (which we deliberately do not access here). + // user has not logged in via 'ccs auth' for this machine or neither the + // profile file nor its bounded macOS Keychain fallback yielded credentials. if (!creds) { const parkedRow = buildParkedClaudeProfileRow(profile, now); // Cache the parked row so repeated calls don't re-stat the fs. @@ -967,9 +1037,12 @@ async function collectCodexRowForProfile( // Serve from cache while within TTL — force bypasses the short-circuit. Parked // rows (no auth -> quotaStatus 'unsupported') use a short TTL so a fresh login // is picked up within seconds instead of staying dimmed for the full quota TTL. + // A row whose own next_reset has passed is stale regardless of TTL. if (!force && state.cachedRow) { const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; - if (now - state.cachedAt < ttl) return serveCached(state); + if (now - state.cachedAt < ttl && !isCachedRowStaleByReset(state, now)) { + return serveCached(state); + } } // Breaker open or cooldown active -> skip network, go to LOCAL fallback. @@ -1170,7 +1243,7 @@ async function collectClaudeRow( state.pending = (async (): Promise => { try { - const creds = readCredentialsFn(); + const creds = await readCredentialsFn(); // No token / unsupported subscription -> never spend a call, omit the row. if (!creds || !hasSupportedSubscription(creds)) { return serveCached(state); @@ -1579,13 +1652,28 @@ async function getNativeAccountRowsMultiProfile( const results: (BarSummaryRow | null)[] = []; const now = (deps.now ?? Date.now)(); - // Preserve the safety budget: only the active/default profile for each surface - // may perform a live refresh. Non-default profiles are cache-only (or parked) - // so one /summary request can trigger at most one Claude and one Codex live - // upstream call regardless of configured profile count. + // Preserve the safety budget: the active/default profile for each surface is + // live-polled every pass, plus ONE rotating live slot for the stalest + // non-default profile. All other profiles are cache-only (or parked), so one + // /summary request triggers at most two Claude and two Codex live upstream + // calls regardless of configured profile count — every account converges to + // real quota within a few polls without per-profile fan-out. + const claudeRotating = pickRotatingLiveProfile( + claudeProfileStates, + claudeProfiles, + claudeDefault, + now + ); + const codexRotating = pickRotatingLiveProfile( + codexProfileStates, + codexProfiles, + codexDefault, + now + ); + for (const p of claudeProfiles) { const isDefault = p === claudeDefault; - if (!isDefault) { + if (!isDefault && p !== claudeRotating) { results.push( markDefaultAndSyncCache( claudeProfileStates, @@ -1602,14 +1690,18 @@ async function getNativeAccountRowsMultiProfile( deps, force || claudeProfileStates.get(p)?.cachedRow?.quotaStatus === 'unsupported' ) - .then((r) => (r ? markDefaultAndSyncCache(claudeProfileStates, p, r, true) : null)) + .then((r) => { + if (!r) return null; + const marked = markDefaultAndSyncCache(claudeProfileStates, p, r, isDefault); + return isDefault ? marked : markPausedAndSyncCache(claudeProfileStates, p, marked); + }) .catch(() => null) ); } for (const p of codexProfiles) { const isDefault = p === codexDefault; - if (!isDefault) { + if (!isDefault && p !== codexRotating) { results.push( markDefaultAndSyncCache( codexProfileStates, @@ -1626,7 +1718,11 @@ async function getNativeAccountRowsMultiProfile( deps, force || codexProfileStates.get(p)?.cachedRow?.quotaStatus === 'unsupported' ) - .then((r) => (r ? markDefaultAndSyncCache(codexProfileStates, p, r, true) : null)) + .then((r) => { + if (!r) return null; + const marked = markDefaultAndSyncCache(codexProfileStates, p, r, isDefault); + return isDefault ? marked : markPausedAndSyncCache(codexProfileStates, p, marked); + }) .catch(() => null) ); } diff --git a/tests/README.md b/tests/README.md index c5e4acf4..232058c6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,65 +1,79 @@ # CCS Test Suite -## Organization +Root TypeScript and JavaScript tests run with Bun's test runner. Native shell, +PowerShell, Docker, and standalone probes cover contracts that need a real +platform or process boundary. -``` -tests/ -├── unit/ # Module unit tests (Mocha) -│ ├── glmt/ # Legacy GLMT transformer/internal compatibility tests -│ └── delegation/ # Delegation module tests -├── npm/ # npm package tests (Mocha) -├── native/ # Native installation tests (bash/PowerShell) -│ ├── unix/ # Unix/Linux/macOS tests -│ └── windows/ # Windows PowerShell tests -├── integration/ # Integration + smoke tests -└── shared/ # Shared utilities - ├── fixtures/ # Test configuration and environment - ├── unit/ # Helper function tests - ├── helpers.sh # Bash test utilities - └── test-data.js # Test data for npm tests -``` +## Ownership -## Running Tests +| Area | Path | Use for | +| --- | --- | --- | +| Unit | [`unit/`](./unit/) | Focused module and command behavior | +| Integration | [`integration/`](./integration/) | Cross-module, process, proxy, auth, and web-server behavior | +| End to end | [`e2e/`](./e2e/) | Packaged CLI workflows | +| npm package | [`npm/`](./npm/) | Installation, exports, and package behavior | +| Native | [`native/`](./native/) | Unix and Windows shell behavior | +| Docker | [`docker/`](./docker/) | Compose and stable network/service contracts | +| Documentation | [`docs/`](./docs/) | Repository documentation invariants | +| Shared support | [`shared/`](./shared/) | Fixtures and helpers reused by suites | +| Mocks | [`mocks/`](./mocks/) | Bounded test doubles and fixtures | + +Some source domains also keep focused tests in `src/**/__tests__/`. Follow the +nearest established pattern and avoid moving tests solely for taxonomy. + +## Commands + +Commands are defined in [`../package.json`](../package.json). Bucket membership +and execution live in +[`../scripts/run-test-bucket.js`](../scripts/run-test-bucket.js). ```bash -bun run test # All automated tests (unit + integration + npm) -bun run test:unit # Unit tests only -bun run test:npm # npm package tests -bun run test:native # Native Unix tests (bash) +bun run test:fast # Fast Bun test bucket +bun run test:slow # Slow Bun test bucket +bun run test:all # All root non-e2e Bun test buckets +bun run test:unit # tests/unit +bun run test:npm # tests/npm +bun run test:native # Native Unix edge-case script +bun run test:e2e # tests/e2e with fail-fast and extended timeout +bun run test # Build, then test:all ``` -## Test Categories +For the normal contributor gate: -### Unit Tests (`unit/`) -Module-level tests using Mocha framework: -- `unit/glmt/` - Legacy transformer internals kept for Cursor translation compatibility -- `unit/delegation/` - Permission mode, session manager, result formatter +```bash +bun run format +bun run lint:fix +bun run validate +``` -### npm Tests (`npm/`) -npm package functionality tests using Mocha: -- `postinstall.test.js` - Postinstall behavior -- `cli.test.js` - CLI argument parsing -- `cross-platform.test.js` - Cross-platform compatibility -- `special-commands.test.js` - Integration tests +For the closest local equivalent to PR CI: -### Native Tests (`native/`) -Installation tests for curl|bash (Unix) and irm|iex (Windows): -- `native/unix/edge-cases.sh` - Unix edge case tests -- `native/windows/edge-cases.ps1` - Windows edge case tests +```bash +bun run validate:ci-parity +``` -### Integration Tests (`integration/`) -Integration and smoke coverage for scenarios that exercise multiple layers: -- Automated `*.test.ts` files run as part of `bun run test:all` and CI -- Shell and standalone probe scripts remain on-demand for targeted debugging -- `cursor-daemon-lifecycle.test.ts` - local daemon process + HTTP smoke coverage -- `image-analyzer-hook.test.ts` - hook integration coverage -- `glmt-integration-test.sh` - legacy GLMT compatibility smoke probe -- `symlink-chain-test.sh` - Symlink chain handling -- `ux-integration-test.sh` - CLI UX integration +Dashboard tests use Vitest and are documented in +[`../ui/README.md`](../ui/README.md). -## Adding New Tests +## Test Isolation -- **Unit tests**: Add to `unit//` for isolated module behavior -- **npm tests**: Add to `npm/` for package behavior -- **Native tests**: Add to `native/unix/` or `native/windows/` -- **Integration tests**: Add automated cross-layer smoke coverage to `integration/*.test.ts` +- Set `CCS_HOME` to a temporary directory. +- Never read or modify a contributor's real `~/.ccs/` or `~/.claude/`. +- Use `getCcsDir()` from + [`../src/utils/config-manager.ts`](../src/utils/config-manager.ts) for CCS + paths. +- Keep fixtures deterministic and free of credentials or private account data. +- Use real behavior at the boundary under test; do not weaken assertions to + hide regressions. + +## Adding Coverage + +- Add a focused unit test for isolated logic. +- Add integration coverage when behavior crosses modules, processes, HTTP, or + persistence boundaries. +- Add e2e coverage when command routing or packaged CLI behavior is the + contract. +- Add native or Docker coverage only when platform/runtime behavior cannot be + represented faithfully in Bun tests. +- Run the smallest relevant command first, then broaden to the contributor or + CI-parity gate when shared contracts changed. diff --git a/tests/docs/documentation-freshness.js b/tests/docs/documentation-freshness.js new file mode 100644 index 00000000..ee5c66bd --- /dev/null +++ b/tests/docs/documentation-freshness.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '../..'); +const failures = []; + +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +function requireText(relativePath, expected) { + if (!read(relativePath).includes(expected)) { + failures.push(`${relativePath} is missing: ${expected}`); + } +} + +function collectFiles(directory, filePattern) { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(absolutePath, filePattern)); + } else if (entry.isFile() && filePattern.test(entry.name)) { + files.push(absolutePath); + } + } + return files; +} + +const removedGuides = [ + 'docs/ccs-bar.md', + 'docs/cursor-integration.md', + 'docs/dashboard-auth-cli.md', + 'docs/session-sharing-technical-analysis.md', +]; + +for (const relativePath of removedGuides) { + if (fs.existsSync(path.join(root, relativePath))) { + failures.push(`${relativePath} should use its canonical public-doc replacement`); + } +} + +requireText('README.md', 'https://docs.ccs.kaitran.ca/features/proxy/openai-compatible-providers'); +requireText('README.md', 'https://docs.ccs.kaitran.ca/features/workflow/browser-automation'); +requireText('docs/browser-automation.md', 'CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled'); +requireText('docs/browser-automation.md', 'CCS_BROWSER_UPLOAD_ROOTS'); +requireText('docs/browser-automation.md', 'CCS_BROWSER_DOWNLOAD_ROOTS'); +requireText('docs/browser-automation.md', 'browser_wait_for_event'); +requireText('docs/browser-automation.md', 'path-scoped bearer values'); +requireText('docs/codex-auth.md', 'src/codex-auth/codex-auth-help.ts'); +requireText('docs/codex-auth.md', 'CCSXP_CODEX_HOME'); +requireText('docs/image-analysis.md', 'https://docs.ccs.kaitran.ca/features/ai/image-analysis'); +requireText('docs/openai-compatible-providers.md', 'CCS_OPENAI_PROXY_INSECURE'); +requireText('docs/openai-compatible-providers.md', 'CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS'); +requireText('macos-bar/README.md', 'macos-bar/VERSION'); +requireText('macos-bar/README.md', '.github/workflows/bar-release.yml'); +requireText( + '.github/ISSUE_TEMPLATE/documentation.yml', + 'https://docs.ccs.kaitran.ca/providers/oauth/cursor' +); + +const practicalGuidanceFiles = [ + path.join(root, 'README.md'), + path.join(root, 'CLAUDE.md'), + path.join(root, 'CONTRIBUTING.md'), + path.join(root, 'SECURITY.md'), + path.join(root, 'docker', 'README.md'), + path.join(root, 'macos-bar', 'README.md'), + ...collectFiles(path.join(root, 'docs'), /\.mdx?$/), + ...collectFiles(path.join(root, '.github', 'ISSUE_TEMPLATE'), /\.(md|ya?ml)$/), +]; + +for (const staleGuidePath of removedGuides) { + for (const guidancePath of practicalGuidanceFiles) { + if (read(path.relative(root, guidancePath)).includes(staleGuidePath)) { + failures.push( + `${path.relative(root, guidancePath)} references deleted guide: ${staleGuidePath}` + ); + } + } +} + +const markdownFiles = [ + path.join(root, 'README.md'), + path.join(root, 'docker', 'README.md'), + path.join(root, 'macos-bar', 'README.md'), + ...collectFiles(path.join(root, 'docs'), /\.mdx?$/), +]; +const linkPattern = /!?\[[^\]]*]\(([^)]+)\)/g; + +for (const markdownPath of markdownFiles) { + const source = fs.readFileSync(markdownPath, 'utf8'); + for (const match of source.matchAll(linkPattern)) { + let target = match[1].trim().replace(/^<|>$/g, ''); + if (!target || target.startsWith('#') || /^(https?:|mailto:|tel:)/i.test(target)) { + continue; + } + + target = target.split('#', 1)[0].split('?', 1)[0]; + try { + target = decodeURIComponent(target); + } catch { + failures.push(`${path.relative(root, markdownPath)} has invalid link encoding: ${match[1]}`); + continue; + } + + const resolved = path.resolve(path.dirname(markdownPath), target); + if (!fs.existsSync(resolved)) { + failures.push(`${path.relative(root, markdownPath)} has missing relative link: ${match[1]}`); + } + } +} + +if (failures.length > 0) { + console.error('[X] Documentation freshness checks failed:'); + for (const failure of failures) { + console.error(` ${failure}`); + } + process.exit(1); +} + +console.log('[OK] Documentation pointers, retained contracts, and relative links are current.'); diff --git a/tests/integration/proxy/fixtures/runtime-probe.cjs b/tests/integration/proxy/fixtures/runtime-probe.cjs new file mode 100644 index 00000000..a07dd929 --- /dev/null +++ b/tests/integration/proxy/fixtures/runtime-probe.cjs @@ -0,0 +1,100 @@ +const http = require('node:http'); +const path = require('node:path'); + +const repoRoot = process.env.CCS_RUNTIME_PROBE_ROOT || process.cwd(); +const { + closeOpenAICompatProxyServer, + startOpenAICompatProxyServer, +} = require(path.join(repoRoot, 'dist/proxy/server/proxy-server.js')); + +function waitForListening(server) { + if (server.listening) return Promise.resolve(); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.once('listening', resolve); + }); +} + +function request(port, requestPath, options = {}) { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: requestPath, + method: options.method || 'GET', + headers: options.headers, + agent: options.agent, + }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve({ status: res.statusCode || 0, body })); + } + ); + req.once('error', reject); + req.end(options.body); + }); +} + +async function startProbeServer() { + const server = startOpenAICompatProxyServer({ + profile: { + profileName: 'sentinel', + settingsPath: '/tmp/sentinel.settings.json', + baseUrl: process.env.CCS_RUNTIME_PROBE_UPSTREAM, + apiKey: 'sentinel-api-key-not-real', + provider: 'generic-chat-completion-api', + model: 'sentinel-model', + passthrough: false, + }, + port: 0, + authToken: 'sentinel-local-token', + }); + await waitForListening(server); + return server; +} + +async function runTransportProbe() { + const server = await startProbeServer(); + const port = server.address().port; + const response = await request(port, '/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': 'sentinel-local-token', + }, + body: JSON.stringify({ + model: 'sentinel-model', + messages: [{ role: 'user', content: 'hi' }], + }), + }); + await closeOpenAICompatProxyServer(server, 250); + const parsed = JSON.parse(response.body); + console.log( + JSON.stringify({ + status: response.status, + text: parsed.content?.[0]?.text, + error: parsed.error?.message, + }) + ); +} + +async function runShutdownProbe() { + const server = await startProbeServer(); + const port = server.address().port; + const agent = new http.Agent({ keepAlive: true }); + await request(port, '/health', { agent }); + const startedAt = Date.now(); + await closeOpenAICompatProxyServer(server, 250); + const elapsedMs = Date.now() - startedAt; + agent.destroy(); + console.log(JSON.stringify({ closed: !server.listening, elapsedMs })); +} + +const mode = process.env.CCS_RUNTIME_PROBE_MODE || 'transport'; +Promise.resolve(mode === 'shutdown' ? runShutdownProbe() : runTransportProbe()).catch((error) => { + console.error(JSON.stringify({ name: error.name, message: error.message })); + process.exitCode = 1; +}); diff --git a/tests/integration/proxy/runtime-transport-matrix.test.ts b/tests/integration/proxy/runtime-transport-matrix.test.ts new file mode 100644 index 00000000..3d0a2773 --- /dev/null +++ b/tests/integration/proxy/runtime-transport-matrix.test.ts @@ -0,0 +1,200 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const matrixEnabled = process.env.CCS_RUNTIME_MATRIX === '1'; +const fixturePath = path.join(process.cwd(), 'tests/integration/proxy/fixtures/runtime-probe.cjs'); +const proxyKeys = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'all_proxy', + 'no_proxy', +]; + +type ProbeResult = { + status?: number; + text?: string; + error?: string; + closed?: boolean; + elapsedMs?: number; +}; + +function resolveNodeBinary(version: 18 | 22 | 26): string { + const result = spawnSync('npx', ['-y', `node@${version}`, '-p', 'process.execPath'], { + encoding: 'utf8', + env: Object.fromEntries( + Object.entries(process.env).filter(([key]) => !proxyKeys.includes(key)) + ), + }); + if (result.status !== 0) { + throw new Error(`Unable to resolve Node ${version}: ${result.stderr.trim()}`); + } + return result.stdout.trim(); +} + +function runProbe( + binary: string, + upstream: string, + env: Record = {}, + mode = 'transport' +): Promise { + const cleanEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !proxyKeys.includes(key)) + ); + const ccsHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-runtime-matrix-')); + return new Promise((resolve, reject) => { + const child = spawn(binary, [fixturePath], { + cwd: process.cwd(), + env: { + ...cleanEnv, + ...env, + CCS_RUNTIME_PROBE_ROOT: process.cwd(), + CCS_RUNTIME_PROBE_UPSTREAM: upstream, + CCS_RUNTIME_PROBE_MODE: mode, + CCS_HOME: ccsHome, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => (stdout += chunk)); + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.once('error', (error) => { + fs.rmSync(ccsHome, { recursive: true, force: true }); + reject(error); + }); + child.once('close', (code) => { + fs.rmSync(ccsHome, { recursive: true, force: true }); + if (code !== 0) { + reject(new Error(`Probe exited ${code}: ${stderr || stdout}`)); + return; + } + const lastLine = stdout.trim().split('\n').pop(); + if (!lastLine) { + reject(new Error(`Probe returned no output: ${stderr}`)); + return; + } + resolve(JSON.parse(lastLine) as ProbeResult); + }); + }); +} + +describe.skipIf(!matrixEnabled)('real runtime upstream transport matrix', () => { + let node18 = ''; + let node22 = ''; + let node26 = ''; + let upstreamServer: http.Server | undefined; + let upstreamPort = 0; + let corporateProxy: http.Server | undefined; + let corporateProxyPort = 0; + let corporateProxyHits = 0; + let sawProxyAuthorization = false; + + beforeAll(async () => { + node18 = resolveNodeBinary(18); + node22 = resolveNodeBinary(22); + node26 = resolveNodeBinary(26); + const payload = JSON.stringify({ + id: 'chatcmpl_matrix', + model: 'sentinel-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'matrix-ok' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + upstreamServer = http.createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(payload); + }); + corporateProxy = http.createServer((req, res) => { + corporateProxyHits += 1; + sawProxyAuthorization = String(req.headers['proxy-authorization'] || '').startsWith('Basic '); + res.writeHead(200, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + }); + res.end(payload); + }); + corporateProxy.on('connect', (req, socket) => { + corporateProxyHits += 1; + sawProxyAuthorization = String(req.headers['proxy-authorization'] || '').startsWith('Basic '); + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + socket.once('data', () => { + socket.end( + `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(payload)}\r\nConnection: close\r\n\r\n${payload}` + ); + }); + }); + await Promise.all([ + new Promise((resolve) => upstreamServer.listen(0, '127.0.0.1', resolve)), + new Promise((resolve) => corporateProxy.listen(0, '127.0.0.1', resolve)), + ]); + upstreamPort = (upstreamServer.address() as { port: number }).port; + corporateProxyPort = (corporateProxy.address() as { port: number }).port; + }, 60_000); + + afterAll(async () => { + const servers = [upstreamServer, corporateProxy].filter( + (server): server is http.Server => server !== undefined + ); + await Promise.all( + servers.map((server) => new Promise((resolve) => server.close(() => resolve()))) + ); + }, 10_000); + + it('uses the matching fetch transport on Node 18, 22, 26, and Bun', async () => { + const upstream = `http://127.0.0.1:${upstreamPort}/v1`; + const results = await Promise.all([ + runProbe(node18, upstream), + runProbe(node22, upstream), + runProbe(node26, upstream), + runProbe(process.execPath, upstream), + ]); + for (const result of results) { + expect(result).toMatchObject({ status: 200, text: 'matrix-ok' }); + } + }); + + it('routes Node 26 and Bun through a real credentialed proxy', async () => { + const proxyUrl = `http://sentinel-user:sentinel-password@127.0.0.1:${corporateProxyPort}`; + const nodeResult = await runProbe(node26, 'http://upstream.invalid/v1', { + HTTP_PROXY: proxyUrl, + NO_PROXY: '127.0.0.1,localhost', + }); + const bunResult = await runProbe(process.execPath, 'http://upstream.invalid/v1', { + HTTP_PROXY: proxyUrl, + NO_PROXY: '127.0.0.1,localhost', + }); + expect(nodeResult).toMatchObject({ status: 200, text: 'matrix-ok' }); + expect(bunResult).toMatchObject({ status: 200, text: 'matrix-ok' }); + expect(corporateProxyHits).toBeGreaterThanOrEqual(2); + expect(sawProxyAuthorization).toBe(true); + }); + + it('honors NO_PROXY without touching the proxy', async () => { + const hitsBefore = corporateProxyHits; + const result = await runProbe(node26, 'http://upstream.invalid/v1', { + HTTP_PROXY: `http://sentinel-user:sentinel-password@127.0.0.1:${corporateProxyPort}`, + NO_PROXY: 'upstream.invalid', + }); + expect(result.status).toBe(502); + expect(corporateProxyHits).toBe(hitsBefore); + }); + + it('shuts down Node 18 with an idle keep-alive connection', async () => { + const result = await runProbe(node18, `http://127.0.0.1:${upstreamPort}/v1`, {}, 'shutdown'); + expect(result.closed).toBe(true); + expect(result.elapsedMs).toBeLessThan(2_000); + }); +}); diff --git a/tests/integration/proxy/runtime-transport.test.ts b/tests/integration/proxy/runtime-transport.test.ts new file mode 100644 index 00000000..f3fad034 --- /dev/null +++ b/tests/integration/proxy/runtime-transport.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { getGlobalDispatcher } from 'undici'; +import { + closeOpenAICompatProxyServer, + startOpenAICompatProxyServer, +} from '../../../src/proxy/server/proxy-server'; +import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'] as const; + +function listen(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to resolve listening port')); + return; + } + resolve(address.port); + }); + }); +} + +function requestMessages(port: number): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/v1/messages', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': 'sentinel-local-token', + }, + }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + } + ); + req.once('error', reject); + req.end( + JSON.stringify({ + model: 'sentinel-model', + messages: [{ role: 'user', content: 'hi' }], + }) + ); + }); +} + +describe('runtime-aware upstream transport', () => { + const originalEnv = new Map(); + let ccsHome = ''; + let corporateProxy: http.Server | undefined; + let corporateProxyHits = 0; + let sawProxyAuthorization = false; + let proxyServer: http.Server | undefined; + + beforeEach(() => { + for (const key of PROXY_ENV_KEYS) { + originalEnv.set(key, process.env[key]); + delete process.env[key]; + } + originalEnv.set('CCS_HOME', process.env.CCS_HOME); + ccsHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-runtime-transport-')); + process.env.CCS_HOME = ccsHome; + corporateProxy = undefined; + proxyServer = undefined; + corporateProxyHits = 0; + sawProxyAuthorization = false; + }); + + afterEach(async () => { + if (proxyServer) { + await closeOpenAICompatProxyServer(proxyServer, 50); + } + if (corporateProxy) { + await new Promise((resolve) => corporateProxy.close(() => resolve())); + } + for (const key of [...PROXY_ENV_KEYS, 'CCS_HOME'] as const) { + const value = originalEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(ccsHome, { recursive: true, force: true }); + }); + + it('uses a credentialed proxy and honors NO_PROXY without global fetch mutation', async () => { + const originalFetch = globalThis.fetch; + const upstreamPayload = JSON.stringify({ + id: 'chatcmpl_proxy', + model: 'sentinel-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'proxied-ok' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + corporateProxy = http.createServer((req, res) => { + corporateProxyHits += 1; + sawProxyAuthorization = String(req.headers['proxy-authorization'] || '').startsWith('Basic '); + res.writeHead(200, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(upstreamPayload), + }); + res.end(upstreamPayload); + }); + const corporateProxyPort = await listen(corporateProxy); + process.env.HTTP_PROXY = `http://sentinel-user:sentinel-password@127.0.0.1:${corporateProxyPort}`; + + const profile: OpenAICompatProfileConfig = { + profileName: 'sentinel', + settingsPath: '/tmp/sentinel.settings.json', + baseUrl: 'http://upstream.invalid/v1', + apiKey: 'sentinel-api-key-not-real', + provider: 'generic-chat-completion-api', + model: 'sentinel-model', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'sentinel-local-token', + }); + const proxyPort = await new Promise((resolve, reject) => { + proxyServer.once('error', reject); + proxyServer.once('listening', () => { + const address = proxyServer.address(); + if (!address || typeof address === 'string') reject(new Error('Missing proxy port')); + else resolve(address.port); + }); + }); + + const proxied = await requestMessages(proxyPort); + expect(proxied.status).toBe(200); + expect(JSON.parse(proxied.body)).toMatchObject({ + content: [{ type: 'text', text: 'proxied-ok' }], + }); + expect(corporateProxyHits).toBe(1); + expect(sawProxyAuthorization).toBe(true); + + process.env.NO_PROXY = 'upstream.invalid'; + const hitsBeforeBypass = corporateProxyHits; + const bypassed = await requestMessages(proxyPort); + expect(bypassed.status).toBe(502); + expect(corporateProxyHits).toBe(hitsBeforeBypass); + expect(globalThis.fetch).toBe(originalFetch); + }); + + it('keeps global transport ownership unchanged across overlapping server lifecycles', async () => { + const originalFetch = globalThis.fetch; + const originalDispatcher = getGlobalDispatcher(); + const profile: OpenAICompatProfileConfig = { + profileName: 'sentinel', + settingsPath: '/tmp/sentinel.settings.json', + baseUrl: 'http://127.0.0.1:1/v1', + apiKey: 'sentinel-api-key-not-real', + provider: 'generic-chat-completion-api', + model: 'sentinel-model', + }; + const serverA = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'sentinel-local-token-a', + }); + const serverB = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'sentinel-local-token-b', + }); + + await Promise.all([ + new Promise((resolve) => serverA.once('listening', resolve)), + new Promise((resolve) => serverB.once('listening', resolve)), + ]); + await closeOpenAICompatProxyServer(serverA, 50); + expect(serverB.listening).toBe(true); + expect(globalThis.fetch).toBe(originalFetch); + expect(getGlobalDispatcher()).toBe(originalDispatcher); + + await closeOpenAICompatProxyServer(serverB, 50); + expect(globalThis.fetch).toBe(originalFetch); + expect(getGlobalDispatcher()).toBe(originalDispatcher); + }); +}); diff --git a/tests/native/windows/custom-claude-path.ps1 b/tests/native/windows/custom-claude-path.ps1 index 1f09cdd1..7caf1ec0 100644 --- a/tests/native/windows/custom-claude-path.ps1 +++ b/tests/native/windows/custom-claude-path.ps1 @@ -648,7 +648,7 @@ function Test-Category6-Integration { $Output = & $CcsPath --version 2>&1 | Out-String - $ShowsVersion = $Output -match "CCS \(Claude Code Switch\) version" + $ShowsVersion = $Output -match "CCS \(Claude Codex Switch\) v\d+\.\d+\.\d+" if ($ShowsVersion) { $Duration = ((Get-Date) - $TestStart).TotalMilliseconds diff --git a/tests/npm/special-commands.test.js b/tests/npm/special-commands.test.js index 13726274..904bcb2c 100644 --- a/tests/npm/special-commands.test.js +++ b/tests/npm/special-commands.test.js @@ -18,7 +18,7 @@ describe('integration: special commands', () => { it('shows version with --version', () => { const output = execSync(buildCliCommand('--version'), { encoding: 'utf8' }); - assert(output.includes('CCS (Claude Code Switch)')); + assert(output.includes('CCS (Claude Codex Switch)')); assert(/v\d+\.\d+\.\d+/.test(output)); }); diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 5d984973..4d124e2b 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -9,6 +9,7 @@ import { importApiProfileBundle, registerApiProfileOrphans, } from '../../../src/api/services/profile-lifecycle-service'; +import { validateApiProfileSettingsPayload } from '../../../src/api/services/profile-lifecycle-validation'; import { createApiProfile } from '../../../src/api/services/profile-writer'; import { loadConfigSafe, @@ -69,6 +70,38 @@ describe('profile lifecycle service', () => { } }); + it('accepts ANTHROPIC_DEFAULT_FABLE_MODEL as a supported model mapping', () => { + const validation = validateApiProfileSettingsPayload({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini', + }, + }); + + expect(validation.valid).toBe(true); + expect(validation.issues).toEqual([]); + }); + + it('enforces the provider denylist for ANTHROPIC_DEFAULT_FABLE_MODEL', () => { + const validation = validateApiProfileSettingsPayload({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-sonnet-4.5', + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.issues).toContainEqual( + expect.objectContaining({ + level: 'error', + code: 'model_denylisted', + field: 'env.ANTHROPIC_DEFAULT_FABLE_MODEL', + }) + ); + }); + it('discovers grandfathered xai/grok orphans while skipping other reserved names', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); diff --git a/tests/unit/auth/resume-lane-diagnostics.test.ts b/tests/unit/auth/resume-lane-diagnostics.test.ts index 938455b8..79617bd7 100644 --- a/tests/unit/auth/resume-lane-diagnostics.test.ts +++ b/tests/unit/auth/resume-lane-diagnostics.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { + findResumeSessionLanes, parseResumeFlagIntent, resolveConfiguredPlainCcsResumeLane, } from '../../../src/auth/resume-lane-diagnostics'; @@ -140,4 +141,158 @@ describe('resume lane diagnostics', () => { expect(lane.kind).toBe('account-inherited'); expect(lane.accountName).toBe('work'); }); + + it('finds an explicit session in native and account resume lanes', () => { + const sessionId = '33333333-3333-4333-8333-333333333333'; + writeConfig([ + 'version: 12', + 'accounts:', + ' work:', + ' created: "2026-04-04T00:00:00.000Z"', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + const nativeProject = path.join(tempHome, '.claude', 'projects', 'workspace'); + const accountProject = path.join( + tempHome, + '.ccs', + 'instances', + 'work', + 'projects', + 'workspace' + ); + fs.mkdirSync(nativeProject, { recursive: true }); + fs.mkdirSync(accountProject, { recursive: true }); + fs.writeFileSync(path.join(nativeProject, `${sessionId}.jsonl`), '{}\n'); + fs.writeFileSync(path.join(accountProject, `${sessionId}.jsonl`), '{}\n'); + + expect(findResumeSessionLanes(sessionId)).toEqual([ + { kind: 'native', configDir: path.join(tempHome, '.claude') }, + { + kind: 'account', + configDir: path.join(tempHome, '.ccs', 'instances', 'work'), + accountName: 'work', + }, + ]); + }); + + it('does not interpret resume search text as a session filename', () => { + expect(findResumeSessionLanes('../../settings')).toEqual([]); + }); + + it('does not follow a symlinked project directory outside the native lane', () => { + const sessionId = '77777777-7777-4777-8777-777777777777'; + writeConfig([ + 'version: 12', + 'accounts: {}', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + const externalProject = path.join(tempHome, 'external-project'); + const nativeProjects = path.join(tempHome, '.claude', 'projects'); + fs.mkdirSync(externalProject, { recursive: true }); + fs.mkdirSync(nativeProjects, { recursive: true }); + fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), '{}\n'); + fs.symlinkSync(externalProject, path.join(nativeProjects, 'linked-project')); + + expect(findResumeSessionLanes(sessionId)).toEqual([]); + }); + + it('does not follow a symlinked projects root outside the native lane', () => { + const sessionId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + writeConfig([ + 'version: 12', + 'accounts: {}', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + const externalProject = path.join(tempHome, 'external-projects', 'workspace'); + const nativeDir = path.join(tempHome, '.claude'); + fs.mkdirSync(externalProject, { recursive: true }); + fs.mkdirSync(nativeDir, { recursive: true }); + fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), '{}\n'); + fs.symlinkSync(path.dirname(externalProject), path.join(nativeDir, 'projects')); + + expect(findResumeSessionLanes(sessionId)).toEqual([]); + }); + + it('does not follow a symlinked native config root', () => { + const sessionId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + writeConfig([ + 'version: 12', + 'accounts: {}', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + const externalProject = path.join(tempHome, 'external-claude', 'projects', 'workspace'); + fs.mkdirSync(externalProject, { recursive: true }); + fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), '{}\n'); + fs.symlinkSync(path.join(tempHome, 'external-claude'), path.join(tempHome, '.claude')); + + expect(findResumeSessionLanes(sessionId)).toEqual([]); + }); + + it('rejects a project parent swapped to an external symlink at the open boundary', () => { + const sessionId = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; + writeConfig([ + 'version: 12', + 'accounts: {}', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + const nativeProject = path.join(tempHome, '.claude', 'projects', 'workspace'); + const displacedProject = path.join(tempHome, 'displaced-project'); + const externalProject = path.join(tempHome, 'external-project'); + fs.mkdirSync(nativeProject, { recursive: true }); + fs.mkdirSync(externalProject, { recursive: true }); + fs.writeFileSync(path.join(nativeProject, `${sessionId}.jsonl`), 'native\n'); + fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), 'external\n'); + let swapped = false; + + expect( + findResumeSessionLanes(sessionId, { + beforeSessionOpen: () => { + if (swapped) return; + swapped = true; + fs.renameSync(nativeProject, displacedProject); + fs.symlinkSync(externalProject, nativeProject); + }, + }) + ).toEqual([]); + }); + + it('does not follow a symlinked session file outside the native lane', () => { + const sessionId = '88888888-8888-4888-8888-888888888888'; + writeConfig([ + 'version: 12', + 'accounts: {}', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + const nativeProject = path.join(tempHome, '.claude', 'projects', 'workspace'); + const externalSession = path.join(tempHome, 'external-session.jsonl'); + fs.mkdirSync(nativeProject, { recursive: true }); + fs.writeFileSync(externalSession, '{}\n'); + fs.symlinkSync(externalSession, path.join(nativeProject, `${sessionId}.jsonl`)); + + expect(findResumeSessionLanes(sessionId)).toEqual([]); + }); }); diff --git a/tests/unit/auth/resume-lane-warning.test.ts b/tests/unit/auth/resume-lane-warning.test.ts index 83205178..89cd12f3 100644 --- a/tests/unit/auth/resume-lane-warning.test.ts +++ b/tests/unit/auth/resume-lane-warning.test.ts @@ -1,4 +1,9 @@ -import { describe, expect, it } from 'bun:test'; +import { afterEach, describe, expect, it } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Shell } from '../../../src/codex-auth/shell-detect'; import { maybeWarnAboutResumeLaneMismatch } from '../../../src/auth/resume-lane-warning'; function stripAnsi(input: string): string { @@ -6,6 +11,14 @@ function stripAnsi(input: string): string { } describe('resume lane warning', () => { + const temporaryDirectories: string[] = []; + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + it('prints guidance when the plain ccs lane differs from the account lane', async () => { const logs: string[] = []; @@ -56,4 +69,288 @@ describe('resume lane warning', () => { expect(logs[0]).toContain('Resume lane guidance skipped because diagnostics failed'); expect(logs[1]).toContain('Diagnostic error: broken config'); }); + + it('does not warn when an explicit session already exists in the target account lane', async () => { + const logs: string[] = []; + + await maybeWarnAboutResumeLaneMismatch( + 'work', + '/tmp/account-lane', + ['--resume', '11111111-1111-4111-8111-111111111111'], + { + log: (message) => logs.push(message), + resolvePlainLane: async () => ({ + kind: 'native', + label: 'native Claude lane', + configDir: '/tmp/native-lane', + projectCount: 12, + }), + findSessionLanes: () => [ + { + kind: 'account', + configDir: '/tmp/account-lane', + accountName: 'work', + }, + ], + } + ); + + expect(logs).toHaveLength(0); + }); + + it('points an explicit session at its unique account lane', async () => { + const logs: string[] = []; + const sessionId = '22222222-2222-4222-8222-222222222222'; + + await maybeWarnAboutResumeLaneMismatch( + 'personal', + '/tmp/personal-lane', + ['--resume', sessionId], + { + log: (message) => logs.push(message), + resolvePlainLane: async () => ({ + kind: 'native', + label: 'native Claude lane', + configDir: '/tmp/native-lane', + projectCount: 12, + }), + findSessionLanes: () => [ + { + kind: 'account', + configDir: '/tmp/work-lane', + accountName: 'work', + }, + ], + } + ); + + const plainLogs = logs.map((message) => stripAnsi(message)); + expect(plainLogs).toContain( + `[i] Resume from the lane that owns this session: ccs work --resume ${sessionId}` + ); + expect(plainLogs).toContain( + '[i] Back up that lane before changing setup: ccs auth backup work' + ); + expect(plainLogs).not.toContain('[i] Recover the original lane first: ccs -r'); + expect(plainLogs).not.toContain( + '[i] For future work, align plain ccs with this account: ccs auth default personal' + ); + }); + + it('lists ambiguous source lanes without choosing or merging one', async () => { + const logs: string[] = []; + const sessionId = '44444444-4444-4444-8444-444444444444'; + + await maybeWarnAboutResumeLaneMismatch( + 'personal', + '/tmp/personal-lane', + ['--resume', sessionId], + { + log: (message) => logs.push(message), + resolvePlainLane: async () => ({ + kind: 'native', + label: 'native Claude lane', + configDir: '/tmp/native-lane', + projectCount: 12, + }), + findSessionLanes: () => [ + { kind: 'native', configDir: '/tmp/native-lane' }, + { kind: 'account', configDir: '/tmp/work-lane', accountName: 'work' }, + ], + } + ); + + const plainLogs = logs.map((message) => stripAnsi(message)); + expect(plainLogs).toContain( + '[i] This session exists in multiple lanes; choose one without merging history:' + ); + expect(plainLogs).toContain(`[i] ccs --resume ${sessionId}`); + expect(plainLogs).toContain(`[i] ccs work --resume ${sessionId}`); + expect(plainLogs).not.toContain('[i] Recover the original lane first: ccs -r'); + }); + + it('uses explicit native Claude routing when plain ccs defaults to another account', async () => { + const logs: string[] = []; + const sessionId = '55555555-5555-4555-8555-555555555555'; + + await maybeWarnAboutResumeLaneMismatch( + 'personal', + '/tmp/personal-lane', + ['--resume', sessionId], + { + log: (message) => logs.push(message), + resolvePlainLane: async () => ({ + kind: 'account-default', + label: 'plain ccs defaults to account "work"', + configDir: '/tmp/work-lane', + accountName: 'work', + projectCount: 12, + }), + shell: 'bash', + findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native-lane' }], + } + ); + + const plainLogs = logs.map((message) => stripAnsi(message)); + expect(plainLogs).toContain( + `[i] Resume from the lane that owns this session: unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native-lane'; claude --resume ${sessionId}` + ); + expect(plainLogs.some((message) => message.includes(`ccs --resume ${sessionId}`))).toBe(false); + }); + + it('routes to native Claude when the target account is also the plain ccs default', async () => { + const logs: string[] = []; + const sessionId = '66666666-6666-4666-8666-666666666666'; + + await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work-lane', ['--resume', sessionId], { + log: (message) => logs.push(message), + resolvePlainLane: async () => ({ + kind: 'account-default', + label: 'plain ccs defaults to account "work"', + configDir: '/tmp/work-lane', + accountName: 'work', + projectCount: 12, + }), + shell: 'bash', + findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native-lane' }], + }); + + const plainLogs = logs.map((message) => stripAnsi(message)); + expect(plainLogs).toContain( + `[i] Resume from the lane that owns this session: unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native-lane'; claude --resume ${sessionId}` + ); + expect(plainLogs.some((message) => message.includes(`ccs --resume ${sessionId}`))).toBe(false); + }); + + it('uses explicit native routing for an ambient lane even when its path matches', async () => { + const logs: string[] = []; + const sessionId = '99999999-9999-4999-8999-999999999999'; + + await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work-lane', ['--resume', sessionId], { + log: (message) => logs.push(stripAnsi(message)), + shell: 'zsh', + resolvePlainLane: async () => ({ + kind: 'ambient', + label: 'current shell CLAUDE_CONFIG_DIR', + configDir: '/tmp/native-lane', + projectCount: 1, + }), + findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native-lane' }], + }); + + expect(logs.some((message) => message.includes('export CLAUDE_CONFIG_DIR'))).toBe(true); + expect(logs.some((message) => message.includes(`ccs --resume ${sessionId}`))).toBe(false); + }); + + it.each([ + [ + 'bash', + "unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native lane'; claude --resume", + ], + [ + 'zsh', + "unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native lane'; claude --resume", + ], + [ + 'fish', + "set -e ANTHROPIC_BASE_URL; set -e ANTHROPIC_AUTH_TOKEN; set -e ANTHROPIC_API_KEY; set -gx CLAUDE_CONFIG_DIR '/tmp/native lane'; claude --resume", + ], + [ + 'pwsh', + 'Remove-Item Env:ANTHROPIC_BASE_URL -ErrorAction SilentlyContinue; Remove-Item Env:ANTHROPIC_AUTH_TOKEN -ErrorAction SilentlyContinue; Remove-Item Env:ANTHROPIC_API_KEY -ErrorAction SilentlyContinue; $env:CLAUDE_CONFIG_DIR = "/tmp/native lane"; claude --resume', + ], + [ + 'cmd', + 'set "ANTHROPIC_BASE_URL=" && set "ANTHROPIC_AUTH_TOKEN=" && set "ANTHROPIC_API_KEY=" && set "CLAUDE_CONFIG_DIR=/tmp/native lane" && claude --resume', + ], + ] as Array<[Shell, string]>)( + 'formats executable native recovery for %s', + async (shell, prefix) => { + const logs: string[] = []; + const sessionId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work-lane', ['--resume', sessionId], { + log: (message) => logs.push(stripAnsi(message)), + shell, + resolvePlainLane: async () => ({ + kind: 'account-default', + label: 'plain ccs defaults to account "work"', + configDir: '/tmp/work-lane', + accountName: 'work', + projectCount: 1, + }), + findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native lane' }], + }); + expect(logs.some((message) => message.includes(`${prefix} ${sessionId}`))).toBe(true); + } + ); + + it('does not emit executable guidance for an unsafe account name', async () => { + const logs: string[] = []; + const sessionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + await maybeWarnAboutResumeLaneMismatch('personal', '/tmp/personal', ['--resume', sessionId], { + log: (message) => logs.push(stripAnsi(message)), + shell: 'bash', + resolvePlainLane: async () => ({ + kind: 'native', + label: 'native Claude lane', + configDir: '/tmp/native', + projectCount: 1, + }), + findSessionLanes: () => [ + { kind: 'account', configDir: '/tmp/unsafe', accountName: 'work; touch /tmp/pwned' }, + ], + }); + expect(logs.some((message) => message.includes('unsafe account name'))).toBe(true); + expect(logs.some((message) => message.includes('ccs work;'))).toBe(false); + expect(logs.some((message) => message.includes('backup work;'))).toBe(false); + }); + + it('executes the printed bash route with poisoned provider routing removed', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-resume-command-')); + temporaryDirectories.push(tempDir); + const binDir = path.join(tempDir, 'bin'); + const capturePath = path.join(tempDir, 'capture'); + const nativeConfigDir = path.join(tempDir, 'native lane'); + const sessionId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + fs.mkdirSync(binDir); + fs.mkdirSync(path.join(nativeConfigDir, 'projects', 'workspace'), { recursive: true }); + fs.writeFileSync( + path.join(nativeConfigDir, 'projects', 'workspace', `${sessionId}.jsonl`), + '{}\n' + ); + fs.writeFileSync( + path.join(binDir, 'claude'), + '#!/bin/sh\nsession_path="$CLAUDE_CONFIG_DIR/projects/workspace/$2.jsonl"\n[ -f "$session_path" ] || exit 9\nprintf "%s|%s|%s|%s|%s|found\\n" "$CLAUDE_CONFIG_DIR" "${ANTHROPIC_BASE_URL-unset}" "${ANTHROPIC_AUTH_TOKEN-unset}" "${ANTHROPIC_API_KEY-unset}" "$*" > "$CAPTURE_PATH"\n', + { mode: 0o700 } + ); + const logs: string[] = []; + await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work', ['--resume', sessionId], { + log: (message) => logs.push(stripAnsi(message)), + shell: 'bash', + resolvePlainLane: async () => ({ + kind: 'account-default', + label: 'plain ccs defaults to account "work"', + configDir: '/tmp/work', + accountName: 'work', + projectCount: 1, + }), + findSessionLanes: () => [{ kind: 'native', configDir: nativeConfigDir }], + }); + const guidance = logs.find((message) => message.includes('Resume from the lane'))!; + const command = guidance.slice(guidance.indexOf(': ') + 2); + const result = spawnSync('/bin/bash', ['-c', command], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + CAPTURE_PATH: capturePath, + ANTHROPIC_BASE_URL: 'https://poison.invalid', + ANTHROPIC_AUTH_TOKEN: 'poison-token', + ANTHROPIC_API_KEY: 'poison-key', + }, + }); + expect(result.status).toBe(0); + expect(fs.readFileSync(capturePath, 'utf8').trim()).toBe( + `${nativeConfigDir}|unset|unset|unset|--resume ${sessionId}|found` + ); + }); }); diff --git a/tests/unit/cliproxy/concurrent-state-locks.test.ts b/tests/unit/cliproxy/concurrent-state-locks.test.ts new file mode 100644 index 00000000..d1019d05 --- /dev/null +++ b/tests/unit/cliproxy/concurrent-state-locks.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawn, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { loadAccountsRegistry } from '../../../src/cliproxy/accounts/registry'; +import { registerSession } from '../../../src/cliproxy/session-tracker'; +import { withSyncLockRetry } from '../../../src/utils/sync-lock-retry'; + +const ORIGINAL_CCS_HOME = process.env.CCS_HOME; +const LOCK_HOLD_MS = 400; + +let tempDir: string; +let cliproxyDir: string; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-concurrent-state-locks-')); + process.env.CCS_HOME = tempDir; + cliproxyDir = path.join(tempDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true, mode: 0o700 }); +}); + +afterEach(() => { + if (ORIGINAL_CCS_HOME === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = ORIGINAL_CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('CLIProxy state locks', () => { + it('waits for a contended account registry lock before loading', async () => { + const holder = await holdLock(cliproxyDir); + + try { + expect(loadAccountsRegistry()).toEqual({ version: 1, providers: {} }); + } finally { + await stopLockHolder(holder); + } + }); + + it('waits for a contended session tracker lock before registering', async () => { + const holder = await holdLock(cliproxyDir); + + try { + const sessionId = registerSession(8317, process.pid); + const sessionState = JSON.parse( + fs.readFileSync(path.join(cliproxyDir, 'sessions.json'), 'utf8') + ) as { sessions: string[] }; + + expect(sessionState.sessions).toContain(sessionId); + } finally { + await stopLockHolder(holder); + } + }); + + it('preserves stale lock recovery for account registry access', () => { + const staleLockPath = `${cliproxyDir}.lock`; + fs.mkdirSync(staleLockPath); + const staleTime = new Date(Date.now() - 15000); + fs.utimesSync(staleLockPath, staleTime, staleTime); + + expect(loadAccountsRegistry()).toEqual({ version: 1, providers: {} }); + expect(fs.existsSync(staleLockPath)).toBe(false); + }); + + it('gives actionable guidance when contention outlasts the bound', async () => { + const holder = await holdLock(cliproxyDir); + + try { + expect(() => + withSyncLockRetry(cliproxyDir, () => undefined, { + description: 'test CLIProxy state lock', + retryTimeoutMs: 0, + }) + ).toThrow( + 'Failed to acquire test CLIProxy state lock after 0ms; another CCS process may still be updating CLIProxy state. Wait for it to finish, then retry.' + ); + } finally { + await stopLockHolder(holder); + } + }); + + it('does not steal a fresh lock that ages past stale while its holder remains active', async () => { + const holder = await holdRawLock(cliproxyDir, 2800); + let callbackRan = false; + + try { + expect(() => + withSyncLockRetry( + cliproxyDir, + () => { + callbackRan = true; + }, + { + description: 'test active CLIProxy state lock', + staleMs: 2000, + retryDelayMs: 50, + retryTimeoutMs: 2300, + } + ) + ).toThrow('Failed to acquire test active CLIProxy state lock after 2300ms'); + expect(callbackRan).toBe(false); + expect(holder.exitCode).toBeNull(); + } finally { + await stopLockHolder(holder); + } + }); + + it('preserves all concurrent session registrations', async () => { + const workerCount = 6; + const gatePath = path.join(tempDir, 'session-writer-gate'); + const workerScript = path.join(tempDir, 'session-writer.ts'); + const sessionTrackerUrl = pathToFileURL( + path.join(process.cwd(), 'src/cliproxy/session-tracker.ts') + ).href; + fs.writeFileSync( + workerScript, + ` +import * as fs from 'fs'; +import { registerSession } from ${JSON.stringify(sessionTrackerUrl)}; + +const gatePath = process.argv[2]; +const readyPath = process.argv[3]; +const proxyPid = Number(process.argv[4]); +fs.writeFileSync(readyPath, String(process.pid)); +while (!fs.existsSync(gatePath)) { + await new Promise((resolve) => setTimeout(resolve, 5)); +} +registerSession(8317, proxyPid); +`, + 'utf8' + ); + + const workers = Array.from({ length: workerCount }, (_, index) => { + const readyPath = path.join(tempDir, `session-writer-ready-${index}`); + return { + readyPath, + child: spawn(process.execPath, [workerScript, gatePath, readyPath, String(process.pid)], { + cwd: process.cwd(), + env: { ...process.env, CCS_HOME: tempDir }, + stdio: ['ignore', 'ignore', 'pipe'], + }), + }; + }); + + try { + await Promise.all(workers.map(({ readyPath, child }) => waitForFile(readyPath, child))); + fs.writeFileSync(gatePath, 'go'); + await Promise.all(workers.map(({ child }) => waitForSuccessfulExit(child))); + + const sessionState = JSON.parse( + fs.readFileSync(path.join(cliproxyDir, 'sessions.json'), 'utf8') + ) as { sessions: string[] }; + expect(sessionState.sessions).toHaveLength(workerCount); + expect(new Set(sessionState.sessions).size).toBe(workerCount); + } finally { + await Promise.all(workers.map(({ child }) => stopLockHolder(child))); + } + }); +}); + +async function holdLock(lockTarget: string): Promise { + const readyPath = path.join(tempDir, `holder-ready-${Date.now()}-${Math.random()}`); + const holderScript = path.join(tempDir, `hold-lock-${Date.now()}-${Math.random()}.cjs`); + fs.writeFileSync( + holderScript, + ` +const fs = require('fs'); +const lockfile = require(process.argv[5]); +const release = lockfile.lockSync(process.argv[2], { stale: 10000 }); +fs.writeFileSync(process.argv[3], String(process.pid)); +setTimeout(() => { + release(); + process.exit(0); +}, Number(process.argv[4])); +setTimeout(() => process.exit(2), 5000); +process.on('SIGTERM', () => { + try { release(); } finally { process.exit(0); } +}); +`, + 'utf8' + ); + + const child = spawn( + process.execPath, + [holderScript, lockTarget, readyPath, String(LOCK_HOLD_MS), require.resolve('proper-lockfile')], + { + cwd: process.cwd(), + stdio: ['ignore', 'ignore', 'pipe'], + } + ); + await waitForFile(readyPath, child); + return child; +} + +async function holdRawLock(lockTarget: string, holdMs: number): Promise { + const readyPath = path.join(tempDir, `raw-holder-ready-${Date.now()}-${Math.random()}`); + const holderScript = path.join(tempDir, `hold-raw-lock-${Date.now()}-${Math.random()}.cjs`); + fs.writeFileSync( + holderScript, + ` +const fs = require('fs'); +const lockPath = process.argv[2] + '.lock'; +fs.mkdirSync(lockPath); +fs.writeFileSync(process.argv[3], String(process.pid)); +setTimeout(() => { + try { fs.rmdirSync(lockPath); } catch {} + process.exit(0); +}, Number(process.argv[4])); +setTimeout(() => process.exit(2), 5000); +process.on('SIGTERM', () => { + try { fs.rmdirSync(lockPath); } catch {} + process.exit(0); +}); +`, + 'utf8' + ); + + const child = spawn(process.execPath, [holderScript, lockTarget, readyPath, String(holdMs)], { + cwd: process.cwd(), + stdio: ['ignore', 'ignore', 'pipe'], + }); + await waitForFile(readyPath, child); + return child; +} + +async function waitForFile(filePath: string, child: ChildProcess, timeoutMs = 2000): Promise { + const startedAt = Date.now(); + while (!fs.existsSync(filePath)) { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error(`Lock holder exited before acquiring lock (code ${child.exitCode})`); + } + if (Date.now() - startedAt >= timeoutMs) { + throw new Error(`Timed out waiting for lock holder at ${filePath}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +async function stopLockHolder(child: ChildProcess): Promise { + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + } + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => child.once('exit', () => resolve())); + } +} + +async function waitForSuccessfulExit(child: ChildProcess): Promise { + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => child.once('exit', () => resolve())); + } + if (child.exitCode !== 0) { + throw new Error(`Concurrent session writer exited with code ${child.exitCode}`); + } +} diff --git a/tests/unit/codex-auth/shell-detect.test.ts b/tests/unit/codex-auth/shell-detect.test.ts index a3ee6ac8..c48dfbb5 100644 --- a/tests/unit/codex-auth/shell-detect.test.ts +++ b/tests/unit/codex-auth/shell-detect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { detectShell, formatExport } from '../../../src/codex-auth/shell-detect'; +import { detectShell, formatExport, formatUnset } from '../../../src/codex-auth/shell-detect'; import type { Shell } from '../../../src/codex-auth/shell-detect'; // ── detectShell ─────────────────────────────────────────────────────────────── @@ -146,6 +146,18 @@ describe('formatExport — cmd', () => { }); }); +describe('formatUnset', () => { + it('formats environment removal for every supported shell', () => { + expect(formatUnset('bash', 'ANTHROPIC_BASE_URL')).toBe('unset ANTHROPIC_BASE_URL'); + expect(formatUnset('zsh', 'ANTHROPIC_BASE_URL')).toBe('unset ANTHROPIC_BASE_URL'); + expect(formatUnset('fish', 'ANTHROPIC_BASE_URL')).toBe('set -e ANTHROPIC_BASE_URL;'); + expect(formatUnset('pwsh', 'ANTHROPIC_BASE_URL')).toBe( + 'Remove-Item Env:ANTHROPIC_BASE_URL -ErrorAction SilentlyContinue' + ); + expect(formatUnset('cmd', 'ANTHROPIC_BASE_URL')).toBe('set "ANTHROPIC_BASE_URL="'); + }); +}); + describe('formatExport — each shell produces distinct syntax', () => { const shells: Shell[] = ['bash', 'zsh', 'fish', 'pwsh', 'cmd']; it('all shells produce different output for same input', () => { diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 5f649ef1..79c3f043 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -169,6 +169,13 @@ describe('bar command dispatcher (index.ts)', () => { expect(calls).toEqual(['launch:']); }); + it('rejects an unknown bare launch flag without dispatching launch', async () => { + const handleBarCommand = await loadHandleBarCommand(); + await handleBarCommand(['--porrt', '3999']); + expect(calls).toEqual([]); + expect(process.exitCode).toBe(1); + }); + it('dispatches `ccs bar install` to install subcommand', async () => { const handleBarCommand = await loadHandleBarCommand(); await handleBarCommand(['install']); @@ -3040,7 +3047,7 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired // parser correctly sets authRequired without relying on the higher-level dep // injection that existing tests use (which bypasses real status-line parsing). - function makeNetMock(statusLine: string) { + function makeNetMock(statusLine: string, token: string) { return { connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => { const listeners: Record void>> = {}; @@ -3054,19 +3061,25 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired void cb; return socket; }, - write() { + write(request: string) { + const nonce = + request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? + ''; + const proof = createBarAuthProof(token, nonce); + setImmediate(() => { + for (const cb of listeners.data ?? []) { + cb( + Buffer.from(`${statusLine}\r\n${BAR_AUTH_TOKEN_HEADER}: ${proof}\r\n\r\n`, 'utf8') + ); + } + }); return true; }, destroy() { return socket; }, }; - setImmediate(() => { - onConnect(); - for (const cb of listeners.data ?? []) { - cb(Buffer.from(`${statusLine}\r\n\r\n`, 'utf8')); - } - }); + setImmediate(onConnect); return socket; }, }; @@ -3081,7 +3094,8 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired JSON.stringify({ port: 41401, baseUrl: 'http://127.0.0.1:41401', authMode: 'loopback' }) ); - mock.module('net', () => makeNetMock('HTTP/1.1 401 Unauthorized')); + const token = getOrCreateBarAuthToken(ccsDir); + mock.module('net', () => makeNetMock('HTTP/1.1 401 Unauthorized', token)); moduleSeq++; const { defaultFindRunningServer } = (await import( @@ -3111,7 +3125,8 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired JSON.stringify({ port: 41403, baseUrl: 'http://127.0.0.1:41403', authMode: 'loopback' }) ); - mock.module('net', () => makeNetMock('HTTP/1.1 403 Forbidden')); + const token = getOrCreateBarAuthToken(ccsDir); + mock.module('net', () => makeNetMock('HTTP/1.1 403 Forbidden', token)); moduleSeq++; const { defaultFindRunningServer } = (await import( @@ -3302,6 +3317,30 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected expect(result === 'timeout' || result === 'rejected').toBe(true); }); + it('unrelated 401/403 without a CCS proof does not trigger auth-required identity', async () => { + for (const status of [401, 403]) { + mock.module('net', () => + buildNetMock(`HTTP/1.1 ${status} Unrelated Service\r\nConnection: close\r\n\r\n`) + ); + moduleSeq++; + const { defaultWaitForServerLive } = (await import( + `../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}` + )) as { + defaultWaitForServerLive: (baseUrl: string) => Promise; + }; + + const result = await Promise.race([ + defaultWaitForServerLive(`http://127.0.0.1:${9900 + status}`).then( + () => 'resolved' as const, + () => 'rejected' as const + ), + new Promise<'still-probing'>((resolve) => setTimeout(() => resolve('still-probing'), 300)), + ]); + expect(result).toBe('still-probing'); + mock.restore(); + } + }); + it('correct-token 200 resolves defaultWaitForServerLive immediately', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -3643,3 +3682,332 @@ describe('bar install: --await-quit waits for the running app to quit (GH-1588)' expect(probes).toBeGreaterThanOrEqual(2); }); }); + +// --------------------------------------------------------------------------- +// --port support: `ccs bar [launch] --port N` (user-selectable dashboard port) +// --------------------------------------------------------------------------- + +describe('bar dispatcher: bare flags route to launch', () => { + beforeEach(() => { + mock.module('../../../src/commands/bar/launch-subcommand', () => ({ + handleBarLaunch: async (args: string[]) => { + calls.push(`launch:${args.join(' ')}`); + }, + })); + }); + + it('dispatches `ccs bar --port 3999` to launch with the flag preserved', async () => { + const handleBarCommand = await loadHandleBarCommand(); + await handleBarCommand(['--port', '3999']); + expect(calls).toEqual(['launch:--port 3999']); + }); + + it('dispatches `ccs bar launch --port 3999` to launch with the flag preserved', async () => { + const handleBarCommand = await loadHandleBarCommand(); + await handleBarCommand(['launch', '--port', '3999']); + expect(calls).toEqual(['launch:--port 3999']); + }); +}); + +describe('launch: --port selects the server port', () => { + function makePortDeps(ccsDir: string) { + const seen: { + spawnPort: number | null; + getPortCandidates: number[] | null; + stopped: boolean; + descriptorPort: number | null; + } = { spawnPort: null, getPortCandidates: null, stopped: false, descriptorPort: null }; + + const deps = { + findRunningServer: async () => null, + getPort: async (opts: { port: number[]; host: string }) => { + seen.getPortCandidates = opts.port; + return opts.port[0]; + }, + spawnDetachedServer: (p: number) => { + seen.spawnPort = p; + }, + waitForServerLive: async () => {}, + createLaunchDescriptor: (opts?: { port?: number }) => { + seen.descriptorPort = opts?.port ?? null; + return { + schema: 1 as const, + runtime: '/usr/bin/node', + args: ['/x/ccs.js', 'bar', 'serve'], + home: '/h', + }; + }, + writeLaunchDescriptor: () => {}, + stopDetachedServer: () => { + seen.stopped = true; + }, + openApp: async () => {}, + getCcsDir: () => ccsDir, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }; + return { deps, seen }; + } + + it('spawns the detached server on the requested port and records it in bar.json', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + + await handleBarLaunch(['--port', '3999'], deps); + + expect(seen.spawnPort).toBe(3999); + const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as { + port: number; + baseUrl: string; + }; + expect(barJson.port).toBe(3999); + expect(barJson.baseUrl).toBe('http://127.0.0.1:3999'); + }); + + it('persists the chosen port into the launch descriptor for app self-start', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + + await handleBarLaunch(['--port', '3999'], deps); + + expect(seen.descriptorPort).toBe(3999); + }); + + it('errors without spawning when the requested port is busy', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + deps.getPort = async () => 4001; // get-port fell back: 3999 not free + + await handleBarLaunch(['--port', '3999'], deps); + + expect(seen.spawnPort).toBeNull(); + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/3999/); + expect(allOutput.toLowerCase()).toMatch(/in use|busy|not free|unavailable/); + }); + + it('errors on an invalid --port value', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + + await handleBarLaunch(['--port', 'banana'], deps); + + expect(seen.spawnPort).toBeNull(); + expect(consoleOutput.join('\n').toLowerCase()).toMatch(/invalid.*port|port.*invalid/); + }); + + it('reuses a running server already on the requested port', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + deps.findRunningServer = async () => ({ port: 3999, baseUrl: 'http://127.0.0.1:3999' }); + + await handleBarLaunch(['--port', '3999'], deps); + + expect(seen.spawnPort).toBeNull(); // reuse, no new spawn + expect(seen.stopped).toBe(false); + const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as { + port: number; + }; + expect(barJson.port).toBe(3999); + }); + + it('stops a running server on a different port, then starts on the requested one', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }); + + await handleBarLaunch(['--port', '3999'], deps); + + expect(seen.stopped).toBe(true); + expect(seen.spawnPort).toBe(3999); + const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as { + port: number; + }; + expect(barJson.port).toBe(3999); + }); + + it('preflights the destination before stopping a healthy running server', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps } = makePortDeps(ccsDir); + const events: string[] = []; + deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }); + deps.getPort = async () => { + events.push('preflight'); + return 3999; + }; + deps.stopDetachedServer = () => { + events.push('stop'); + }; + + await handleBarLaunch(['--port', '3999'], deps); + expect(events).toEqual(['preflight', 'stop']); + }); + + it('leaves the healthy server running when destination preflight is busy', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }); + deps.getPort = async () => 4000; + + await handleBarLaunch(['--port', '3999'], deps); + expect(seen.stopped).toBe(false); + expect(seen.spawnPort).toBeNull(); + }); + + it('aborts the move when the verified stop fails', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }); + deps.stopDetachedServer = () => { + throw new Error('identity mismatch'); + }; + + await handleBarLaunch(['--port', '3999'], deps); + expect(seen.spawnPort).toBeNull(); + }); + + it('restores the prior server when a check-to-bind race breaks the replacement', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps } = makePortDeps(ccsDir); + const spawnedPorts: number[] = []; + deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }); + deps.spawnDetachedServer = (port: number) => { + spawnedPorts.push(port); + }; + deps.waitForServerLive = async (baseUrl: string) => { + if (baseUrl.endsWith(':3999')) throw new Error('EADDRINUSE after preflight'); + }; + + await handleBarLaunch(['--port', '3999'], deps); + expect(spawnedPorts).toEqual([3999, 3000]); + }); + + it('restores the prior server when replacement spawn fails', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps } = makePortDeps(ccsDir); + const spawnedPorts: number[] = []; + deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }); + deps.spawnDetachedServer = (port: number) => { + spawnedPorts.push(port); + if (port === 3999) throw new Error('spawn failed'); + }; + + await handleBarLaunch(['--port', '3999'], deps); + expect(spawnedPorts).toEqual([3999, 3000]); + }); + + it('preserves prior discovery state when replacement and rollback both fail', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true }); + const oldBarJson = JSON.stringify({ + baseUrl: 'http://127.0.0.1:3000', + port: 3000, + authMode: 'loopback', + }); + const oldLaunchJson = JSON.stringify({ args: ['ccs.js', 'bar', 'serve', '--port', '3000'] }); + fs.writeFileSync(path.join(ccsDir, 'bar.json'), oldBarJson); + fs.writeFileSync(path.join(ccsDir, 'bar', 'launch.json'), oldLaunchJson); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps } = makePortDeps(ccsDir); + deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }); + deps.spawnDetachedServer = () => { + throw new Error('spawn failed'); + }; + + await handleBarLaunch(['--port', '3999'], deps); + expect(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')).toBe(oldBarJson); + expect(fs.readFileSync(path.join(ccsDir, 'bar', 'launch.json'), 'utf8')).toBe(oldLaunchJson); + }); + + it('without --port, prefers the port recorded in bar.json (sticky port)', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'bar.json'), + JSON.stringify({ baseUrl: 'http://127.0.0.1:3777', port: 3777, authMode: 'loopback' }) + ); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + + await handleBarLaunch([], deps); + + expect(seen.getPortCandidates?.[0]).toBe(3777); + expect(seen.spawnPort).toBe(3777); + }); +}); + +describe('resolveBarPort: launch.json fallback survives `ccs bar stop`', () => { + // `ccs bar stop` deletes bar.json, so bar.json alone cannot carry the sticky + // port across a stop/start cycle. launch.json (refreshed by launch, NOT + // deleted by stop) records the port in its args and acts as the fallback. + it('falls back to the --port recorded in launch.json when bar.json is absent', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'bar', 'launch.json'), + JSON.stringify({ + schema: 1, + runtime: '/usr/bin/node', + args: ['/x/ccs.js', 'bar', 'serve', '--port', '3456'], + home: tempHome, + }) + ); + + const { resolveBarPort } = await loadLaunchSubcommand(); + expect(resolveBarPort(ccsDir)).toBe(3456); + }); + + it('bar.json port wins over launch.json when both exist', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'bar.json'), + JSON.stringify({ baseUrl: 'http://127.0.0.1:4000', port: 4000, authMode: 'loopback' }) + ); + fs.writeFileSync( + path.join(ccsDir, 'bar', 'launch.json'), + JSON.stringify({ + schema: 1, + runtime: '/usr/bin/node', + args: ['/x/ccs.js', 'bar', 'serve', '--port', '3456'], + home: tempHome, + }) + ); + + const { resolveBarPort } = await loadLaunchSubcommand(); + expect(resolveBarPort(ccsDir)).toBe(4000); + }); + + it('returns null when launch.json has no --port and bar.json is absent', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'bar', 'launch.json'), + JSON.stringify({ + schema: 1, + runtime: '/usr/bin/node', + args: ['/x/ccs.js', 'bar', 'serve'], + home: tempHome, + }) + ); + + const { resolveBarPort } = await loadLaunchSubcommand(); + expect(resolveBarPort(ccsDir)).toBeNull(); + }); +}); diff --git a/tests/unit/commands/bar-lifecycle-hardening.test.ts b/tests/unit/commands/bar-lifecycle-hardening.test.ts new file mode 100644 index 00000000..899131c4 --- /dev/null +++ b/tests/unit/commands/bar-lifecycle-hardening.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn } from 'child_process'; +import { + defaultFindRunningServer, + resolveBarPort, +} from '../../../src/commands/bar/bar-server-probe'; +import { + serializeBarServerProcessRecord, + getProcessBirthIdentity, + removeBarDiscoveryIfNoProcess, + removeBarServerProcessRecordIfOwned, + stopDetachedBarServer, + stopBarServerProcessFile, + stopRecordedBarServer, +} from '../../../src/commands/bar/bar-process-control'; +import { parsePortFlag, validatePortArgs } from '../../../src/commands/bar/port-arg'; +import { handleBarServe } from '../../../src/commands/bar/serve-subcommand'; +import { handleBarStop } from '../../../src/commands/bar/stop-subcommand'; + +let tempHome: string; +let originalHome: string | undefined; +let originalCcsHome: string | undefined; +const liveChildren = new Set>(); + +beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-lifecycle-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + process.env.HOME = tempHome; + process.env.CCS_HOME = path.join(tempHome, '.ccs'); + process.exitCode = 0; +}); + +afterEach(() => { + for (const child of liveChildren) { + try { + child.kill('SIGKILL'); + } catch { + // Already exited. + } + } + liveChildren.clear(); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalCcsHome === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = originalCcsHome; + process.exitCode = 0; + fs.rmSync(tempHome, { recursive: true, force: true }); +}); + +describe('strict Bar port parsing', () => { + it('rejects numeric prefixes, fractions, signs, whitespace, and out-of-range values', () => { + for (const raw of ['3999junk', '3.5', '+3999', ' 3999', '0', '65536']) { + expect(parsePortFlag(['--port', raw])).toEqual({ present: true, port: null }); + } + expect(parsePortFlag(['--port', '3999'])).toEqual({ present: true, port: 3999 }); + }); + + it('rejects unknown and duplicate launch options', () => { + expect(validatePortArgs(['--porrt', '3999'])).toBe('Unknown option: --porrt'); + expect(validatePortArgs(['--port', '3999', '--port', '4000'])).toBe('Duplicate option: --port'); + }); + + it('rejects malformed persisted ports in both discovery files', () => { + const ccsDir = process.env.CCS_HOME!; + fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify({ port: 3999.5 })); + fs.writeFileSync( + path.join(ccsDir, 'bar', 'launch.json'), + JSON.stringify({ args: ['ccs.js', 'bar', 'serve', '--port', '4555junk'] }) + ); + expect(resolveBarPort(ccsDir)).toBeNull(); + }); +}); + +describe('verified Bar process stopping', () => { + const rawRecord = serializeBarServerProcessRecord({ pid: 4321, birthIdentity: 'birth-a' }); + + it('does not signal when the PID birth identity changed', async () => { + let signaled = false; + const outcome = await stopRecordedBarServer(rawRecord, { + getProcessBirthIdentity: () => 'birth-b', + killProcess: () => { + signaled = true; + }, + }); + expect(outcome.result).toBe('identity-mismatch'); + expect(signaled).toBe(false); + }); + + it('preserves server.pid and bar.json on mismatch, EPERM, and timeout', async () => { + for (const failure of [ + 'identity-mismatch', + 'permission-denied', + 'signal-failed', + 'timeout', + ] as const) { + const ccsDir = path.join(tempHome, failure); + const pidPath = path.join(ccsDir, 'bar', 'server.pid'); + const barJsonPath = path.join(ccsDir, 'bar.json'); + fs.mkdirSync(path.dirname(pidPath), { recursive: true }); + fs.writeFileSync(pidPath, rawRecord); + fs.writeFileSync(barJsonPath, '{}'); + + await handleBarStop([], { + getCcsDir: () => ccsDir, + getProcessBirthIdentity: () => (failure === 'identity-mismatch' ? 'birth-b' : 'birth-a'), + killProcess: () => { + if (failure === 'permission-denied') { + const err = new Error('not permitted') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + } + if (failure === 'signal-failed') throw new Error('signal transport failed'); + }, + waitForProcessExit: async () => (failure === 'timeout' ? 'timeout' : 'exited'), + }); + + expect(fs.existsSync(pidPath)).toBe(true); + expect(fs.existsSync(barJsonPath)).toBe(true); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + } + }); + + it('never signals a legacy integer PID and gives manual recovery guidance', async () => { + const ccsDir = path.join(tempHome, 'legacy'); + const pidPath = path.join(ccsDir, 'bar', 'server.pid'); + fs.mkdirSync(path.dirname(pidPath), { recursive: true }); + fs.writeFileSync(pidPath, '4321'); + let signaled = false; + + await handleBarStop([], { + getCcsDir: () => ccsDir, + killProcess: () => { + signaled = true; + }, + }); + + expect(signaled).toBe(false); + expect(fs.existsSync(pidPath)).toBe(true); + expect(process.exitCode).toBe(1); + }); + + it('atomically preserves a replacement record written while the old process stops', async () => { + const pidPath = path.join(tempHome, 'race', 'server.pid'); + fs.mkdirSync(path.dirname(pidPath), { recursive: true }); + fs.writeFileSync(pidPath, rawRecord); + const replacement = serializeBarServerProcessRecord({ + pid: 9876, + birthIdentity: 'replacement-birth', + }); + + const outcome = await stopBarServerProcessFile(pidPath, { + getProcessBirthIdentity: () => 'birth-a', + killProcess: () => fs.writeFileSync(pidPath, replacement), + waitForProcessExit: async () => 'exited', + }); + + expect(outcome.result).toBe('stopped'); + expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement); + }); + + it('does not let an old serve cleanup unlink a replacement record', () => { + const pidPath = path.join(tempHome, 'serve-race', 'server.pid'); + fs.mkdirSync(path.dirname(pidPath), { recursive: true }); + const replacement = serializeBarServerProcessRecord({ + pid: 9876, + birthIdentity: 'replacement-birth', + }); + fs.writeFileSync(pidPath, replacement); + + removeBarServerProcessRecordIfOwned(pidPath, { pid: 4321, birthIdentity: 'birth-a' }); + + expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement); + }); + + it('stops a real recorded process through the claimed process file', async () => { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }); + liveChildren.add(child); + const birthIdentity = await waitForBirthIdentity(child.pid!); + const pidPath = path.join(tempHome, 'real-stop', 'server.pid'); + fs.mkdirSync(path.dirname(pidPath), { recursive: true }); + fs.writeFileSync(pidPath, serializeBarServerProcessRecord({ pid: child.pid!, birthIdentity })); + + const outcome = await stopBarServerProcessFile(pidPath); + + expect(outcome.result).toBe('stopped'); + expect(fs.existsSync(pidPath)).toBe(false); + liveChildren.delete(child); + }); + + it('stops a real recorded process through the launch-move stop path', async () => { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }); + liveChildren.add(child); + const birthIdentity = await waitForBirthIdentity(child.pid!); + const ccsDir = path.join(tempHome, 'real-move'); + const pidPath = path.join(ccsDir, 'bar', 'server.pid'); + fs.mkdirSync(path.dirname(pidPath), { recursive: true }); + fs.writeFileSync(pidPath, serializeBarServerProcessRecord({ pid: child.pid!, birthIdentity })); + await stopDetachedBarServer(ccsDir); + + expect(fs.existsSync(pidPath)).toBe(false); + liveChildren.delete(child); + }); +}); + +describe('Bar serve publication ownership', () => { + it('publishes server.pid before bar.json so old-stop cleanup preserves replacement discovery', async () => { + const ccsDir = path.join(tempHome, 'serve-publication'); + const pidPath = path.join(ccsDir, 'bar', 'server.pid'); + const barJsonPath = path.join(ccsDir, 'bar.json'); + const publicationOrder: string[] = []; + let oldStopRemovedDiscovery: boolean | null = null; + + await handleBarServe(['--port', '4555'], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 4555, + startServer: async () => ({ port: 4555, baseUrl: 'http://127.0.0.1:4555' }), + getProcessBirthIdentity: () => 'replacement-birth', + writeFile: (filePath, content) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + publicationOrder.push(filePath); + if (filePath === barJsonPath) { + oldStopRemovedDiscovery = removeBarDiscoveryIfNoProcess(barJsonPath, pidPath); + } + }, + onSignal: () => {}, + exit: (code) => { + throw new Error(`unexpected exit ${code}`); + }, + }); + + expect(publicationOrder).toEqual([pidPath, barJsonPath]); + expect(oldStopRemovedDiscovery).toBe(false); + expect(fs.existsSync(pidPath)).toBe(true); + expect(JSON.parse(fs.readFileSync(barJsonPath, 'utf8')).port).toBe(4555); + }); + + it('conditionally rolls back its owned process record when discovery publication fails', async () => { + const ccsDir = path.join(tempHome, 'serve-rollback'); + const pidPath = path.join(ccsDir, 'bar', 'server.pid'); + const barJsonPath = path.join(ccsDir, 'bar.json'); + + await expect( + handleBarServe(['--port', '4555'], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 4555, + startServer: async () => ({ port: 4555, baseUrl: 'http://127.0.0.1:4555' }), + getProcessBirthIdentity: () => 'replacement-birth', + writeFile: (filePath, content) => { + if (filePath === barJsonPath) throw new Error('discovery write failed'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + }, + onSignal: () => {}, + exit: (code) => { + throw new Error(`exit ${code}`); + }, + }) + ).rejects.toThrow('exit 1'); + + expect(fs.existsSync(pidPath)).toBe(false); + }); +}); + +async function waitForBirthIdentity(pid: number): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const identity = getProcessBirthIdentity(pid); + if (identity !== null) return identity; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`Process ${pid} never became observable`); +} + +describe('Bar server identity probe', () => { + it('ignores unrelated services returning 401 or 403 without a CCS proof', async () => { + for (const status of [401, 403]) { + const server = http.createServer((_req, res) => { + res.writeHead(status); + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as { port: number }).port; + const ccsDir = path.join(tempHome, `status-${status}`); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify({ port })); + try { + const result = await defaultFindRunningServer(ccsDir); + expect(result?.port).not.toBe(port); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + } + }); +}); diff --git a/tests/unit/commands/bar-lifecycle-subcommands.test.ts b/tests/unit/commands/bar-lifecycle-subcommands.test.ts index fdebbc2b..60c0ed04 100644 --- a/tests/unit/commands/bar-lifecycle-subcommands.test.ts +++ b/tests/unit/commands/bar-lifecycle-subcommands.test.ts @@ -15,6 +15,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +function processRecord(pid: number, birthIdentity = 'test-birth'): string { + return JSON.stringify({ pid, birthIdentity }, null, 2); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -24,6 +28,7 @@ let tempHome: string; let originalCcsHome: string | undefined; let originalConsoleLog: typeof console.log; let originalConsoleError: typeof console.error; +let originalExitCode: number | undefined; function captureConsole(): void { originalConsoleLog = console.log; @@ -113,6 +118,8 @@ beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-lifecycle-test-')); originalCcsHome = process.env.CCS_HOME; + originalExitCode = process.exitCode; + process.exitCode = 0; process.env.CCS_HOME = tempHome; }); @@ -124,6 +131,7 @@ afterEach(() => { } else { process.env.CCS_HOME = originalCcsHome; } + process.exitCode = originalExitCode ?? 0; try { fs.rmSync(tempHome, { recursive: true, force: true }); @@ -214,6 +222,7 @@ describe('serve: start new server', () => { exit: (code: number) => { throw new Error(`__EXIT_${code}__`); }, + getProcessBirthIdentity: () => 'test-birth', }); // bar.json must be written @@ -226,7 +235,10 @@ describe('serve: start new server', () => { // server.pid must be written const pidPath = path.join(ccsDir, 'bar', 'server.pid'); expect(writtenFiles[pidPath]).toBeDefined(); - expect(writtenFiles[pidPath]).toBe(String(process.pid)); + expect(JSON.parse(writtenFiles[pidPath])).toEqual({ + pid: process.pid, + birthIdentity: 'test-birth', + }); // Both SIGINT and SIGTERM handlers registered expect(signals).toContain('SIGINT'); @@ -309,7 +321,7 @@ describe('stop: SIGTERM and cleanup', () => { const ccsDir = path.join(tempHome, '.ccs'); const barDir = path.join(ccsDir, 'bar'); fs.mkdirSync(barDir, { recursive: true }); - fs.writeFileSync(path.join(barDir, 'server.pid'), '12345'); + fs.writeFileSync(path.join(barDir, 'server.pid'), processRecord(12345)); fs.writeFileSync(path.join(ccsDir, 'bar.json'), '{"baseUrl":"http://127.0.0.1:3000"}'); const killed: Array<{ pid: number; signal: string }> = []; @@ -329,6 +341,8 @@ describe('stop: SIGTERM and cleanup', () => { killProcess: (pid: number, signal: string) => { killed.push({ pid, signal }); }, + getProcessBirthIdentity: () => 'test-birth', + waitForProcessExit: async () => 'exited', removeFile: (filePath: string) => { removed.push(filePath); }, @@ -338,7 +352,7 @@ describe('stop: SIGTERM and cleanup', () => { // Both pid and bar.json must be removed expect(removed.some((p) => p.includes('server.pid'))).toBe(true); expect(removed.some((p) => p.includes('bar.json'))).toBe(true); - expect(allOutput()).toMatch(/\[OK\].*SIGTERM/i); + expect(allOutput()).toMatch(/\[OK\].*stopped/i); }); it('prints guidance and returns cleanly when no server.pid exists', async () => { @@ -370,7 +384,8 @@ describe('stop: SIGTERM and cleanup', () => { await handleBarStop([], { getCcsDir: () => ccsDir, - readPidFile: () => '99999', + readPidFile: () => processRecord(99999), + getProcessBirthIdentity: () => null, killProcess: () => { const err = new Error('no such process') as NodeJS.ErrnoException; err.code = 'ESRCH'; @@ -402,9 +417,9 @@ describe('stop: SIGTERM and cleanup', () => { }, }); - expect(allOutput()).toMatch(/\[X\].*invalid/i); - // Corrupted pid file must be cleaned up - expect(removed.some((p) => p.includes('server.pid'))).toBe(true); + expect(allOutput()).toMatch(/\[X\].*verified process record/i); + // Unverified state is preserved rather than risking removal of recovery evidence. + expect(removed.some((p) => p.includes('server.pid'))).toBe(false); }); }); @@ -419,7 +434,7 @@ describe('status: running state reporting', () => { await handleBarStatus([], { getCcsDir: () => ccsDir, - readPidFile: () => '12345', + readPidFile: () => processRecord(12345), isProcessAlive: () => true, probeServer: async () => true, readBarJsonBaseUrl: () => 'http://127.0.0.1:3000', @@ -451,7 +466,7 @@ describe('status: running state reporting', () => { await handleBarStatus([], { getCcsDir: () => ccsDir, - readPidFile: () => '99999', + readPidFile: () => processRecord(99999), isProcessAlive: () => false, probeServer: async () => false, readBarJsonBaseUrl: () => null, @@ -461,13 +476,34 @@ describe('status: running state reporting', () => { expect(allOutput()).toMatch(/99999/); }); + it('reports legacy integer PIDs as unverified and does not inspect their liveness', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarStatus } = await loadStatusSubcommand(); + let inspected = false; + + await handleBarStatus([], { + getCcsDir: () => ccsDir, + readPidFile: () => '12345', + isProcessAlive: () => { + inspected = true; + return true; + }, + probeServer: async () => true, + readBarJsonBaseUrl: () => null, + }); + + expect(inspected).toBe(false); + expect(allOutput()).toMatch(/legacy.*unverified PID 12345/i); + expect(allOutput()).toMatch(/ps -p 12345 -o command=/); + }); + it('reports alive-but-unreachable when PID is alive but HTTP probe fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); const { handleBarStatus } = await loadStatusSubcommand(); await handleBarStatus([], { getCcsDir: () => ccsDir, - readPidFile: () => '12345', + readPidFile: () => processRecord(12345), isProcessAlive: () => true, probeServer: async () => false, readBarJsonBaseUrl: () => 'http://127.0.0.1:3000', diff --git a/tests/unit/commands/cliproxy-catalog-json.test.ts b/tests/unit/commands/cliproxy-catalog-json.test.ts index 7bc3e5ea..8c99c79a 100644 --- a/tests/unit/commands/cliproxy-catalog-json.test.ts +++ b/tests/unit/commands/cliproxy-catalog-json.test.ts @@ -42,10 +42,7 @@ describe('cliproxy catalog --json output', () => { it('includes metadata fields when present on model entries', () => { handleCatalogJson(); - const parsed = JSON.parse(capturedOutput[0]) as Record< - string, - Array> - >; + const parsed = JSON.parse(capturedOutput[0]) as Record>>; const allModels = Object.values(parsed).flat(); // At least some models in the static catalog have tier set @@ -56,15 +53,19 @@ describe('cliproxy catalog --json output', () => { for (const model of withTier) { expect(['free', 'pro', 'ultra']).toContain(model.tier); } + + const withContextWindow = allModels.filter((m) => m.contextWindow !== undefined); + expect(withContextWindow.length).toBeGreaterThan(0); + for (const model of withContextWindow) { + expect(Number.isInteger(model.contextWindow)).toBe(true); + expect(model.contextWindow as number).toBeGreaterThan(0); + } }); it('omits undefined optional fields instead of including nulls', () => { handleCatalogJson(); - const parsed = JSON.parse(capturedOutput[0]) as Record< - string, - Array> - >; + const parsed = JSON.parse(capturedOutput[0]) as Record>>; const allModels = Object.values(parsed).flat(); for (const model of allModels) { @@ -78,10 +79,7 @@ describe('cliproxy catalog --json output', () => { it('includes explicit false boolean values in output', () => { handleCatalogJson(); - const parsed = JSON.parse(capturedOutput[0]) as Record< - string, - Array> - >; + const parsed = JSON.parse(capturedOutput[0]) as Record>>; const allModels = Object.values(parsed).flat(); // Static catalog has models with extendedContext: false @@ -92,10 +90,7 @@ describe('cliproxy catalog --json output', () => { it('includes thinking configuration when present on models', () => { handleCatalogJson(); - const parsed = JSON.parse(capturedOutput[0]) as Record< - string, - Array> - >; + const parsed = JSON.parse(capturedOutput[0]) as Record>>; const allModels = Object.values(parsed).flat(); // Static catalog has thinking models (e.g. Claude Opus 4.6 Thinking) diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index 955ea422..dc1fa4b9 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test'; +import { displayClaudeQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/claude'; async function loadQuotaCommandTestExports() { const moduleId = Date.now() + Math.random(); @@ -9,6 +10,43 @@ async function loadQuotaCommandTestExports() { } describe('cliproxy quota subcommand failure formatting', () => { + it('renders Claude usage-probe 429 as a warning with an inference-safe hint', () => { + const output: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + try { + displayClaudeQuotaSection([ + { + account: 'healthy@example.com', + quota: { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: 1, + accountId: 'healthy@example.com', + error: 'Claude usage status temporarily unavailable', + errorCode: 'usage_probe_unavailable', + actionHint: + 'Inference may still be available. Retry the Claude quota status check later.', + httpStatus: 429, + errorDetail: 'retry-after:0', + retryable: true, + }, + }, + ]); + } finally { + console.log = originalLog; + } + + expect(output.join('\n')).toContain('[!] healthy@example.com'); + expect(output.join('\n')).not.toContain('[X] healthy@example.com'); + expect(output.join('\n')).toContain('Claude usage status temporarily unavailable'); + expect(output.join('\n')).toContain('Inference may still be available'); + expect(output.join('\n')).toContain('HTTP 429 | Code: usage_probe_unavailable | Retryable'); + expect(output.join('\n')).toContain('Detail: retry-after:0'); + }); + it('builds Gemini failure lines with the remediation hint, code, and detail', async () => { const { getQuotaFailureDisplayEntries } = await loadQuotaCommandTestExports(); diff --git a/tests/unit/copilot/copilot-executor-env.test.ts b/tests/unit/copilot/copilot-executor-env.test.ts index 982287eb..3b4f7289 100644 --- a/tests/unit/copilot/copilot-executor-env.test.ts +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { + buildCopilotClaudeLaunchArgs, generateCopilotEnv, resolveCopilotImageAnalysisEnv, } from '../../../src/copilot/copilot-executor'; @@ -15,6 +16,13 @@ const baseConfig: CopilotConfig = { model: 'gpt-4.1', }; +it('disallows native WebSearch without CCS steering when the Copilot snapshot is disabled', () => { + const args = buildCopilotClaudeLaunchArgs(['smoke'], false, false); + expect(args).toContain('--disallowedTools'); + expect(args).toContain('WebSearch'); + expect(args).not.toContain('--append-system-prompt'); +}); + describe('generateCopilotEnv', () => { it('normalizes deprecated raptor-mini model selections to the safe default', () => { const env = generateCopilotEnv({ diff --git a/tests/unit/cursor/cursor-profile-executor.test.ts b/tests/unit/cursor/cursor-profile-executor.test.ts index 1f144896..40c6fdd6 100644 --- a/tests/unit/cursor/cursor-profile-executor.test.ts +++ b/tests/unit/cursor/cursor-profile-executor.test.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import { + buildCursorClaudeLaunchArgs, executeCursorProfile, generateCursorEnv, resolveCursorImageAnalysisEnv, @@ -19,6 +20,13 @@ const BASE_CONFIG: CursorConfig = { model: 'gpt-5.3-codex', }; +it('disallows native WebSearch without CCS steering when the Cursor snapshot is disabled', () => { + const args = buildCursorClaudeLaunchArgs(['smoke'], false); + expect(args).toContain('--disallowedTools'); + expect(args).toContain('WebSearch'); + expect(args).not.toContain('--append-system-prompt'); +}); + describe('cursor-profile-executor', () => { let originalCcsHome: string | undefined; let tempDir: string; diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index f7d1b626..dcece2a6 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -284,6 +284,168 @@ describe('InstanceManager MCP sync', () => { expect(manager.listInstances()).toEqual(['work']); }); + for (const testCase of [ + { + label: 'isolated bare', + policy: { mode: 'isolated' as const }, + options: { bare: true }, + }, + { + label: 'shared context', + policy: { mode: 'shared' as const, group: 'review-safety' }, + options: {}, + }, + ]) { + it(`rejects an existing symlink instance root before ${testCase.label} mutation`, async () => { + const externalInstance = path.join(tempRoot, `external-${testCase.label.replace(' ', '-')}`); + const instancesDir = path.join(tempRoot, '.ccs', 'instances'); + fs.mkdirSync(externalInstance, { recursive: true }); + fs.writeFileSync(path.join(externalInstance, 'sentinel.txt'), 'unchanged', 'utf8'); + fs.mkdirSync(instancesDir, { recursive: true }); + fs.symlinkSync(externalInstance, path.join(instancesDir, 'work'), 'dir'); + + const manager = new InstanceManager(); + await expect( + manager.ensureInstance('work', testCase.policy, testCase.options) + ).rejects.toThrow('Unsafe account instance path'); + + expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.txt']); + expect(fs.readFileSync(path.join(externalInstance, 'sentinel.txt'), 'utf8')).toBe( + 'unchanged' + ); + expect(fs.readdirSync(instancesDir)).toEqual(['work']); + }); + } + + it('rejects a symlinked instances parent before creating a new instance or lock', async () => { + const externalInstances = path.join(tempRoot, 'external-instances'); + const managedInstances = path.join(tempRoot, '.ccs', 'instances'); + fs.mkdirSync(externalInstances, { recursive: true }); + fs.mkdirSync(path.dirname(managedInstances), { recursive: true }); + fs.symlinkSync(externalInstances, managedInstances, 'dir'); + + const manager = new InstanceManager(); + await expect( + manager.ensureInstance('new-profile', { mode: 'shared', group: 'review-safety' }) + ).rejects.toThrow('Unsafe account instance path'); + + expect(fs.readdirSync(externalInstances)).toEqual([]); + }); + + it('detects parent replacement and leaves only an empty redirected artifact', async () => { + const managedCcsDir = path.join(tempRoot, '.ccs'); + const managedInstances = path.join(managedCcsDir, 'instances'); + const displacedCcsDir = path.join(tempRoot, 'displaced-ccs'); + const externalTarget = path.join(tempRoot, 'parent-race-target'); + fs.mkdirSync(managedCcsDir, { recursive: true }); + fs.mkdirSync(externalTarget, { recursive: true }); + fs.writeFileSync(path.join(externalTarget, 'sentinel.txt'), 'unchanged', 'utf8'); + + const originalMkdirSync = fs.mkdirSync; + let replacedParent = false; + spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => { + if (directoryPath === managedInstances && !replacedParent) { + replacedParent = true; + fs.renameSync(managedCcsDir, displacedCcsDir); + fs.symlinkSync(externalTarget, managedCcsDir, 'dir'); + } + return originalMkdirSync(directoryPath, options); + }); + + const manager = new InstanceManager(); + await expect( + manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true }) + ).rejects.toThrow('Unsafe account instance path'); + + expect(replacedParent).toBe(true); + expect(fs.readdirSync(externalTarget).sort()).toEqual(['instances', 'sentinel.txt']); + expect(fs.readdirSync(path.join(externalTarget, 'instances'))).toEqual([]); + expect(fs.existsSync(path.join(externalTarget, 'instances', '.locks'))).toBe(false); + expect(fs.existsSync(path.join(externalTarget, 'instances', 'new-profile'))).toBe(false); + expect(fs.readdirSync(displacedCcsDir)).toEqual([]); + }); + + it('preserves created and unrelated directories when the redirected path is replaced', async () => { + const managedCcsDir = path.join(tempRoot, '.ccs'); + const managedInstances = path.join(managedCcsDir, 'instances'); + const displacedCcsDir = path.join(tempRoot, 'displaced-ccs-cleanup-race'); + const externalTarget = path.join(tempRoot, 'cleanup-race-target'); + const createdDirectory = path.join(externalTarget, 'created-instances'); + const redirectedInstances = path.join(externalTarget, 'instances'); + fs.mkdirSync(managedCcsDir, { recursive: true }); + fs.mkdirSync(externalTarget, { recursive: true }); + + const originalMkdirSync = fs.mkdirSync; + const originalLstatSync = fs.lstatSync; + let replacedParent = false; + let createdIdentityObserved = false; + let substitutedUnrelatedDirectory = false; + + spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => { + if (directoryPath === managedInstances && !replacedParent) { + replacedParent = true; + fs.renameSync(managedCcsDir, displacedCcsDir); + fs.symlinkSync(externalTarget, managedCcsDir, 'dir'); + } + return originalMkdirSync(directoryPath, options); + }); + spyOn(fs, 'lstatSync').mockImplementation((targetPath, options) => { + if ( + targetPath === managedCcsDir && + createdIdentityObserved && + !substitutedUnrelatedDirectory + ) { + substitutedUnrelatedDirectory = true; + fs.renameSync(redirectedInstances, createdDirectory); + originalMkdirSync(redirectedInstances); + } + const stats = originalLstatSync(targetPath, options); + if (targetPath === managedInstances && replacedParent && stats.isDirectory()) { + createdIdentityObserved = true; + } + return stats; + }); + + const manager = new InstanceManager(); + await expect( + manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true }) + ).rejects.toThrow('Unsafe account instance path'); + + expect(substitutedUnrelatedDirectory).toBe(true); + expect(fs.existsSync(redirectedInstances)).toBe(true); + expect(fs.readdirSync(redirectedInstances)).toEqual([]); + expect(fs.existsSync(createdDirectory)).toBe(true); + expect(fs.readdirSync(createdDirectory)).toEqual([]); + expect(fs.existsSync(path.join(redirectedInstances, '.locks'))).toBe(false); + expect(fs.existsSync(path.join(redirectedInstances, 'new-profile'))).toBe(false); + expect(fs.existsSync(path.join(createdDirectory, '.locks'))).toBe(false); + expect(fs.existsSync(path.join(createdDirectory, 'new-profile'))).toBe(false); + }); + + it('revalidates a newly created instance root before initializing its contents', async () => { + const externalInstance = path.join(tempRoot, 'post-create-race-target'); + fs.mkdirSync(externalInstance, { recursive: true }); + fs.writeFileSync(path.join(externalInstance, 'sentinel.txt'), 'unchanged', 'utf8'); + + const manager = new InstanceManager(); + const instancePath = manager.getInstancePath('new-profile'); + const originalMkdirSync = fs.mkdirSync; + spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => { + if (directoryPath === instancePath) { + fs.symlinkSync(externalInstance, instancePath, 'dir'); + return undefined; + } + return originalMkdirSync(directoryPath, options); + }); + + await expect( + manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true }) + ).rejects.toThrow('Unsafe account instance path'); + + expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.txt']); + expect(fs.readFileSync(path.join(externalInstance, 'sentinel.txt'), 'utf8')).toBe('unchanged'); + }); + it('skips shared symlinks and MCP sync for bare instance creation', async () => { const linkSharedSpy = spyOn( SharedManager.prototype, @@ -491,7 +653,9 @@ describe('InstanceManager MCP sync', () => { metadata?: Record; } >; - const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record< + const personalRegistry = readJson( + path.join(personalPath, 'plugins', 'known_marketplaces.json') + ) as Record< string, { installLocation?: string; @@ -552,10 +716,7 @@ describe('InstanceManager MCP sync', () => { const legacyRegistry = readJson( path.join(legacyPath, 'plugins', 'known_marketplaces.json') - ) as Record< - string, - { installLocation?: string; label?: string; refreshToken?: string } - >; + ) as Record; expect(legacyRegistry['claude-code-plugins']).toMatchObject({ installLocation: marketplacePath(legacyPath), label: 'Legacy marketplace', diff --git a/tests/unit/management/instance-directory.test.ts b/tests/unit/management/instance-directory.test.ts index da64d7f1..f3456d2d 100644 --- a/tests/unit/management/instance-directory.test.ts +++ b/tests/unit/management/instance-directory.test.ts @@ -86,6 +86,26 @@ describe('account instance directory enumeration', () => { expect(listAccountInstanceNames(instancesDir())).toEqual(['work']); }); + it('rejects instance entries that symlink outside the managed root', () => { + const externalInstance = path.join(tempRoot, 'external-instance'); + fs.mkdirSync(externalInstance, { recursive: true }); + fs.mkdirSync(instancesDir(), { recursive: true }); + fs.symlinkSync(externalInstance, path.join(instancesDir(), 'escaped'), 'dir'); + + expect(listAccountInstanceNames(instancesDir())).toEqual([]); + expect(listAccountInstancePaths(instancesDir())).toEqual([]); + }); + + it('rejects an instances root that is itself a symlink', () => { + const externalInstances = path.join(tempRoot, 'external-instances'); + fs.mkdirSync(path.join(externalInstances, 'work'), { recursive: true }); + fs.mkdirSync(path.dirname(instancesDir()), { recursive: true }); + fs.symlinkSync(externalInstances, instancesDir(), 'dir'); + + expect(listAccountInstanceNames(instancesDir())).toEqual([]); + expect(listAccountInstancePaths(instancesDir())).toEqual([]); + }); + it('keeps ccs doctor settings symlinks healthy when .locks exists', () => { createValidSettingsLayout(); diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 9ce51248..2df898fc 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -285,10 +285,26 @@ describe('model-pricing', () => { expect(sonnet5.cacheReadPerMillion).toBe(0.3); }); + it('should return Opus-tier pricing for Claude Opus 5', () => { + // Opus 5 mirrors Opus 4.8 rates; date-stamped ids resolve via stripDateSuffix. + const opus5 = getModelPricing('claude-opus-5'); + expect(opus5.inputPerMillion).toBe(5.0); + expect(opus5.outputPerMillion).toBe(25.0); + expect(opus5.cacheCreationPerMillion).toBe(6.25); + expect(opus5.cacheReadPerMillion).toBe(0.5); + expect(getModelPricing('claude-opus-5-20270101').inputPerMillion).toBe(5.0); + }); + + it('should apply fast-tier pricing for Claude Opus 5', () => { + const opus5fast = getModelPricing('claude-opus-5', { serviceTier: 'fast' }); + expect(opus5fast.inputPerMillion).toBe(10.0); + expect(opus5fast.outputPerMillion).toBe(50.0); + }); + it('should not map unknown future model families onto known family pricing', () => { const fallback = getModelPricing('unknown-model-xyz'); - expect(getModelPricing('claude-opus-5-20270101')).toEqual(fallback); + expect(getModelPricing('claude-opus-6-20270101')).toEqual(fallback); expect(getModelPricing('gemini-3.2-pro')).toEqual(fallback); }); }); diff --git a/tests/unit/proxy/messages-route.test.ts b/tests/unit/proxy/messages-route.test.ts index eb66ee5a..9cca4a5a 100644 --- a/tests/unit/proxy/messages-route.test.ts +++ b/tests/unit/proxy/messages-route.test.ts @@ -9,8 +9,13 @@ import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-rou import { attachDisconnectAbortHandlers, buildUpstreamAgentTimeouts, - handleProxyMessagesRequest, + handleProxyMessagesRequest as handleProxyMessagesRequestImpl, } from '../../../src/proxy/server/messages-route'; +import { + closeUpstreamDispatcher, + isBunRuntime, + toLogErrorInfo, +} from '../../../src/proxy/server/upstream-transport'; import { loadSettings } from '../../../src/utils/config-manager'; class FakeSocket extends EventEmitter { @@ -70,6 +75,24 @@ function buildProfile(profileName: string) { return profile!; } +function handleProxyMessagesRequest( + req: Parameters[0], + res: Parameters[1], + profile: Parameters[2], + expectedAuthToken: string, + insecureDispatcher?: Parameters[4] +) { + return handleProxyMessagesRequestImpl( + req, + res, + profile, + expectedAuthToken, + insecureDispatcher, + undefined, + globalThis.fetch + ); +} + beforeEach(() => { originalCcsHome = process.env.CCS_HOME; originalFetch = globalThis.fetch; @@ -191,6 +214,45 @@ describe('buildUpstreamAgentTimeouts', () => { }); }); +describe('closeUpstreamDispatcher', () => { + it('closes an owned dispatcher once across repeated cleanup', async () => { + let closeCalls = 0; + const dispatcher = { + close: async () => { + closeCalls += 1; + }, + } as never; + + await Promise.all([ + closeUpstreamDispatcher(dispatcher), + closeUpstreamDispatcher(dispatcher), + closeUpstreamDispatcher(dispatcher), + ]); + + expect(closeCalls).toBe(1); + }); +}); + +describe('toLogErrorInfo', () => { + it('preserves nested transport cause metadata for diagnostics', () => { + const cause = Object.assign(new Error('invalid onError method'), { + name: 'InvalidArgumentError', + code: 'UND_ERR_INVALID_ARG', + }); + const error = new TypeError('fetch failed', { cause }); + + expect(toLogErrorInfo(error)).toEqual({ + name: 'TypeError', + message: 'fetch failed', + cause: { + name: 'InvalidArgumentError', + message: 'invalid onError method', + code: 'UND_ERR_INVALID_ARG', + }, + }); + }); +}); + describe('handleProxyMessagesRequest', () => { it('dispatches secure upstream fetches with an explicit dispatcher (not undici defaults)', async () => { const activeProfile = buildProfile('hf'); @@ -217,7 +279,11 @@ describe('handleProxyMessagesRequest', () => { // A bare global-dispatcher fallback would reapply undici's 300s // headersTimeout/bodyTimeout and undercut the proxy's request timeout. - expect(capturedDispatcher).toBeDefined(); + if (isBunRuntime()) { + expect(capturedDispatcher).toBeUndefined(); + } else { + expect(capturedDispatcher).toBeDefined(); + } expect(res.statusCode).toBe(502); }); @@ -403,9 +469,13 @@ describe('handleProxyMessagesRequest', () => { ); await pending; - expect(capturedDispatcher).toBeInstanceOf(Agent); + if (isBunRuntime()) { + expect(capturedDispatcher).toBeUndefined(); + } else { + expect(capturedDispatcher).toBeInstanceOf(Agent); + } expect(capturedDispatcher).not.toBe(sharedDispatcher); - expect(closeCalls).toBe(1); + expect(closeCalls).toBe(isBunRuntime() ? 0 : 1); expect(res.statusCode).toBe(502); }); diff --git a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts index 656c4e7a..2497b2f4 100644 --- a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -321,4 +321,100 @@ describe('ProxyRequestTransformer regressions', () => { expect(result.tool_choice).toBe('auto'); }); + + it('merges the top-level system field with a mid-array system message into one leading system message', () => { + // Claude Code sends the main system prompt via the top-level `system` + // field AND a skill/plugin listing as a `role: "system"` message inside + // `messages` (see #1459). Prepending the top-level field unconditionally + // used to leave two non-adjacent `system` messages in the payload, which + // strict OpenAI-compatible backends (LiteLLM among them) reject with: + // `400 A 'system' message can only appear at index 0 of the messages array.` + const result = new ProxyRequestTransformer().transform({ + system: [{ type: 'text', text: 'You are Claude Code, a CLI tool.' }], + messages: [ + { role: 'user', content: 'ping' }, + { + role: 'system', + content: 'The following skills are available for use with the Skill tool:\n- foo', + }, + { role: 'user', content: 'pong' }, + ], + }); + + const systemMessages = result.messages.filter((message) => message.role === 'system'); + expect(systemMessages).toHaveLength(1); + expect(result.messages[0]).toEqual({ + role: 'system', + content: + 'You are Claude Code, a CLI tool.\n\nThe following skills are available for use with the Skill tool:\n- foo', + }); + expect(result.messages[1]).toEqual({ role: 'user', content: 'ping\npong' }); + }); + + it('hoists a late system message after complete parallel tool results without disturbing tool order', () => { + const result = new ProxyRequestTransformer().transform({ + system: 'base instructions', + messages: [ + { role: 'user', content: 'inspect both files' }, + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_1', name: 'read', input: { path: 'a.ts' } }, + { type: 'tool_use', id: 'toolu_2', name: 'read', input: { path: 'b.ts' } }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'a contents' }, + { type: 'tool_result', tool_use_id: 'toolu_2', content: 'b contents' }, + ], + }, + { role: 'system', content: 'late instructions' }, + { role: 'user', content: 'compare them' }, + ], + }); + + expect(result.messages).toEqual([ + { role: 'system', content: 'base instructions\n\nlate instructions' }, + { role: 'user', content: 'inspect both files' }, + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'toolu_1', + type: 'function', + function: { name: 'read', arguments: '{"path":"a.ts"}' }, + }, + { + id: 'toolu_2', + type: 'function', + function: { name: 'read', arguments: '{"path":"b.ts"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'a contents' }, + { role: 'tool', tool_call_id: 'toolu_2', content: 'b contents' }, + { role: 'user', content: 'compare them' }, + ]); + }); + + it('rejects a system message inserted before pending tool results', () => { + expect(() => + new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'read', input: { path: 'a.ts' } }], + }, + { role: 'system', content: 'interrupting instructions' }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a contents' }], + }, + ], + }) + ).toThrow('role must be "user" with tool_result blocks after assistant tool_use'); + }); }); diff --git a/tests/unit/proxy/transformers/request-transformer.test.ts b/tests/unit/proxy/transformers/request-transformer.test.ts index f3258f07..847f58ba 100644 --- a/tests/unit/proxy/transformers/request-transformer.test.ts +++ b/tests/unit/proxy/transformers/request-transformer.test.ts @@ -43,7 +43,7 @@ describe('ProxyRequestTransformer', () => { }); }); - it('accepts Claude Code system messages in the messages array', () => { + it('accepts Claude Code system messages in the messages array and hoists them to a single leading system message', () => { const transformer = new ProxyRequestTransformer(); const result = transformer.transform({ messages: [ @@ -53,10 +53,14 @@ describe('ProxyRequestTransformer', () => { ], }); + // Strict OpenAI-compatible backends (e.g. LiteLLM) reject any payload + // where `system` is not alone at index 0, so a mid-array `system` + // message must be hoisted rather than left in place. See #1459 for why + // the message must be accepted at all, and the coalesce-duplicate-system + // fix for why it can't simply stay where it landed. expect(result.messages).toEqual([ - { role: 'user', content: 'hello' }, { role: 'system', content: 'answer tersely' }, - { role: 'user', content: 'which model is this?' }, + { role: 'user', content: 'hello\nwhich model is this?' }, ]); }); diff --git a/tests/unit/scripts/github/ai-review-workflow.test.ts b/tests/unit/scripts/github/ai-review-workflow.test.ts index e454aea8..45c4ea02 100644 --- a/tests/unit/scripts/github/ai-review-workflow.test.ts +++ b/tests/unit/scripts/github/ai-review-workflow.test.ts @@ -34,6 +34,7 @@ describe('PR-Agent review lane migration', () => { expect(workflow).toContain('OPENAI_KEY'); expect(workflow).toContain('vars.AI_REVIEW_BASE_URL'); expect(workflow).toContain('vars.AI_REVIEW_MODEL'); + expect(workflow).toContain('vars.AI_REVIEW_REASONING_EFFORT'); expect(workflow).toContain('secrets.AI_REVIEW_API_KEY'); expect(workflow).toContain('github_action_config.auto_review'); expect(workflow).toContain("github.event.comment.body == '/review'"); diff --git a/tests/unit/scripts/hardening-inventory.test.ts b/tests/unit/scripts/hardening-inventory.test.ts index b5f02281..e131a75b 100644 --- a/tests/unit/scripts/hardening-inventory.test.ts +++ b/tests/unit/scripts/hardening-inventory.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -const { collectSyncCallSites } = require('../../../scripts/hardening-inventory.js'); +const { collectSyncCallSites, isTestFile } = require('../../../scripts/hardening-inventory.js'); describe('hardening-inventory sync call scanning', () => { test('ignores sync-call names inside regex literals after else', () => { @@ -34,3 +34,22 @@ describe('hardening-inventory sync call scanning', () => { expect(result.calls).toEqual(['readFileSync']); }); }); + +describe('hardening-inventory runtime file classification', () => { + test.each([ + 'src/cliproxy/__tests__/routing.test.ts', + 'src/commands/fixtures/help-output.ts', + 'src/auth/profile.spec.ts', + 'tests/unit/commands/profile.test.ts', + ])('excludes %s from runtime hotpaths', (filePath) => { + expect(isTestFile(filePath)).toBe(true); + }); + + test.each([ + 'src/cliproxy/routing/retry-settings.ts', + 'src/commands/help-command.ts', + 'src/utils/browser/mcp-installer.ts', + ])('keeps %s eligible for runtime hotpaths', (filePath) => { + expect(isTestFile(filePath)).toBe(false); + }); +}); diff --git a/tests/unit/scripts/maintainability-metrics.test.ts b/tests/unit/scripts/maintainability-metrics.test.ts index 8ca8fcfe..d1061f06 100644 --- a/tests/unit/scripts/maintainability-metrics.test.ts +++ b/tests/unit/scripts/maintainability-metrics.test.ts @@ -8,10 +8,31 @@ const { classifyThrows, countConsoleErrors, hasCreateLogger, + isTestFile, countLoc, collectMaintainabilityMetrics, } = require('../../../scripts/maintainability-metrics.js'); +describe('maintainability-metrics runtime file classification', () => { + test.each([ + 'src/cliproxy/__tests__/routing.test.ts', + 'src/commands/fixtures/help-output.ts', + 'src/auth/profile.spec.ts', + 'tests/unit/commands/profile.test.ts', + 'src\\auth\\__mocks__\\profile.ts', + ])('excludes %s from runtime metrics', (filePath) => { + expect(isTestFile(filePath)).toBe(true); + }); + + test.each([ + 'src/cliproxy/routing/retry-settings.ts', + 'src/commands/help-command.ts', + 'src/utils/browser/mcp-installer.ts', + ])('keeps %s in runtime metrics', (filePath) => { + expect(isTestFile(filePath)).toBe(false); + }); +}); + describe('maintainability-metrics.classifyThrows', () => { test('counts plain Error, typed, and other throws; ignores re-throws', () => { const source = [ @@ -94,14 +115,18 @@ describe('maintainability-metrics.collectMaintainabilityMetrics (fixtures tree)' fs.mkdirSync(path.join(root, 'src', 'commands'), { recursive: true }); fs.writeFileSync( path.join(root, 'src', 'commands', 'b.ts'), - ['console.error(\'cli print\');', "throw new Error('plain');"].join('\n') + ["console.error('cli print');", "throw new Error('plain');"].join('\n') ); // src/cliproxy/quota/q.ts: createLogger present, 1 plain throw, 0 console.error fs.mkdirSync(path.join(root, 'src', 'cliproxy', 'quota'), { recursive: true }); fs.writeFileSync( path.join(root, 'src', 'cliproxy', 'quota', 'q.ts'), - ["import { createLogger } from '../../../services/logging';", 'const logger = createLogger();', "throw new Error('quota plain');"].join('\n') + [ + "import { createLogger } from '../../../services/logging';", + 'const logger = createLogger();', + "throw new Error('quota plain');", + ].join('\n') ); // non-source file is ignored diff --git a/tests/unit/services/logging/cli-entry-log-redaction.test.ts b/tests/unit/services/logging/cli-entry-log-redaction.test.ts index cf3e34c9..ebd1c11f 100644 --- a/tests/unit/services/logging/cli-entry-log-redaction.test.ts +++ b/tests/unit/services/logging/cli-entry-log-redaction.test.ts @@ -63,7 +63,9 @@ beforeEach(() => { baselineUncaughtExceptionListeners = process.listeners('uncaughtException'); baselineUnhandledRejectionListeners = process.listeners('unhandledRejection'); - mock.module('../../../../src/utils/fetch-proxy-setup', () => ({})); + mock.module('../../../../src/utils/fetch-proxy-setup', () => ({ + applyGlobalFetchProxy: () => ({ enabled: false }), + })); mock.module('../../../../src/utils/error-manager', () => ({ ErrorManager: class ErrorManager { static async showProfileNotFound(): Promise {} diff --git a/tests/unit/services/logging/log-redaction.test.ts b/tests/unit/services/logging/log-redaction.test.ts index 124165eb..eb223abc 100644 --- a/tests/unit/services/logging/log-redaction.test.ts +++ b/tests/unit/services/logging/log-redaction.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'bun:test'; -import { redactContext } from '../../../../src/services/logging/log-redaction'; +import { + maskSecretTokens, + redactContext, + redactErrorInfo, +} from '../../../../src/services/logging/log-redaction'; describe('log redaction', () => { it('redacts sensitive keys and preserves non-sensitive values', () => { @@ -100,4 +104,31 @@ describe('log redaction', () => { }, }); }); + + it('redacts proxy URL userinfo from nested error causes', () => { + const redacted = redactErrorInfo({ + name: 'TypeError', + message: 'fetch failed', + cause: { + name: 'ProxyError', + code: 'ECONNREFUSED', + message: 'connect http://sentinel-user:sentinel-password@proxy.example:8080', + }, + }); + + expect(redacted?.cause).toEqual({ + name: 'ProxyError', + code: 'ECONNREFUSED', + message: 'connect http://[redacted]@proxy.example:8080', + }); + expect(JSON.stringify(redacted)).not.toContain('sentinel-user'); + expect(JSON.stringify(redacted)).not.toContain('sentinel-password'); + }); + + it('redacts URL userinfo without changing non-credential URLs', () => { + expect(maskSecretTokens('https://user:pass@example.com/path')).toBe( + 'https://[redacted]@example.com/path' + ); + expect(maskSecretTokens('https://example.com/path')).toBe('https://example.com/path'); + }); }); diff --git a/tests/unit/shared-claude-memory.test.ts b/tests/unit/shared-claude-memory.test.ts new file mode 100644 index 00000000..9ec355e0 --- /dev/null +++ b/tests/unit/shared-claude-memory.test.ts @@ -0,0 +1,447 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import SharedManager from '../../src/management/shared-manager'; + +describe('shared CLAUDE.md memory', () => { + let tempHome = ''; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + const claudeFile = () => path.join(tempHome, '.claude', 'CLAUDE.md'); + const ccsDir = () => path.join(tempHome, '.ccs'); + const sharedFile = () => path.join(ccsDir(), 'shared', 'CLAUDE.md'); + const instanceDir = (name: string) => path.join(ccsDir(), 'instances', name); + const instanceFile = (name: string) => path.join(instanceDir(name), 'CLAUDE.md'); + + function writeFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); + } + + function conflictFiles(filePath: string): string[] { + const prefix = `${path.basename(filePath)}.ccs-shared-conflict`; + return fs + .readdirSync(path.dirname(filePath)) + .filter((entry) => entry === prefix || entry.startsWith(`${prefix}-`)) + .map((entry) => path.join(path.dirname(filePath), entry)); + } + + function writeLegacyProfiles( + profiles: Record + ): void { + writeFile( + path.join(ccsDir(), 'profiles.json'), + JSON.stringify( + { + version: '2.0.0', + default: null, + profiles: Object.fromEntries( + Object.entries(profiles).map(([name, metadata]) => [ + name, + { + type: 'account', + created: '2026-08-08T00:00:00.000Z', + last_used: null, + ...metadata, + }, + ]) + ), + }, + null, + 2 + ) + ); + } + + function expectLinkTo(filePath: string, targetPath: string): void { + expect(fs.lstatSync(filePath).isSymbolicLink()).toBe(true); + expect(path.resolve(path.dirname(filePath), fs.readlinkSync(filePath))).toBe(targetPath); + } + + function expectInvalidInstancePath(operation: () => void): void { + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(TypeError); + expect((thrown as NodeJS.ErrnoException).code).toBe('EINVAL'); + } + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-claude-memory-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + + spyOn(os, 'homedir').mockReturnValue(tempHome); + process.env.HOME = tempHome; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + mock.restore(); + + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalCcsHome === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = originalCcsHome; + if (originalCcsDir === undefined) delete process.env.CCS_DIR; + else process.env.CCS_DIR = originalCcsDir; + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('seeds missing Markdown as empty text and links it through the shared root', () => { + const manager = new SharedManager(); + const workInstance = instanceDir('work'); + fs.mkdirSync(workInstance, { recursive: true }); + + manager.linkSharedDirectories(workInstance); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe(''); + expectLinkTo(sharedFile(), claudeFile()); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + it('preserves canonical instructions and quarantines an older instance variant', () => { + const manager = new SharedManager(); + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Stale local copy\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n'); + expectLinkTo(instanceFile('work'), sharedFile()); + const recoveries = conflictFiles(instanceFile('work')); + expect(recoveries).toHaveLength(1); + expect(fs.readFileSync(recoveries[0], 'utf8')).toBe('# Stale local copy\n'); + }); + + it('adopts the only instance variant when the canonical file is absent', () => { + const manager = new SharedManager(); + writeFile(instanceFile('work'), '# Adopt me\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Adopt me\n'); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + it('never inventories or mutates profile-local CLAUDE.md content', () => { + const manager = new SharedManager(); + writeLegacyProfiles({ + work: { shared_resource_mode: 'shared' }, + sandbox: { shared_resource_mode: 'profile-local', bare: true }, + }); + writeFile(instanceFile('work'), '# Shared account\n'); + writeFile(instanceFile('sandbox'), '# Isolated account\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n'); + expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Isolated account\n'); + expect(conflictFiles(instanceFile('sandbox'))).toEqual([]); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + it('matches uppercase profile metadata to its normalized instance name', () => { + const manager = new SharedManager(); + writeLegacyProfiles({ + work: { shared_resource_mode: 'shared' }, + Sandbox: { shared_resource_mode: 'profile-local', bare: true }, + }); + writeFile(instanceFile('work'), '# Shared account\n'); + writeFile(instanceFile('sandbox'), '# Uppercase isolated account\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n'); + expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Uppercase isolated account\n'); + expect(conflictFiles(instanceFile('sandbox'))).toEqual([]); + }); + + it('excludes legacy profile-name normalization collisions from inventory', () => { + const manager = new SharedManager(); + writeLegacyProfiles({ + work: { shared_resource_mode: 'shared' }, + Sandbox: { shared_resource_mode: 'profile-local', bare: true }, + sandbox: { shared_resource_mode: 'shared' }, + }); + writeFile(instanceFile('work'), '# Shared account\n'); + writeFile(instanceFile('sandbox'), '# Ambiguous legacy account\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n'); + expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Ambiguous legacy account\n'); + expect(conflictFiles(instanceFile('sandbox'))).toEqual([]); + }); + + it('quarantines every distinct variant instead of selecting by mtime', () => { + const manager = new SharedManager(); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + writeFile(instanceFile('work'), '# Work\n'); + writeFile(instanceFile('personal'), '# Personal\n'); + const now = Date.now(); + fs.utimesSync(instanceFile('work'), new Date(now - 60_000), new Date(now - 60_000)); + fs.utimesSync(instanceFile('personal'), new Date(now), new Date(now)); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe(''); + expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Work\n'); + expect(fs.readFileSync(conflictFiles(instanceFile('personal'))[0], 'utf8')).toBe( + '# Personal\n' + ); + expect( + logSpy.mock.calls.some(([message]) => + String(message).includes('No CLAUDE.md variant was selected') + ) + ).toBe(true); + }); + + it('is idempotent after canonical-first reconciliation', () => { + const manager = new SharedManager(); + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Diverged\n'); + + manager.linkSharedDirectories(instanceDir('work')); + const firstRecoveries = conflictFiles(instanceFile('work')); + manager.linkSharedDirectories(instanceDir('work')); + + expect(conflictFiles(instanceFile('work'))).toEqual(firstRecoveries); + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n'); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + it('never lets a later diverged instance overwrite canonical instructions', () => { + const manager = new SharedManager(); + writeFile(claudeFile(), '# Canonical\n'); + fs.mkdirSync(instanceDir('work'), { recursive: true }); + manager.linkSharedDirectories(instanceDir('work')); + + fs.unlinkSync(instanceFile('work')); + writeFile(instanceFile('work'), '# Newer but local\n'); + const future = new Date(Date.now() + 60_000); + fs.utimesSync(instanceFile('work'), future, future); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n'); + expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe( + '# Newer but local\n' + ); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + for (const linkErrorCode of ['EPERM', 'EACCES', 'ENOTSUP']) { + it(`copies conflict recovery when hard links fail with ${linkErrorCode}`, () => { + const manager = new SharedManager(); + const originalLinkSync = fs.linkSync; + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Diverged\n'); + spyOn(fs, 'linkSync').mockImplementation((existingPath, newPath) => { + if (String(newPath).includes('.ccs-shared-conflict')) { + throw Object.assign(new Error('simulated hard-link failure'), { code: linkErrorCode }); + } + return originalLinkSync(existingPath, newPath); + }); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Diverged\n'); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + } + + it('restores the original divergence when hard-link and copy recovery both fail', () => { + const manager = new SharedManager(); + const originalCopyFileSync = fs.copyFileSync; + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Diverged\n'); + spyOn(fs, 'linkSync').mockImplementation(() => { + throw Object.assign(new Error('simulated hard-link failure'), { code: 'EPERM' }); + }); + spyOn(fs, 'copyFileSync').mockImplementation((source, destination, mode) => { + if (String(destination).includes('.ccs-shared-conflict')) { + throw Object.assign(new Error('simulated copy failure'), { code: 'EACCES' }); + } + return originalCopyFileSync(source, destination, mode); + }); + + expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow( + 'simulated hard-link failure' + ); + expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Diverged\n'); + expect( + fs.readdirSync(instanceDir('work')).some((entry) => entry.includes('.ccs-shared-claim-')) + ).toBe(false); + }); + + it('recovers an interrupted claim before inventory and adoption', () => { + const manager = new SharedManager(); + const claimPath = `${instanceFile('work')}.ccs-shared-claim-interrupted`; + writeFile(claimPath, '# Interrupted\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Interrupted\n'); + expect(fs.existsSync(claimPath)).toBe(false); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + it('preserves both copies and aborts when a concurrent writer replaces the claimed path', () => { + const manager = new SharedManager(); + const originalRenameSync = fs.renameSync; + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Claimed\n'); + spyOn(fs, 'renameSync').mockImplementation((oldPath, newPath) => { + originalRenameSync(oldPath, newPath); + if (oldPath === instanceFile('work') && String(newPath).includes('.ccs-shared-claim-')) { + writeFile(instanceFile('work'), '# Concurrent writer\n'); + } + }); + + expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow( + 'Concurrent replacement detected' + ); + expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Concurrent writer\n'); + expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Claimed\n'); + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n'); + }); + + it('aborts when a late writer recreates the path during recovery publication', () => { + const manager = new SharedManager(); + const originalLinkSync = fs.linkSync; + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Claimed\n'); + spyOn(fs, 'linkSync').mockImplementation((existingPath, newPath) => { + const result = originalLinkSync(existingPath, newPath); + if (String(newPath).includes('.ccs-shared-conflict')) { + writeFile(instanceFile('work'), '# Late writer\n'); + } + return result; + }); + + expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow( + 'Path reappeared during reconciliation' + ); + expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Late writer\n'); + expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Claimed\n'); + }); + + it('aborts when the instance root is replaced during shared-root setup', () => { + const manager = new SharedManager(); + const workInstance = instanceDir('work'); + const originalInstance = instanceDir('work-original'); + const externalInstance = path.join(tempHome, 'external-work'); + const sentinelPath = path.join(externalInstance, 'sentinel.bin'); + const sentinel = Buffer.from([0x00, 0x7f, 0x80, 0xff]); + fs.mkdirSync(workInstance, { recursive: true }); + fs.mkdirSync(externalInstance, { recursive: true }); + fs.mkdirSync(path.join(tempHome, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + fs.writeFileSync(sentinelPath, sentinel); + + const originalMkdirSync = fs.mkdirSync; + let replaced = false; + spyOn(fs, 'mkdirSync').mockImplementation(((targetPath, options) => { + if (!replaced && String(targetPath) === path.join(tempHome, '.claude', 'plugins')) { + replaced = true; + fs.renameSync(workInstance, originalInstance); + fs.symlinkSync(externalInstance, workInstance, 'dir'); + } + return originalMkdirSync(targetPath, options); + }) as typeof fs.mkdirSync); + + expectInvalidInstancePath(() => manager.linkSharedDirectories(workInstance)); + expect(fs.readFileSync(sentinelPath)).toEqual(sentinel); + expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.bin']); + }); + + it('does not reconcile a regular replacement directory during shared-root setup', () => { + const manager = new SharedManager(); + const workInstance = instanceDir('work'); + const originalInstance = instanceDir('work-original'); + const replacementFixture = path.join(tempHome, 'replacement-work'); + const replacementMemory = path.join(replacementFixture, 'CLAUDE.md'); + const sentinelPath = path.join(replacementFixture, 'sentinel.bin'); + const memoryBytes = Buffer.from([0x23, 0x20, 0x80, 0xff, 0x0a]); + const sentinel = Buffer.from([0x11, 0x22, 0x33, 0x44]); + fs.mkdirSync(workInstance, { recursive: true }); + fs.mkdirSync(replacementFixture, { recursive: true }); + fs.mkdirSync(path.join(tempHome, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + fs.writeFileSync(replacementMemory, memoryBytes); + fs.writeFileSync(sentinelPath, sentinel); + + const originalMkdirSync = fs.mkdirSync; + let replaced = false; + spyOn(fs, 'mkdirSync').mockImplementation(((targetPath, options) => { + if (!replaced && String(targetPath) === path.join(tempHome, '.claude', 'plugins')) { + replaced = true; + fs.renameSync(workInstance, originalInstance); + fs.renameSync(replacementFixture, workInstance); + } + return originalMkdirSync(targetPath, options); + }) as typeof fs.mkdirSync); + + expectInvalidInstancePath(() => manager.linkSharedDirectories(workInstance)); + expect(fs.readFileSync(path.join(workInstance, 'CLAUDE.md'))).toEqual(memoryBytes); + expect(fs.readFileSync(path.join(workInstance, 'sentinel.bin'))).toEqual(sentinel); + expect(fs.readdirSync(workInstance).sort()).toEqual(['CLAUDE.md', 'sentinel.bin']); + }); + + it('aborts when the instance root is replaced during plugin linking', () => { + const manager = new SharedManager(); + const workInstance = instanceDir('work'); + const originalInstance = instanceDir('work-original'); + const externalInstance = path.join(tempHome, 'external-work'); + const externalPlugins = path.join(externalInstance, 'plugins'); + const sentinelPath = path.join(externalPlugins, 'sentinel.bin'); + const sentinel = Buffer.from([0x10, 0x20, 0x30, 0x40]); + fs.mkdirSync(path.join(workInstance, 'plugins'), { recursive: true }); + fs.mkdirSync(externalPlugins, { recursive: true }); + fs.writeFileSync(sentinelPath, sentinel); + + const originalLstatSync = fs.lstatSync; + let replaced = false; + spyOn(fs, 'lstatSync').mockImplementation(((targetPath, options) => { + const result = originalLstatSync(targetPath, options); + if (!replaced && String(targetPath) === path.join(workInstance, 'plugins')) { + replaced = true; + fs.renameSync(workInstance, originalInstance); + fs.symlinkSync(externalInstance, workInstance, 'dir'); + } + return result; + }) as typeof fs.lstatSync); + + expectInvalidInstancePath(() => manager.linkSharedDirectories(workInstance)); + expect(fs.readFileSync(sentinelPath)).toEqual(sentinel); + expect(fs.readdirSync(externalPlugins)).toEqual(['sentinel.bin']); + expect(fs.readdirSync(externalInstance)).toEqual(['plugins']); + }); + + it('rejects linking through a symlinked instances root without touching its target', () => { + const manager = new SharedManager(); + const managedInstances = path.join(ccsDir(), 'instances'); + const externalInstances = path.join(tempHome, 'external-instances'); + const externalWork = path.join(externalInstances, 'work'); + writeFile(path.join(externalWork, 'CLAUDE.md'), '# External\n'); + fs.mkdirSync(path.dirname(managedInstances), { recursive: true }); + fs.symlinkSync(externalInstances, managedInstances, 'dir'); + + expect(() => manager.linkSharedDirectories(path.join(managedInstances, 'work'))).toThrow( + 'Unsafe account instance path' + ); + expect(fs.readFileSync(path.join(externalWork, 'CLAUDE.md'), 'utf8')).toBe('# External\n'); + }); +}); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 555953eb..0053a7da 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -35,6 +35,18 @@ describe('SharedManager', () => { fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); } + function setMtime(filePath: string, mtimeMs: number): void { + const timestamp = new Date(mtimeMs); + fs.utimesSync(filePath, timestamp, timestamp); + } + + function findRecovery(filePath: string): string | undefined { + const prefix = `${path.basename(filePath)}.ccs-adopt-recovery`; + return fs + .readdirSync(path.dirname(filePath)) + .find((entry) => entry === prefix || entry.startsWith(`${prefix}-`)); + } + function readMarketplaceLocation(filePath: string, name = 'claude-code-plugins'): string { const parsed = readJson(filePath) as Record; return parsed[name]?.installLocation ?? ''; @@ -306,6 +318,473 @@ describe('SharedManager', () => { }); }); + describe('diverged settings adoption', () => { + it('adopts a diverged shared settings.json into ~/.claude before re-linking', () => { + const manager = new SharedManager(); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + writeJson(claudeSettingsPath, { enabledPlugins: { 'demo@market': false } }); + // Simulate Claude Code's atomic save (temp file + rename) replacing the + // managed symlink with a regular file carrying the user's latest change. + writeJson(sharedSettingsPath, { enabledPlugins: { 'demo@market': true } }); + setMtime(sharedSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000); + + manager.ensureSharedDirectories(); + + expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true); + expect(readJson(claudeSettingsPath)).toEqual({ + enabledPlugins: { 'demo@market': true }, + }); + expect(readJson(`${claudeSettingsPath}.bak-ccs-adopt`)).toEqual({ + enabledPlugins: { 'demo@market': false }, + }); + }); + + it('adopts a diverged instance settings.json during instance linking', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(claudeSettingsPath, { theme: 'light' }); + writeJson(instanceSettingsPath, { theme: 'dark' }); + setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000); + + manager.linkSharedDirectories(instancePath); + + expect(fs.lstatSync(instanceSettingsPath).isSymbolicLink()).toBe(true); + expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' }); + }); + + it('adopts a diverged instance plugin registry during instance linking', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + const claudeRegistryPath = path.join(claudeDir(), 'plugins', 'installed_plugins.json'); + const instanceRegistryPath = path.join(instancePath, 'plugins', 'installed_plugins.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(instancePath, 'plugins'), { recursive: true }); + writeJson(claudeRegistryPath, { version: 2, plugins: {} }); + // Simulate Claude Code's atomic save replacing the managed symlink with + // a regular file that records a plugin installed inside a session. + writeJson(instanceRegistryPath, { + version: 2, + plugins: { 'demo@demo-market': [{ scope: 'user', version: '1.0.0' }] }, + }); + setMtime(instanceRegistryPath, fs.statSync(claudeRegistryPath).mtimeMs + 2_000); + + manager.linkSharedDirectories(instancePath); + + expect(fs.lstatSync(instanceRegistryPath).isSymbolicLink()).toBe(true); + const adopted = readJson(claudeRegistryPath) as { plugins: Record }; + expect(Object.keys(adopted.plugins)).toContain('demo@demo-market'); + }); + + it('does not create a backup when the diverged copy matches the canonical file', () => { + const manager = new SharedManager(); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + writeJson(claudeSettingsPath, { theme: 'dark' }); + fs.copyFileSync(claudeSettingsPath, sharedSettingsPath); + + manager.ensureSharedDirectories(); + + expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true); + expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' }); + expect(fs.existsSync(`${claudeSettingsPath}.bak-ccs-adopt`)).toBe(false); + }); + + it('preserves diverged bytes when canonical backup creation fails', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(claudeSettingsPath, { theme: 'light' }); + writeJson(instanceSettingsPath, { theme: 'dark' }); + setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000); + + const originalLinkSync = fs.linkSync; + const linkSpy = spyOn(fs, 'linkSync').mockImplementation((( + existingPath: fs.PathLike, + newPath: fs.PathLike + ) => { + if (String(newPath).includes('.bak-ccs-adopt')) { + throw Object.assign(new Error('simulated backup failure'), { code: 'EIO' }); + } + return originalLinkSync(existingPath, newPath); + }) as typeof fs.linkSync); + + expect(() => manager.linkSharedDirectories(instancePath)).toThrow('simulated backup failure'); + linkSpy.mockRestore(); + + expect(readJson(claudeSettingsPath)).toEqual({ theme: 'light' }); + expect(readJson(instanceSettingsPath)).toEqual({ theme: 'dark' }); + }); + + it('does not let a stale Windows fallback roll back newer canonical settings', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('windows-copy'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + writeJson(claudeSettingsPath, { generation: 2 }); + manager.ensureSharedDirectories(); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(instanceSettingsPath, { generation: 1 }); + setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs - 2_000); + + Object.defineProperty(process, 'platform', { value: 'win32' }); + spyOn(fs, 'symlinkSync').mockImplementation(() => { + throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' }); + }); + + manager.linkSharedDirectories(instancePath); + + expect(readJson(claudeSettingsPath)).toEqual({ generation: 2 }); + expect(readJson(instanceSettingsPath)).toEqual({ generation: 2 }); + const recoveryName = findRecovery(instanceSettingsPath); + expect(recoveryName).toBeDefined(); + expect(readJson(path.join(path.dirname(instanceSettingsPath), recoveryName!))).toEqual({ + generation: 1, + }); + }); + + it('preserves divergence when the canonical settings symlink is dangling', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('dangling'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const missingTarget = path.join(tempRoot, 'missing', 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + fs.symlinkSync(missingTarget, claudeSettingsPath, 'file'); + writeJson(instanceSettingsPath, { preserved: true }); + + expect(() => manager.linkSharedDirectories(instancePath)).toThrow(); + + expect(fs.lstatSync(claudeSettingsPath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(missingTarget)).toBe(false); + expect(readJson(instanceSettingsPath)).toEqual({ preserved: true }); + }); + + it('preserves malformed managed JSON instead of poisoning canonical settings', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('malformed'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(claudeSettingsPath, { valid: true }); + fs.writeFileSync(instanceSettingsPath, '{broken-json', 'utf8'); + setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000); + + manager.linkSharedDirectories(instancePath); + + expect(readJson(claudeSettingsPath)).toEqual({ valid: true }); + expect(fs.lstatSync(instanceSettingsPath).isSymbolicLink()).toBe(true); + const recoveryName = findRecovery(instanceSettingsPath); + expect(recoveryName).toBeDefined(); + expect( + fs.readFileSync(path.join(path.dirname(instanceSettingsPath), recoveryName!), 'utf8') + ).toBe('{broken-json'); + }); + + it('aborts relinking when a writer replaces the path after it is claimed', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('concurrent'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(claudeSettingsPath, { generation: 0 }); + writeJson(instanceSettingsPath, { generation: 1 }); + setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000); + + const originalRenameSync = fs.renameSync; + const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( + oldPath: fs.PathLike, + newPath: fs.PathLike + ) => { + originalRenameSync(oldPath, newPath); + if ( + String(oldPath) === instanceSettingsPath && + String(newPath).includes('.ccs-adopt-claim-') + ) { + writeJson(instanceSettingsPath, { generation: 2 }); + } + }) as typeof fs.renameSync); + + expect(() => manager.linkSharedDirectories(instancePath)).toThrow( + 'Concurrent replacement detected' + ); + renameSpy.mockRestore(); + + expect(readJson(instanceSettingsPath)).toEqual({ generation: 2 }); + expect(fs.lstatSync(instanceSettingsPath).isSymbolicLink()).toBe(false); + expect(readJson(claudeSettingsPath)).toEqual({ generation: 0 }); + const recoveryName = findRecovery(instanceSettingsPath); + expect(recoveryName).toBeDefined(); + expect(readJson(path.join(path.dirname(instanceSettingsPath), recoveryName!))).toEqual({ + generation: 1, + }); + }); + + it('preserves a canonical write that lands during no-replace publication', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('canonical-race'); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const divergedPath = path.join(instancePath, 'settings.json'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + writeJson(divergedPath, { generation: 1 }); + setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const originalLinkSync = fs.linkSync; + const linkSpy = spyOn(fs, 'linkSync').mockImplementation((( + existingPath: fs.PathLike, + newPath: fs.PathLike + ) => { + if (String(newPath) === canonicalPath && String(existingPath).includes('.ccs-write-')) { + writeJson(canonicalPath, { generation: 99 }); + } + return originalLinkSync(existingPath, newPath); + }) as typeof fs.linkSync); + + expect(() => manager.linkSharedDirectories(instancePath)).toThrow(); + linkSpy.mockRestore(); + expect(readJson(canonicalPath)).toEqual({ generation: 99 }); + expect(readJson(divergedPath)).toEqual({ generation: 1 }); + expect(readJson(`${canonicalPath}.bak-ccs-adopt`)).toEqual({ generation: 0 }); + }); + + it('keeps adopted bytes recoverable when canonical changes after verification', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('late-canonical-writer'); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const divergedPath = path.join(instancePath, 'settings.json'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + writeJson(divergedPath, { generation: 1 }); + setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const originalUnlinkSync = fs.unlinkSync; + let injected = false; + const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { + if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) { + injected = true; + writeJson(canonicalPath, { generation: 99 }); + } + return originalUnlinkSync(targetPath); + }) as typeof fs.unlinkSync); + + manager.linkSharedDirectories(instancePath); + unlinkSpy.mockRestore(); + expect(readJson(canonicalPath)).toEqual({ generation: 99 }); + expect(readJson(`${divergedPath}.ccs-adopted-recovery`)).toEqual({ generation: 1 }); + }); + + it('fails reconciliation when the managed source reappears during late cleanup', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('late-source-writer'); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const divergedPath = path.join(instancePath, 'settings.json'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + writeJson(divergedPath, { generation: 1 }); + setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const originalUnlinkSync = fs.unlinkSync; + let injected = false; + const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { + if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) { + injected = true; + writeJson(divergedPath, { generation: 2 }); + } + return originalUnlinkSync(targetPath); + }) as typeof fs.unlinkSync); + + expect(() => manager.linkSharedDirectories(instancePath)).toThrow( + 'Concurrent replacement detected' + ); + unlinkSpy.mockRestore(); + expect(readJson(divergedPath)).toEqual({ generation: 2 }); + }); + + it('retries backup publication without replacing a concurrent backup', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('backup-race'); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const divergedPath = path.join(instancePath, 'settings.json'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + writeJson(divergedPath, { generation: 1 }); + setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const backupPath = `${canonicalPath}.bak-ccs-adopt`; + const originalLinkSync = fs.linkSync; + let injected = false; + const linkSpy = spyOn(fs, 'linkSync').mockImplementation((( + existingPath: fs.PathLike, + newPath: fs.PathLike + ) => { + if (!injected && String(newPath) === backupPath) { + injected = true; + writeJson(backupPath, { competing: true }); + } + return originalLinkSync(existingPath, newPath); + }) as typeof fs.linkSync); + + manager.linkSharedDirectories(instancePath); + linkSpy.mockRestore(); + expect(readJson(backupPath)).toEqual({ competing: true }); + expect(readJson(`${backupPath}-1`)).toEqual({ generation: 0 }); + expect(readJson(canonicalPath)).toEqual({ generation: 1 }); + }); + + it('preserves regular and symlink-target modes under a restrictive umask', () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const regularInstance = instanceDir('regular-mode'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(regularInstance, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + fs.chmodSync(canonicalPath, 0o640); + const regularDiverged = path.join(regularInstance, 'settings.json'); + writeJson(regularDiverged, { generation: 1 }); + setMtime(regularDiverged, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const previousUmask = process.umask(0o077); + try { + manager.linkSharedDirectories(regularInstance); + expect(fs.statSync(canonicalPath).mode & 0o777).toBe(0o640); + + const targetPath = path.join(tempRoot, 'external-settings.json'); + fs.renameSync(canonicalPath, targetPath); + fs.symlinkSync(targetPath, canonicalPath, 'file'); + fs.chmodSync(targetPath, 0o664); + const symlinkInstance = instanceDir('symlink-mode'); + fs.mkdirSync(symlinkInstance, { recursive: true }); + const symlinkDiverged = path.join(symlinkInstance, 'settings.json'); + writeJson(symlinkDiverged, { generation: 2 }); + setMtime(symlinkDiverged, fs.statSync(targetPath).mtimeMs + 2_000); + manager.linkSharedDirectories(symlinkInstance); + expect(fs.statSync(targetPath).mode & 0o777).toBe(0o664); + expect(fs.lstatSync(canonicalPath).isSymbolicLink()).toBe(true); + } finally { + process.umask(previousUmask); + } + }); + + it('uses the mode of the canonical inode actually claimed for publication', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('concurrent-mode'); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const divergedPath = path.join(instancePath, 'settings.json'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + fs.chmodSync(canonicalPath, 0o640); + writeJson(divergedPath, { generation: 1 }); + setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const originalRenameSync = fs.renameSync; + const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( + oldPath: fs.PathLike, + newPath: fs.PathLike + ) => { + if ( + String(oldPath) === canonicalPath && + String(newPath).includes('.ccs-canonical-claim-') + ) { + fs.chmodSync(canonicalPath, 0o664); + } + return originalRenameSync(oldPath, newPath); + }) as typeof fs.renameSync); + + manager.linkSharedDirectories(instancePath); + renameSpy.mockRestore(); + expect(fs.statSync(canonicalPath).mode & 0o777).toBe(0o664); + }); + + it('recovers an interrupted canonical claim before provisioning defaults', () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const claimPath = `${canonicalPath}.ccs-canonical-claim-interrupted`; + fs.mkdirSync(claudeDir(), { recursive: true }); + writeJson(canonicalPath, { preserved: true }); + fs.renameSync(canonicalPath, claimPath); + + manager.ensureSharedDirectories(); + + expect(readJson(canonicalPath)).toEqual({ preserved: true }); + expect(fs.existsSync(claimPath)).toBe(false); + }); + + it('quarantines a leftover plugin canonical claim without replacing a live symlink target', () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'plugins', 'installed_plugins.json'); + const intermediatePath = path.join(tempRoot, 'external', 'registry-link.json'); + const targetPath = path.join(tempRoot, 'external', 'installed_plugins.json'); + const claimPath = `${targetPath}.ccs-canonical-claim-interrupted`; + fs.mkdirSync(path.dirname(canonicalPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + writeJson(targetPath, { version: 2, plugins: { live: [] } }); + writeJson(claimPath, { version: 2, plugins: { preserved: [] } }); + fs.symlinkSync(targetPath, intermediatePath, 'file'); + fs.symlinkSync(intermediatePath, canonicalPath, 'file'); + + manager.ensureSharedDirectories(); + + expect(fs.lstatSync(canonicalPath).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(intermediatePath).isSymbolicLink()).toBe(true); + expect(readJson(targetPath)).toEqual({ version: 2, plugins: { live: [] } }); + expect(readJson(`${targetPath}.ccs-canonical-recovery`)).toEqual({ + version: 2, + plugins: { preserved: [] }, + }); + expect(fs.existsSync(claimPath)).toBe(false); + }); + + it('recovers a claimed plugin registry through a multi-hop dangling symlink chain', () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'plugins', 'installed_plugins.json'); + const intermediatePath = path.join(tempRoot, 'external', 'registry-link.json'); + const targetPath = path.join(tempRoot, 'external', 'missing', 'installed_plugins.json'); + const claimPath = `${targetPath}.ccs-canonical-claim-interrupted`; + fs.mkdirSync(path.dirname(canonicalPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + writeJson(claimPath, { version: 2, plugins: { recovered: [] } }); + fs.symlinkSync(targetPath, intermediatePath, 'file'); + fs.symlinkSync(intermediatePath, canonicalPath, 'file'); + + manager.ensureSharedDirectories(); + + expect(readJson(targetPath)).toEqual({ version: 2, plugins: { recovered: [] } }); + expect(fs.lstatSync(canonicalPath).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(intermediatePath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(claimPath)).toBe(false); + }); + }); + describe('marketplace registry ownership', () => { it('skips unstatable shared plugin entries during instance linking', () => { const manager = new SharedManager(); diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts new file mode 100644 index 00000000..3694f74c --- /dev/null +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'bun:test'; + +import { + ANTHROPIC_MODEL_ENV_KEYS, + isAnthropicModelEnvKey, +} from '../../../src/shared/extended-context-utils'; + +describe('Anthropic model env keys', () => { + it('includes all five tier mapping keys', () => { + expect([...ANTHROPIC_MODEL_ENV_KEYS]).toEqual([ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_FABLE_MODEL', + ]); + }); + + it('recognizes the fable tier key like the other tiers', () => { + expect(isAnthropicModelEnvKey('ANTHROPIC_DEFAULT_FABLE_MODEL')).toBe(true); + expect(isAnthropicModelEnvKey('ANTHROPIC_DEFAULT_HAIKU_MODEL')).toBe(true); + expect(isAnthropicModelEnvKey('ANTHROPIC_BASE_URL')).toBe(false); + }); +}); diff --git a/tests/unit/targets/settings-profile-websearch-launch.test.ts b/tests/unit/targets/settings-profile-websearch-launch.test.ts index 7e3f5346..feabb164 100644 --- a/tests/unit/targets/settings-profile-websearch-launch.test.ts +++ b/tests/unit/targets/settings-profile-websearch-launch.test.ts @@ -136,7 +136,7 @@ exit 0 expect(fs.existsSync(claudeArgsLogPath)).toBe(false); }); - it('keeps launch non-fatal when WebSearch is disabled', () => { + it('keeps native WebSearch disallowed without CCS steering when WebSearch is disabled', () => { if (process.platform === 'win32') return; fs.writeFileSync( @@ -154,8 +154,26 @@ exit 0 const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); expect(launchedArgs).toContain('--disallowedTools'); expect(launchedArgs).toContain('WebSearch'); - expect(launchedArgs).toContain('--append-system-prompt'); - expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET); + expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); + }); + + it('keeps native WebSearch disallowed without CCS steering for disabled headless launches', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: false', ''].join('\n'), + 'utf8' + ); + + const result = runCcs(['glm', '-p', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(fs.existsSync(claudeArgsLogPath)).toBe(true); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain('--disallowedTools'); + expect(launchedArgs).toContain('WebSearch'); + expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); }); it('writes a source-side launch trace for settings profiles when tracing is enabled', () => { @@ -187,7 +205,7 @@ exit 0 expect(launchEvent?.launcher).toBe('ccs.settings-profile'); expect(launchEvent?.nativeWebSearchDisallowed).toBe(true); - expect(launchEvent?.steeringPromptApplied).toBe(true); + expect(launchEvent?.steeringPromptApplied).toBe(false); expect(launchEvent?.settingsPath).toBe(settingsPath); }); }); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index ddb3ab71..5187e094 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -264,6 +264,7 @@ describe('CLAUDECODE environment stripping', () => { delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL; delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL; delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + delete process.env.ANTHROPIC_DEFAULT_FABLE_MODEL; delete process.env.ANTHROPIC_SMALL_FAST_MODEL; delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS; @@ -543,6 +544,7 @@ describe('CLAUDECODE environment stripping', () => { process.env.ANTHROPIC_API_KEY = 'parent-api-key'; process.env.ANTHROPIC_MODEL = 'gpt-5.4'; process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_FABLE_MODEL = 'gpt-5.4-mini'; execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'settings', @@ -570,10 +572,18 @@ describe('CLAUDECODE environment stripping', () => { call.args[1] === 'ANTHROPIC_MODEL' && call.args[2] === 'gpt-5.4' ); + const fableModelCall = spawnSyncCalls.find( + (call) => + call.command === 'tmux' && + call.args[0] === 'setenv' && + call.args[1] === 'ANTHROPIC_DEFAULT_FABLE_MODEL' && + call.args[2] === 'gpt-5.4-mini' + ); expect(unsetBaseUrlCall).toBeDefined(); expect(unsetAuthTokenCall).toBeDefined(); expect(modelCall).toBeDefined(); + expect(fableModelCall).toBeDefined(); }); it('headless executor spawn path strips CLAUDECODE before spawn', async () => { diff --git a/tests/unit/utils/fetch-proxy-setup.test.ts b/tests/unit/utils/fetch-proxy-setup.test.ts index 446d765c..22a02d08 100644 --- a/tests/unit/utils/fetch-proxy-setup.test.ts +++ b/tests/unit/utils/fetch-proxy-setup.test.ts @@ -4,7 +4,7 @@ import { Agent, Dispatcher, ProxyAgent, - fetch, + fetch as undiciFetch, getGlobalDispatcher, setGlobalDispatcher, } from 'undici'; @@ -26,18 +26,24 @@ const PROXY_ENV_KEYS = [ describe('global fetch proxy setup', () => { const originalEnv = new Map(); + const originalFetch = globalThis.fetch; const originalDispatcher = getGlobalDispatcher(); - beforeEach(() => { + globalThis.fetch = originalFetch; + setGlobalDispatcher(originalDispatcher); for (const key of PROXY_ENV_KEYS) { originalEnv.set(key, process.env[key]); delete process.env[key]; } - setGlobalDispatcher(originalDispatcher); }); - afterEach(() => { + afterEach(async () => { + const activeDispatcher = getGlobalDispatcher(); + globalThis.fetch = originalFetch; setGlobalDispatcher(originalDispatcher); + if (activeDispatcher !== originalDispatcher) { + await activeDispatcher.close(); + } for (const key of PROXY_ENV_KEYS) { const value = originalEnv.get(key); if (value === undefined) { @@ -112,18 +118,6 @@ describe('global fetch proxy setup', () => { }); }); - it('rebinds globalThis.fetch to undici fetch when proxying is enabled', () => { - const originalFetch = globalThis.fetch; - process.env.HTTP_PROXY = 'http://proxy.example:8080'; - - try { - expect(applyGlobalFetchProxy()).toEqual({ enabled: true }); - expect(globalThis.fetch).toBe(fetch); - } finally { - globalThis.fetch = originalFetch; - } - }); - it('bypasses loopback fetches even when HTTP_PROXY is set', async () => { process.env.HTTP_PROXY = 'http://127.0.0.1:9'; @@ -139,11 +133,14 @@ describe('global fetch proxy setup', () => { } try { - expect(applyGlobalFetchProxy()).toEqual({ enabled: true }); - - const response = await fetch(`http://127.0.0.1:${address.port}/health`); + const dispatcher = createGlobalFetchProxyDispatcher(); + expect(dispatcher).not.toBeNull(); + const response = await undiciFetch(`http://127.0.0.1:${address.port}/health`, { + dispatcher: dispatcher!, + }); expect(response.status).toBe(200); expect(await response.text()).toBe('ok'); + await dispatcher?.close(); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -158,6 +155,15 @@ describe('global fetch proxy setup', () => { expect(result).toEqual({ directCalls: 0, proxyCalls: 1 }); }); + it('applies the proxy transport only when explicitly invoked', () => { + process.env.HTTP_PROXY = 'http://proxy.example:8080'; + + expect(globalThis.fetch).toBe(originalFetch); + expect(applyGlobalFetchProxy()).toEqual({ enabled: true }); + expect(globalThis.fetch).toBe(undiciFetch); + expect(getGlobalDispatcher()).not.toBe(originalDispatcher); + }); + it('routes HTTPS requests through HTTPS_PROXY', async () => { process.env.HTTPS_PROXY = 'http://proxy.example:8443'; diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts index 59d484c0..6b3ed3df 100644 --- a/tests/unit/utils/websearch/claude-tool-args.test.ts +++ b/tests/unit/utils/websearch/claude-tool-args.test.ts @@ -4,9 +4,13 @@ import { appendThirdPartyWebSearchToolArgs } from '../../../../src/utils/websear const STEERING_PROMPT = 'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.'; +function appendEnabledWebSearchArgs(args: string[]): string[] { + return appendThirdPartyWebSearchToolArgs(args, true); +} + describe('appendThirdPartyWebSearchToolArgs', () => { it('appends native WebSearch suppression and inline steering prompt when no prompt flags are present', () => { - expect(appendThirdPartyWebSearchToolArgs(['smoke'])).toEqual([ + expect(appendEnabledWebSearchArgs(['smoke'])).toEqual([ 'smoke', '--disallowedTools', 'WebSearch', @@ -17,7 +21,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { it('does not append duplicate suppression or steering prompt when both are already present', () => { expect( - appendThirdPartyWebSearchToolArgs([ + appendEnabledWebSearchArgs([ 'smoke', '--disallowedTools', 'WebSearch', @@ -34,9 +38,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('detects comma-separated disallowed tool values', () => { - expect( - appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read,WebSearch']) - ).toEqual([ + expect(appendEnabledWebSearchArgs(['smoke', '--disallowedTools=Read,WebSearch'])).toEqual([ 'smoke', '--disallowedTools=Read,WebSearch', '--append-system-prompt', @@ -45,7 +47,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('merges WebSearch into an existing space-separated disallowed tool flag', () => { - expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools', 'Read'])).toEqual([ + expect(appendEnabledWebSearchArgs(['smoke', '--disallowedTools', 'Read'])).toEqual([ 'smoke', '--disallowedTools', 'Read,WebSearch', @@ -55,7 +57,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('merges WebSearch into an existing equals-form disallowed tool flag', () => { - expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read'])).toEqual([ + expect(appendEnabledWebSearchArgs(['smoke', '--disallowedTools=Read'])).toEqual([ 'smoke', '--disallowedTools=Read,WebSearch', '--append-system-prompt', @@ -65,11 +67,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { it('preserves user-supplied append-system-prompt values and adds the CCS steering hint once', () => { expect( - appendThirdPartyWebSearchToolArgs([ - 'smoke', - '--append-system-prompt', - 'User-provided instruction', - ]) + appendEnabledWebSearchArgs(['smoke', '--append-system-prompt', 'User-provided instruction']) ).toEqual([ 'smoke', '--append-system-prompt', @@ -83,7 +81,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { it('does not duplicate the steering prompt when it already exists in equals form', () => { expect( - appendThirdPartyWebSearchToolArgs([ + appendEnabledWebSearchArgs([ 'smoke', '--disallowedTools', 'WebSearch', @@ -98,9 +96,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('does not consume positional args after a disallowed-tools flag value', () => { - expect( - appendThirdPartyWebSearchToolArgs(['--disallowedTools', 'Read', 'latest AI news']) - ).toEqual([ + expect(appendEnabledWebSearchArgs(['--disallowedTools', 'Read', 'latest AI news'])).toEqual([ '--disallowedTools', 'Read,WebSearch', 'latest AI news', @@ -110,7 +106,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('injects synthetic flags before an end-of-options marker', () => { - expect(appendThirdPartyWebSearchToolArgs(['--', 'latest AI news'])).toEqual([ + expect(appendEnabledWebSearchArgs(['--', 'latest AI news'])).toEqual([ '--disallowedTools', 'WebSearch', '--append-system-prompt', @@ -121,7 +117,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('inserts the WebSearch disallow value when the flag is present without one', () => { - expect(appendThirdPartyWebSearchToolArgs(['--disallowedTools', '--verbose'])).toEqual([ + expect(appendEnabledWebSearchArgs(['--disallowedTools', '--verbose'])).toEqual([ '--disallowedTools', 'WebSearch', '--verbose', @@ -133,7 +129,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { // File mode: --append-system-prompt-file when user passes --append-system-prompt-file it('uses --append-system-prompt-file when user passes --append-system-prompt-file', () => { - const result = appendThirdPartyWebSearchToolArgs([ + const result = appendEnabledWebSearchArgs([ 'smoke', '--append-system-prompt-file', '/tmp/user-prompt.txt', @@ -147,26 +143,54 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('uses --append-system-prompt-file when user passes --append-system-prompt-file= form', () => { - const result = appendThirdPartyWebSearchToolArgs([ + const result = appendEnabledWebSearchArgs([ 'smoke', '--append-system-prompt-file=/tmp/user-prompt.txt', ]); const fileFlags = result.filter( - (arg) => arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=') + (arg) => + arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=') ); expect(fileFlags.length).toBeGreaterThanOrEqual(2); expect(result).not.toContain('--append-system-prompt'); }); it('does not treat unrelated user prompt files as the managed CCS steering prompt', () => { - const result = appendThirdPartyWebSearchToolArgs([ + const result = appendEnabledWebSearchArgs([ 'smoke', '--append-system-prompt-file', '/tmp/user-ccs-prompt-websearch-tool-notes.txt', ]); - const filePaths = result.filter((arg, index) => result[index - 1] === '--append-system-prompt-file'); + const filePaths = result.filter( + (arg, index) => result[index - 1] === '--append-system-prompt-file' + ); expect(filePaths).toContain('/tmp/user-ccs-prompt-websearch-tool-notes.txt'); - expect(filePaths.some((filePath) => filePath.endsWith('/ccs-prompt-websearch-tool.txt'))).toBe(true); + expect(filePaths.some((filePath) => filePath.endsWith('/ccs-prompt-websearch-tool.txt'))).toBe( + true + ); + }); + + it('keeps native WebSearch disallowed but skips CCS steering when WebSearch is disabled', () => { + expect(appendThirdPartyWebSearchToolArgs(['smoke'], false)).toEqual([ + 'smoke', + '--disallowedTools', + 'WebSearch', + ]); + }); + + it('preserves user prompt args when WebSearch is disabled', () => { + expect( + appendThirdPartyWebSearchToolArgs( + ['smoke', '--append-system-prompt', 'User-provided instruction'], + false + ) + ).toEqual([ + 'smoke', + '--append-system-prompt', + 'User-provided instruction', + '--disallowedTools', + 'WebSearch', + ]); }); }); diff --git a/tests/unit/utils/websearch/launch-state.test.ts b/tests/unit/utils/websearch/launch-state.test.ts new file mode 100644 index 00000000..b56061ce --- /dev/null +++ b/tests/unit/utils/websearch/launch-state.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { appendThirdPartyWebSearchToolArgs } from '../../../../src/utils/websearch/claude-tool-args'; +import { resolveWebSearchLaunchState } from '../../../../src/utils/websearch/launch-state'; +import { ensureWebSearchMcpForLaunch } from '../../../../src/utils/websearch/mcp-installer'; + +describe('WebSearch launch state', () => { + let originalCcsHome: string | undefined; + let tempHome = ''; + let configPath = ''; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-launch-state-')); + const ccsDir = path.join(tempHome, '.ccs'); + configPath = path.join(ccsDir, 'config.yaml'); + fs.mkdirSync(ccsDir, { recursive: true }); + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('keeps provisioning, hook env, and steering on one snapshot when config changes', () => { + fs.writeFileSync(configPath, 'version: 12\nwebsearch:\n enabled: false\n', 'utf8'); + const launch = resolveWebSearchLaunchState(); + + ensureWebSearchMcpForLaunch(launch.config); + fs.writeFileSync(configPath, 'version: 12\nwebsearch:\n enabled: true\n', 'utf8'); + + const args = appendThirdPartyWebSearchToolArgs(['smoke'], launch.enabled); + expect(launch.enabled).toBe(false); + expect(launch.hookEnv.CCS_WEBSEARCH_ENABLED).toBe('0'); + expect(launch.hookEnv.CCS_WEBSEARCH_SKIP).toBe('1'); + expect(args).toContain('--disallowedTools'); + expect(args).toContain('WebSearch'); + expect(args).not.toContain('--append-system-prompt'); + }); +}); diff --git a/tests/unit/web-server/bar-routes.test.ts b/tests/unit/web-server/bar-routes.test.ts index 4890820f..10fbc1d6 100644 --- a/tests/unit/web-server/bar-routes.test.ts +++ b/tests/unit/web-server/bar-routes.test.ts @@ -1116,6 +1116,49 @@ describe('/summary force flag passed to getNativeAccountRows', () => { expect(status).toBe(200); expect(body.some((r) => r.provider === 'codex')).toBe(true); }); + + it('enforces one absolute deadline across cost, quota, and blocked native work', async () => { + const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import( + '../../../src/web-server/routes/bar-routes' + ); + const app = express(); + app.use(express.json()); + const router = createBarRouter({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAllAccountsSummary: () => ({ agy: [makeAccountInfo()] }) as any, + getCachedQuota: () => makeQuotaResult(), + setCachedQuota: () => {}, + invalidateQuotaCache: () => {}, + fetchAccountQuota: () => new Promise(() => {}), + getTodayCostByAccount: () => ({}), + loadCliproxyDetails: () => new Promise((resolve) => setTimeout(() => resolve([]), 1_400)), + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], + getNativeAccountRows: () => new Promise(() => {}), + getCachedNativeRows: () => [], + }); + app.use('/api/bar', router); + const srv = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); + const addr = srv.address(); + if (!addr || typeof addr === 'string') throw new Error('No server address'); + resetDebounce(); + + const startedAt = Date.now(); + const { status } = await getJson( + `http://127.0.0.1:${(addr as { port: number }).port}`, + '/api/bar/summary?refresh=true' + ); + const elapsed = Date.now() - startedAt; + await new Promise((resolve) => srv.close(() => resolve())); + + expect(status).toBe(200); + expect(elapsed).toBeGreaterThanOrEqual(2_200); + expect(elapsed).toBeLessThan(3_200); + }); }); // ============================================================================ diff --git a/tests/unit/web-server/claude-native-credentials.test.ts b/tests/unit/web-server/claude-native-credentials.test.ts index 4a4643eb..1de415b6 100644 --- a/tests/unit/web-server/claude-native-credentials.test.ts +++ b/tests/unit/web-server/claude-native-credentials.test.ts @@ -8,6 +8,8 @@ import { describe, expect, it } from 'bun:test'; import { readClaudeCredentials, + readClaudeCredentialsForConfigDir, + claudeKeychainServiceForConfigDir, getAccessToken, getSubscriptionTier, hasSupportedSubscription, @@ -25,14 +27,14 @@ function makeCreds(overrides: Record = {}): ClaudeNativeCredent } describe('readClaudeCredentials', () => { - it('parses the on-disk credentials file when present (file-first, no Keychain)', () => { + it('parses the on-disk credentials file when present (file-first, no Keychain)', async () => { let keychainCalled = false; - const creds = readClaudeCredentials({ + const creds = await readClaudeCredentials({ platform: 'darwin', homedir: '/home/test', existsSyncImpl: () => true, readFileSyncImpl: () => JSON.stringify(makeCreds()), - execSyncImpl: () => { + execFileImpl: () => { keychainCalled = true; return ''; }, @@ -42,44 +44,52 @@ describe('readClaudeCredentials', () => { expect(keychainCalled).toBe(false); }); - it('falls back to the macOS Keychain when the file is absent', () => { - const creds = readClaudeCredentials({ + it('falls back to the macOS Keychain when the file is absent', async () => { + let executable = ''; + let args: readonly string[] = []; + const creds = await readClaudeCredentials({ platform: 'darwin', homedir: '/home/test', existsSyncImpl: () => false, readFileSyncImpl: () => { throw new Error('should not read file'); }, - execSyncImpl: () => JSON.stringify(makeCreds({ subscriptionType: 'pro' })), + execFileImpl: (file, receivedArgs, _options, callback) => { + executable = file; + args = receivedArgs; + callback(null, JSON.stringify(makeCreds({ subscriptionType: 'pro' }))); + }, }); expect(creds?.claudeAiOauth?.subscriptionType).toBe('pro'); + expect(executable).toBe('/usr/bin/security'); + expect(args).toEqual(['find-generic-password', '-s', 'Claude Code-credentials', '-w']); }); - it('returns null when both file and Keychain are absent', () => { - const creds = readClaudeCredentials({ + it('returns null when both file and Keychain are absent', async () => { + const creds = await readClaudeCredentials({ platform: 'darwin', homedir: '/home/test', existsSyncImpl: () => false, readFileSyncImpl: () => { throw new Error('no file'); }, - execSyncImpl: () => { - throw new Error('no keychain entry'); + execFileImpl: (_file, _args, _options, callback) => { + callback(new Error('no keychain entry'), ''); }, }); expect(creds).toBeNull(); }); - it('does not consult the Keychain on non-darwin platforms', () => { + it('does not consult the Keychain on non-darwin platforms', async () => { let keychainCalled = false; - const creds = readClaudeCredentials({ + const creds = await readClaudeCredentials({ platform: 'linux', homedir: '/home/test', existsSyncImpl: () => false, readFileSyncImpl: () => { throw new Error('no file'); }, - execSyncImpl: () => { + execFileImpl: () => { keychainCalled = true; return ''; }, @@ -124,3 +134,107 @@ describe('token + tier extraction', () => { expect(getSubscriptionTier(null)).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// Per-config-dir credential reading (isolated `ccs auth` profiles on macOS) +// +// Claude Code stores OAuth credentials for a non-default CLAUDE_CONFIG_DIR in +// a per-directory Keychain item: service "Claude Code-credentials-", +// where is the first 8 hex chars of sha256(configDir). These tests pin +// that derivation and the file-first / Keychain-fallback read order. +// --------------------------------------------------------------------------- + +describe('claudeKeychainServiceForConfigDir', () => { + it('derives the service name from sha256 of the config dir path (first 8 hex chars)', () => { + // sha256("/home/test/.ccs/instances/work") = ffeb4b45... + expect(claudeKeychainServiceForConfigDir('/home/test/.ccs/instances/work')).toBe( + 'Claude Code-credentials-ffeb4b45' + ); + }); +}); + +describe('readClaudeCredentialsForConfigDir', () => { + const configDir = '/home/test/.ccs/instances/work'; + const credFile = `${configDir}/.credentials.json`; + + it('reads /.credentials.json when present (file-first, no Keychain)', async () => { + let keychainCalled = false; + const creds = await readClaudeCredentialsForConfigDir(configDir, { + platform: 'darwin', + existsSyncImpl: (p: string) => p === credFile, + readFileSyncImpl: (p: string) => { + expect(p).toBe(credFile); + return JSON.stringify(makeCreds()); + }, + execFileImpl: () => { + keychainCalled = true; + return ''; + }, + }); + expect(creds?.claudeAiOauth?.accessToken).toBe('tok-abc'); + expect(keychainCalled).toBe(false); + }); + + it('uses absolute security path and argument array for the per-config-dir Keychain item', async () => { + let executable = ''; + let args: readonly string[] = []; + const creds = await readClaudeCredentialsForConfigDir(configDir, { + platform: 'darwin', + existsSyncImpl: () => false, + readFileSyncImpl: () => { + throw new Error('should not read file'); + }, + execFileImpl: (file, receivedArgs, _options, callback) => { + executable = file; + args = receivedArgs; + callback(null, JSON.stringify(makeCreds({ subscriptionType: 'team' }))); + }, + }); + expect(creds?.claudeAiOauth?.subscriptionType).toBe('team'); + expect(executable).toBe('/usr/bin/security'); + expect(args).toEqual(['find-generic-password', '-s', 'Claude Code-credentials-ffeb4b45', '-w']); + }); + + it('returns null when both file and Keychain are absent', async () => { + const creds = await readClaudeCredentialsForConfigDir(configDir, { + platform: 'darwin', + existsSyncImpl: () => false, + readFileSyncImpl: () => { + throw new Error('no file'); + }, + execFileImpl: (_file, _args, _options, callback) => { + callback(new Error('no keychain entry'), ''); + }, + }); + expect(creds).toBeNull(); + }); + + it('bounds a blocked Keychain lookup without exposing a credential payload', async () => { + const startedAt = Date.now(); + const creds = await readClaudeCredentialsForConfigDir(configDir, { + platform: 'darwin', + existsSyncImpl: () => false, + keychainTimeoutMs: 20, + execFileImpl: () => ({ kill: () => {} }), + }); + expect(creds).toBeNull(); + expect(Date.now() - startedAt).toBeLessThan(250); + }); + + it('does not consult the Keychain on non-darwin platforms', async () => { + let keychainCalled = false; + const creds = await readClaudeCredentialsForConfigDir(configDir, { + platform: 'linux', + existsSyncImpl: () => false, + readFileSyncImpl: () => { + throw new Error('no file'); + }, + execFileImpl: () => { + keychainCalled = true; + return ''; + }, + }); + expect(creds).toBeNull(); + expect(keychainCalled).toBe(false); + }); +}); diff --git a/tests/unit/web-server/cliproxy-routing-routes.test.ts b/tests/unit/web-server/cliproxy-routing-routes.test.ts index 1c828b84..86038868 100644 --- a/tests/unit/web-server/cliproxy-routing-routes.test.ts +++ b/tests/unit/web-server/cliproxy-routing-routes.test.ts @@ -9,6 +9,8 @@ describe('cliproxy routing routes', () => { let applyStrategyMock: ReturnType; let readAffinityStateMock: ReturnType; let applyAffinityMock: ReturnType; + let readRetryMock: ReturnType; + let applyRetryMock: ReturnType; beforeEach(async () => { readStateMock = mock(async () => ({ @@ -41,6 +43,23 @@ describe('cliproxy routing routes', () => { manageable: true, applied: 'config-only', })); + readRetryMock = mock(async () => ({ + request_retry: 2, + max_retry_interval: 20, + source: 'live', + target: 'local', + reachable: true, + manageable: true, + })); + applyRetryMock = mock(async () => ({ + request_retry: 3, + max_retry_interval: 30, + source: 'live', + target: 'local', + reachable: true, + manageable: true, + applied: 'live-and-config', + })); mock.module('../../../src/cliproxy/routing/routing-strategy', () => ({ readCliproxyRoutingState: readStateMock, @@ -62,6 +81,12 @@ describe('cliproxy routing routes', () => { return null; }, })); + mock.module('../../../src/cliproxy/routing/retry-settings', () => ({ + readCliproxyRetryState: readRetryMock, + applyCliproxyRetrySettings: applyRetryMock, + normalizeCliproxyRetryValue: (value: unknown) => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null, + })); const { default: routingRoutes } = await import( `../../../src/web-server/routes/cliproxy-routing-routes?test=${Date.now()}-${Math.random()}` @@ -180,4 +205,37 @@ describe('cliproxy routing routes', () => { applied: 'config-only', }); }); + + it('returns and updates retry settings through the dedicated route', async () => { + const readResponse = await fetch(`${baseUrl}/api/cliproxy/retry`); + expect(readResponse.status).toBe(200); + expect((await readResponse.json()).request_retry).toBe(2); + + const updateResponse = await fetch(`${baseUrl}/api/cliproxy/retry`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ request_retry: 3, max_retry_interval: 30 }), + }); + expect(updateResponse.status).toBe(200); + expect(applyRetryMock).toHaveBeenCalledWith({ + request_retry: 3, + max_retry_interval: 30, + }); + }); + + it.each([ + { request_retry: -1, max_retry_interval: 30 }, + { request_retry: 1.5, max_retry_interval: 30 }, + { request_retry: Number.MAX_SAFE_INTEGER + 1, max_retry_interval: 30 }, + { request_retry: 1, max_retry_interval: '30' }, + ])('rejects invalid retry payload %#', async (payload) => { + const response = await fetch(`${baseUrl}/api/cliproxy/retry`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + expect(response.status).toBe(400); + expect(applyRetryMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 9c24fa0f..add60779 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -1032,7 +1032,7 @@ function makeMultiProfileDeps(opts: { listCodexProfiles: () => codexProfiles, defaultClaudeProfile: () => claudeDefault, defaultCodexProfile: () => codexDefault, - // Credential seams (file-only, no keychain) + // Credential seams (fully injected; no real filesystem or Keychain access) readClaudeCredentialsForProfile: credsForProfile, readCodexNativeAuth: codexNativeAuth, // Fetch seams @@ -1170,8 +1170,10 @@ describe('multi-profile: account_id and wire fields', () => { const rows = await getNativeAccountRows(deps); expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length); - expect(deps.claudeFetchCount()).toBe(1); - expect(deps.codexNetworkCount()).toBe(1); + // Budget per pass: the default + one rotating non-default live slot per + // surface — constant regardless of profile count. + expect(deps.claudeFetchCount()).toBe(2); + expect(deps.codexNetworkCount()).toBe(2); }); it('rows are sorted by (surface, profile)', async () => { @@ -1193,7 +1195,7 @@ describe('multi-profile: account_id and wire fields', () => { }); }); -describe('multi-profile: Claude file-only reader', () => { +describe('multi-profile: Claude credential reader', () => { it('profile with .credentials.json present -> live fetch row (paused:false when default)', async () => { const clock = { now: 1_000_000 }; const deps = makeMultiProfileDeps({ @@ -1396,8 +1398,9 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { const ckRow = rows.find((r) => r.profile === 'ck'); const workRow = rows.find((r) => r.profile === 'work'); - // 'ck' stays cache-only, independent of work's breaker. - expect(ckRow?.quotaStatus).toBe('unsupported'); + // 'ck' gets its own live row via the rotating slot, independent of work's + // breaker history. + expect(ckRow?.quotaStatus).toBe('ok'); // 'work' is also fine after reset (no breaker state). expect(workRow?.quotaStatus).toBe('ok'); }); @@ -1431,10 +1434,11 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { }, }); - // First call: 'work' gets a 429, 'ck' remains cache-only. + // First call: 'work' gets a 429; 'ck' is refreshed by the rotating slot and + // succeeds — work's failures do not leak into ck's state. const rows1 = await getNativeAccountRows(deps); const ck1 = rows1.find((r) => r.profile === 'ck'); - expect(ck1?.quotaStatus).toBe('unsupported'); + expect(ck1?.quotaStatus).toBe('ok'); expect(workCall429Count).toBeGreaterThanOrEqual(1); // Skip past cooldown for 'work' only; 'ck' is within TTL. @@ -1443,8 +1447,8 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { // Second call past 'work' cooldown: work tries again (429 again); ck cached. const rows2 = await getNativeAccountRows(deps); const ck2 = rows2.find((r) => r.profile === 'ck'); - // 'ck' remains cache-only and is not affected by work's breaker. - expect(ck2?.quotaStatus).toBe('unsupported'); + // 'ck' keeps its healthy cached row and is not affected by work's breaker. + expect(ck2?.quotaStatus).toBe('ok'); }); }); @@ -1537,15 +1541,18 @@ describe('review focus areas: reauth caching + codex local fallback', () => { codexNetworkFetch: async () => ({ success: false, needsReauth: true }) as CodexQuotaResult, }); + // The rotating slot polls 'ck' once; the 401 parks it into the reauth + // cooldown. const first = await getNativeAccountRows(deps); const r1 = first.find((r) => r.profile === 'ck'); expect(r1?.needsReauth).toBe(true); expect(r1?.paused).toBe(true); - expect(deps.codexNetworkCount()).toBe(0); + expect(deps.codexNetworkCount()).toBe(1); + // Within the cooldown it is NOT re-polled (no repeated 401s), even forced. const second = await getNativeAccountRows(deps, { force: true }); expect(second.find((r) => r.profile === 'ck')?.cached).toBe(true); - expect(deps.codexNetworkCount()).toBe(0); + expect(deps.codexNetworkCount()).toBe(1); }); it('Codex named profile without on-disk auth is parked, never filled from global local data', async () => { @@ -1655,7 +1662,7 @@ describe('review focus areas: reauth caching + codex local fallback', () => { expect(deps.claudeFetchCount()).toBe(1); // re-checked -> fetched }); - it('Codex named profile with valid auth but sparse payload stays parked when not default', async () => { + it('Codex named profile with valid auth but sparse payload yields an active quota-less row', async () => { resetNativeQuotaState(); const clock = { now: 7_000_000 }; const deps = makeMultiProfileDeps({ @@ -1668,13 +1675,15 @@ describe('review focus areas: reauth caching + codex local fallback', () => { codexNetworkFetch: async () => ({ success: true }) as CodexQuotaResult, }); + // The rotating slot polls 'ck'; the token authenticated, so it is a valid + // active subscription with a sparse payload — an active quota-less row, + // still dimmed because it is not the default profile. const rows = await getNativeAccountRows(deps); const ck = rows.find((r) => r.profile === 'ck'); expect(ck).toBeDefined(); - expect(ck?.paused).toBe(true); // cache-only, parked until selected as default - expect(ck?.needsReauth).toBe(true); - expect(ck?.quotaStatus).toBe('unsupported'); - expect(ck?.quota_percentage).toBeNull(); // no windows while parked + expect(ck?.paused).toBe(true); // non-default rows always render dimmed + expect(ck?.needsReauth).toBe(false); + expect(ck?.quota_percentage).toBeNull(); // no windows in the payload }); it('non-default cache-only rows do not overwrite the canonical live cache', async () => { @@ -1691,9 +1700,11 @@ describe('review focus areas: reauth caching + codex local fallback', () => { }); deps.defaultCodexProfile = () => codexDefault; + // First pass: 'ck' (default) is live-polled and the rotating slot also + // refreshes 'default' — two upstream calls. const first = await getNativeAccountRows(deps); expect(first.find((r) => r.profile === 'ck')?.paused).toBe(false); - expect(deps.codexNetworkCount()).toBe(1); + expect(deps.codexNetworkCount()).toBe(2); codexDefault = 'default'; const second = await getNativeAccountRows(deps); @@ -1710,3 +1721,234 @@ describe('review focus areas: reauth caching + codex local fallback', () => { expect(deps.codexNetworkCount()).toBe(2); }); }); + +// ============================================================================ +// Multi-profile: rotating live slot for non-default profiles +// +// Non-default profiles used to be cache-only forever, so accounts other than +// the default never showed real quota — they sat parked ("needs re-auth") for +// the lifetime of the server. One rotating live slot per surface refreshes the +// stalest eligible non-default profile per pass, so every account converges to +// real data within a few polls while the per-pass upstream budget stays +// constant (<= 2 calls per surface) regardless of profile count. +// ============================================================================ + +describe('multi-profile: rotating live slot for non-default profiles', () => { + it('live-polls the default plus one stale non-default Claude profile per pass', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'personal', 'fc'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async () => successQuota(), + }); + + // Pass 1: default (work) + one stale non-default get live data. + let rows = await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(2); + expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(2); + + // Pass 2 after the parked TTL: the remaining profile gets its live row. + clock.now += 31_000; + rows = await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(3); + expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(3); + + // Pass 3 while everything is within TTL: fully cached, zero upstream calls. + clock.now += 1_000; + rows = await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(3); + expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(3); + }); + + it('rotated non-default rows keep paused:true (only the default renders active)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'personal'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + const personal = rows.find((r) => r.profile === 'personal'); + expect(personal?.quotaStatus).toBe('ok'); + expect(personal?.needsReauth).toBe(false); + expect(personal?.paused).toBe(true); + expect(personal?.is_default).toBe(false); + }); + + it('codex non-default profiles also get one rotating live slot per pass', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['personal', 'ck'], + codexDefault: 'personal', + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexSuccessQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(deps.codexNetworkCount()).toBe(2); + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck?.paused).toBe(true); + expect(ck?.needsReauth).toBe(false); + }); + + it('profiles in reauth cooldown are skipped by the rotating slot', async () => { + const clock = { now: 1_000_000 }; + let failProfile: string | null = 'personal'; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'personal'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async (_token: string, accountId?: string) => + accountId === `ccs:${failProfile}` + ? ({ success: false, needsReauth: true, retryable: false } as ClaudeQuotaResult) + : successQuota(), + }); + + // Pass 1: personal is rotated in, 401s, and enters the reauth cooldown. + let rows = await getNativeAccountRows(deps); + expect(rows.find((r) => r.profile === 'personal')?.needsReauth).toBe(true); + expect(deps.claudeFetchCount()).toBe(2); + + // Pass 2 inside the cooldown: the slot must NOT re-poll (and re-401) it. + clock.now += 31_000; + rows = await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(2); + expect(rows.find((r) => r.profile === 'personal')?.needsReauth).toBe(true); + }); +}); + +// ============================================================================ +// Quota reset invalidates cached rows +// +// A cached row whose next_reset has passed no longer describes the current +// window — the quota snapped back at the reset boundary. Serving it for the +// rest of the 10-min TTL makes the bar visibly wrong right after a reset, so +// a passed reset marks the row stale (guarded: only when the row was fetched +// BEFORE the reset, so a post-reset payload that still reports a past reset +// cannot cause a refetch loop). +// ============================================================================ + +describe('multi-profile: quota reset invalidates cached rows', () => { + function quotaResettingAt(resetIso: string): ClaudeQuotaResult { + const base = successQuota(); + return { + ...base, + coreUsage: { + fiveHour: { ...base.coreUsage!.fiveHour!, resetAt: resetIso }, + weekly: base.coreUsage!.weekly, + }, + }; + } + + it('re-fetches the default Claude profile once its next_reset passes, before TTL expiry', async () => { + const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') }; + const resetIso = '2026-06-09T10:01:00.000Z'; // 60s ahead + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async () => quotaResettingAt(resetIso), + }); + + await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(1); + + // 90s later: past the reset but far inside the 10-min TTL. + clock.now += 90_000; + await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(2); + }); + + it('does not refetch-loop when a post-reset payload still reports a past reset', async () => { + const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') }; + const resetIso = '2026-06-09T10:01:00.000Z'; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async () => quotaResettingAt(resetIso), + }); + + await getNativeAccountRows(deps); + clock.now += 90_000; + await getNativeAccountRows(deps); // refetch fires; payload STILL says 10:01 + expect(deps.claudeFetchCount()).toBe(2); + + // Another pass within TTL: the row was fetched after the reset passed, so + // the stale-by-reset rule must not apply again. + clock.now += 30_000; + await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(2); + }); + + it('rotating slot treats a non-default profile with a passed reset as stale', async () => { + const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') }; + const resetIso = '2026-06-09T10:01:00.000Z'; + const farFuture = '2026-06-16T00:00:00.000Z'; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'personal'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async (_t, accountId) => + quotaResettingAt(accountId === 'ccs:personal' ? resetIso : farFuture), + }); + + // Pass 1: work (default) + personal (rotating slot, never fetched). + await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(2); + + // 90s later: personal's reset passed; work is fresh. The rotating slot + // must pick personal again even though its row is inside the 10-min TTL. + clock.now += 90_000; + await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(3); + }); + + it('Codex rows honour the same reset-staleness rule', async () => { + const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') }; + const codexQuota: CodexQuotaResult = { + ...codexSuccessQuota(), + coreUsage: { + fiveHour: { + label: 'Primary', + remainingPercent: 60, + resetAfterSeconds: 60, + resetAt: '2026-06-09T10:01:00.000Z', + }, + weekly: codexSuccessQuota().coreUsage!.weekly, + }, + }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['personal'], + codexDefault: 'personal', + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexQuota, + }); + + await getNativeAccountRows(deps); + expect(deps.codexNetworkCount()).toBe(1); + + clock.now += 90_000; + await getNativeAccountRows(deps); + expect(deps.codexNetworkCount()).toBe(2); + }); +}); diff --git a/ui/README.md b/ui/README.md index 5c395658..454e96c6 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,76 +1,78 @@ # CCS Dashboard UI -React + TypeScript + Vite frontend for the CCS local dashboard. - -This UI is served by the CCS web server and is accessed via: +React, TypeScript, and Vite frontend for the local dashboard served by the CCS +web server through: ```bash ccs config ``` ---- - ## Development -From project root: +From the repository root, build the server and open the integrated dashboard: ```bash bun run dev ``` -This starts the CCS server, opens a local browser URL, and prints bind/network details for the dashboard. -If the runtime bind is reachable beyond loopback, CCS also prints an auth reminder. - -For remote device access during development, run: +Pass a host explicitly when testing network access: ```bash bun run dev -- --host 0.0.0.0 -``` - -For local-only development, run: - -```bash bun run dev -- --host 127.0.0.1 ``` -From `ui/` only (frontend dev server): +For the frontend-only Vite server: ```bash cd ui bun run dev ``` ---- +Root and UI scripts are defined in +[`../package.json`](../package.json) and [`package.json`](./package.json). + +## Source Ownership + +| Area | Path | +| --- | --- | +| Route-level pages | [`src/pages/`](./src/pages/) | +| Domain and shared components | [`src/components/`](./src/components/) | +| Server-state hooks | [`src/hooks/`](./src/hooks/) | +| Cross-page context/providers | [`src/contexts/`](./src/contexts/) and [`src/providers/`](./src/providers/) | +| API, localization, catalogs, helpers | [`src/lib/`](./src/lib/) | +| UI tests | [`tests/`](./tests/) and colocated tests | + +Follow the owning domain's existing import pattern. Barrel exports are optional, +not required at every directory level. ## Quality Commands ```bash cd ui +bun run format bun run typecheck bun run lint bun run validate bun run test:run ``` ---- +`bun run validate` currently runs typecheck, lint with fixes, and the format +check. UI tests run with Vitest; use `test`, `test:run`, `test:coverage`, or +`test:ui` as defined in [`package.json`](./package.json). -## i18n +## Localization Dashboard localization uses `react-i18next`. -- Main setup: `ui/src/lib/i18n.ts` -- Locale helpers: `ui/src/lib/locales.ts` -- Language switcher: `ui/src/components/layout/language-switcher.tsx` +| Concern | Source | +| --- | --- | +| Supported locales, normalization, fallback, persistence | [`src/lib/locales.ts`](./src/lib/locales.ts) | +| i18next setup and translation resources | [`src/lib/i18n.ts`](./src/lib/i18n.ts) | +| Language switcher | [`src/components/layout/language-switcher.tsx`](./src/components/layout/language-switcher.tsx) | -For full architecture, conventions, and locale onboarding, see: - -- [`../docs/i18n-dashboard.md`](../docs/i18n-dashboard.md) - ---- - -## Notes - -- UI locale persistence uses browser localStorage key `ccs-ui-locale`. -- Current supported locales are managed in `ui/src/lib/locales.ts`. -- Current locales: `en`, `zh-CN`, `vi`. -- Fallback locale is English (`en`). +English is the fallback. The selected locale is stored in browser local storage +under `ccs-ui-locale`. Treat `src/lib/locales.ts` as the complete source of +truth for supported locale codes. When adding or removing a locale, update the +translation resources, switcher, tests, and +[dashboard i18n guide](../docs/i18n-dashboard.md) in the same change. diff --git a/ui/index.html b/ui/index.html index e36ed302..cac90fb3 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,7 +4,7 @@ CCS Dashboard - + diff --git a/ui/src/components/cliproxy/cliproxy-retry-control.tsx b/ui/src/components/cliproxy/cliproxy-retry-control.tsx new file mode 100644 index 00000000..830acb2c --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-retry-control.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCliproxyRetryConfig, useUpdateCliproxyRetryConfig } from '@/hooks/use-cliproxy'; + +function parseRetryValue(value: string): number | null { + if (!/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +export function CliproxyRetryControl() { + const { t } = useTranslation(); + const retryQuery = useCliproxyRetryConfig(); + const updateRetry = useUpdateCliproxyRetryConfig(); + const requestRetry = retryQuery.data?.request_retry ?? 0; + const maxRetryInterval = retryQuery.data?.max_retry_interval ?? 0; + const manageable = retryQuery.data?.manageable !== false; + const disabled = + retryQuery.isLoading || retryQuery.isError || updateRetry.isPending || !manageable; + const statusMessage = retryQuery.error?.message ?? retryQuery.data?.message; + + return ( +
+
+
+
+ {t('routingGuidance.retryTitle')} +
+
+ {t('routingGuidance.retryHint')} + {retryQuery.data ? ` · ${retryQuery.data.source} · ${retryQuery.data.target}` : ''} +
+
+ + updateRetry.mutate({ + request_retry: nextRequestRetry, + max_retry_interval: nextMaxRetryInterval, + }) + } + /> +
+ {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} +
+ ); +} + +interface RetryInputsProps { + requestRetry: number; + maxRetryInterval: number; + disabled: boolean; + onUpdate: (requestRetry: number, maxRetryInterval: number) => void; +} + +function RetryInputs({ requestRetry, maxRetryInterval, disabled, onUpdate }: RetryInputsProps) { + const { t } = useTranslation(); + const [requestRetryInput, setRequestRetryInput] = useState(String(requestRetry)); + const [maxRetryIntervalInput, setMaxRetryIntervalInput] = useState(String(maxRetryInterval)); + const [fieldError, setFieldError] = useState(null); + + const handleBlur = () => { + if (disabled) return; + const nextRequestRetry = parseRetryValue(requestRetryInput.trim()); + const nextMaxRetryInterval = parseRetryValue(maxRetryIntervalInput.trim()); + if (nextRequestRetry === null || nextMaxRetryInterval === null) { + setFieldError(t('routingGuidance.retryRangeError')); + setRequestRetryInput(String(requestRetry)); + setMaxRetryIntervalInput(String(maxRetryInterval)); + return; + } + + setFieldError(null); + if (nextRequestRetry !== requestRetry || nextMaxRetryInterval !== maxRetryInterval) { + onUpdate(nextRequestRetry, nextMaxRetryInterval); + } + }; + + return ( +
+
+ setRequestRetryInput(event.target.value)} + onBlur={handleBlur} + disabled={disabled} + /> + / + setMaxRetryIntervalInput(event.target.value)} + onBlur={handleBlur} + disabled={disabled} + /> +
+ {fieldError ?
{fieldError}
: null} +
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index b4df3f7a..b7506691 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -122,6 +122,7 @@ export function ProviderEditor({ opusModel, sonnetModel, haikuModel, + fableModel, extendedContextEnabled, toggleExtendedContext, handleRawJsonChange, @@ -271,6 +272,7 @@ export function ProviderEditor({ opusModel={opusModel} sonnetModel={sonnetModel} haikuModel={haikuModel} + fableModel={fableModel} providerModels={providerModels} routing={routing} extendedContextEnabled={extendedContextEnabled} diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index 52eb5aea..ec64f636 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -43,6 +43,7 @@ export function ModelConfigSection({ opusModel, sonnetModel, haikuModel, + fableModel, providerModels, routing, provider, @@ -67,7 +68,7 @@ export function ModelConfigSection({ const extendedContextModels = useMemo(() => { if (!catalog) return []; - const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel] + const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel, fableModel] .filter((modelId): modelId is string => Boolean(modelId)) .map((modelId) => stripExtendedContextSuffix(modelId)); @@ -75,7 +76,7 @@ export function ModelConfigSection({ return uniqueIds .map((modelId) => findCatalogModel(catalog.provider, modelId, catalog)) .filter((model): model is NonNullable => Boolean(model?.extendedContext)); - }, [catalog, currentModel, opusModel, sonnetModel, haikuModel]); + }, [catalog, currentModel, opusModel, sonnetModel, haikuModel, fableModel]); const resolvedCatalogModels = useMemo( () => getResolvedCatalogModels(catalog, providerModels), @@ -253,6 +254,15 @@ export function ModelConfigSection({ onToggle={onExtendedContextToggle} /> )} + onUpdateEnvValue('ANTHROPIC_DEFAULT_FABLE_MODEL', model)} + catalog={catalog} + allModels={providerModels} + routing={routing} + /> ; routing?: CliproxyProviderRoutingHints; /** Whether extended context (1M tokens) is enabled */ @@ -72,6 +73,7 @@ export function ModelConfigTab({ opusModel, sonnetModel, haikuModel, + fableModel, providerModels, routing, extendedContextEnabled, @@ -163,6 +165,7 @@ export function ModelConfigTab({ opusModel={opusModel} sonnetModel={sonnetModel} haikuModel={haikuModel} + fableModel={fableModel} providerModels={providerModels} routing={routing} provider={provider} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 3e1c6bc2..2f10d017 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -126,6 +126,7 @@ export interface ModelConfigSectionProps { opusModel?: string; sonnetModel?: string; haikuModel?: string; + fableModel?: string; providerModels: Array<{ id: string; owned_by: string }>; routing?: CliproxyProviderRoutingHints; /** Provider name for display */ @@ -154,6 +155,7 @@ export interface UseProviderEditorReturn { opusModel?: string; sonnetModel?: string; haikuModel?: string; + fableModel?: string; /** Whether extended context (1M tokens) is enabled */ extendedContextEnabled: boolean; /** Toggle extended context on/off */ diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 739955c1..35ac28b1 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -85,6 +85,7 @@ export function useProviderEditor( const opusModel = currentSettings?.env?.ANTHROPIC_DEFAULT_OPUS_MODEL; const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL; const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL; + const fableModel = currentSettings?.env?.ANTHROPIC_DEFAULT_FABLE_MODEL; // Extended context is enabled if any model has [1m] suffix const extendedContextEnabled = useMemo(() => { @@ -224,6 +225,7 @@ export function useProviderEditor( opusModel, sonnetModel, haikuModel, + fableModel, extendedContextEnabled, toggleExtendedContext, handleRawJsonChange, diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index a9c47779..e6607c19 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -10,16 +10,31 @@ import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; import { SearchableSelect } from '@/components/ui/searchable-select'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; +import { Switch } from '@/components/ui/switch'; import type { CliproxyProviderRoutingHints } from '@/lib/api-client'; import type { CodexEffort, CodexServiceTier } from '@/lib/codex-effort'; import { + applyCodexServiceTierSuffix, getCodexEffortDisplay, getCodexEffortVariants, parseCodexEffort, parseCodexServiceTier, + stripCodexEffortSuffix, } from '@/lib/codex-effort'; -import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs'; +import { + findCatalogModel, + getResolvedCatalogModels, + getSupplementalCatalogModels, +} from '@/lib/model-catalogs'; +import { getReasoningAdapter } from '@/lib/reasoning-control'; import { cn } from '@/lib/utils'; /** Model entry from catalog */ @@ -47,6 +62,8 @@ export interface ModelEntry { codexEfforts?: CodexEffort[]; /** Additional Codex service-tier suffixes supported by this model in the dashboard UI. */ codexServiceTiers?: CodexServiceTier[]; + /** Reasoning-level suffixes this model supports via paren syntax in the dashboard UI. */ + reasoningLevels?: string[]; } /** Provider catalog */ @@ -561,6 +578,34 @@ export function FlexibleModelSelector({ } : null; const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0; + const reasoningAdapter = getReasoningAdapter(catalog?.provider); + const currentReasoningLevel = reasoningAdapter?.parse(value); + const selectableReasoningLevels = + reasoningAdapter && catalog + ? reasoningAdapter.getOptions( + reasoningAdapter.strip(normalizeModelValue(value, routing)), + catalog + ) + : null; + const reasoningOptions = + selectableReasoningLevels && + currentReasoningLevel && + !selectableReasoningLevels.includes(currentReasoningLevel) + ? [...selectableReasoningLevels, currentReasoningLevel] + : selectableReasoningLevels; + const currentServiceTier = isCodexProvider ? parseCodexServiceTier(value) : undefined; + const selectedCodexBaseModel = + isCodexProvider && catalog && value + ? findCatalogModel( + catalog.provider, + stripCodexEffortSuffix(normalizeModelValue(value, routing)), + catalog + ) + : undefined; + const showFastToggle = + isCodexProvider && + (selectedCodexBaseModel?.codexServiceTiers?.includes('fast') === true || + currentServiceTier === 'fast'); return (
@@ -568,87 +613,129 @@ export function FlexibleModelSelector({ {description &&

{description}

}
- - {t('providerModelSelector.currentValue')} - - ), - }, - ] - : []), - { - key: 'recommended', - label: ( - {t('providerModelSelector.recommended')} - ), - }, - ...(isCodexProvider - ? [ - { - key: 'codex-reasoning', - label: ( - - {t('providerModelSelector.codexReasoningVariants')} - - ), - }, - { - key: 'codex-fast', - label: ( - - {t('providerModelSelector.codexFastVariants')} - - ), - }, - ] - : []), - ...(allModelOptions.length > 0 - ? [ - { - key: 'all', - label: ( - - {t('providerModelSelector.allModelsCount', { - count: allModelOptions.length, - })} - - ), - }, - ] - : []), - { - key: 'custom', - label: ( - - {t('providerModelSelector.customModel')} - - ), - }, - ]} - options={[ - ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []), - ...recommendedOptions, - ...allModelOptions, - ]} - /> +
+ + {t('providerModelSelector.currentValue')} + + ), + }, + ] + : []), + { + key: 'recommended', + label: ( + + {t('providerModelSelector.recommended')} + + ), + }, + ...(isCodexProvider + ? [ + { + key: 'codex-reasoning', + label: ( + + {t('providerModelSelector.codexReasoningVariants')} + + ), + }, + { + key: 'codex-fast', + label: ( + + {t('providerModelSelector.codexFastVariants')} + + ), + }, + ] + : []), + ...(allModelOptions.length > 0 + ? [ + { + key: 'all', + label: ( + + {t('providerModelSelector.allModelsCount', { + count: allModelOptions.length, + })} + + ), + }, + ] + : []), + { + key: 'custom', + label: ( + + {t('providerModelSelector.customModel')} + + ), + }, + ]} + options={[ + ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []), + ...recommendedOptions, + ...allModelOptions, + ]} + /> + {reasoningAdapter && reasoningOptions && ( + + )} + {showFastToggle && ( + + )} +
{selectedRoutingHint ? (
) : null} + +
); } diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 98c23f55..b7a03751 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -12,6 +12,8 @@ import { type CreatePreset, type RoutingStrategy, type CliproxySessionAffinityApplyResult, + type CliproxyRetryApplyResult, + type CliproxyRetryValues, } from '@/lib/api-client'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -106,6 +108,30 @@ export function useUpdateCliproxySessionAffinity() { }); } +export function useCliproxyRetryConfig() { + return useQuery({ + queryKey: ['cliproxy-retry-config'], + queryFn: () => api.cliproxy.getRetrySettings(), + }); +} + +export function useUpdateCliproxyRetryConfig() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (retry: CliproxyRetryValues) => api.cliproxy.updateRetrySettings(retry), + onSuccess: (result: CliproxyRetryApplyResult) => { + queryClient.setQueryData(['cliproxy-retry-config'], result); + queryClient.invalidateQueries({ queryKey: ['cliproxy-retry-config'] }); + toast.success(result.message || t('toasts.cliproxyRetryUpdated')); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + export function useCreateVariant() { const queryClient = useQueryClient(); const { t } = useTranslation(); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index f8b12736..15d42477 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -550,6 +550,23 @@ export interface CliproxySessionAffinityApplyResult extends CliproxySessionAffin applied: 'config-and-live' | 'config-only' | 'unsupported'; } +export interface CliproxyRetryValues { + request_retry: number; + max_retry_interval: number; +} + +export interface CliproxyRetryState extends CliproxyRetryValues { + source: 'live' | 'config'; + target: 'local' | 'remote'; + reachable: boolean; + manageable: boolean; + message?: string; +} + +export interface CliproxyRetryApplyResult extends CliproxyRetryState { + applied: 'live' | 'live-and-config' | 'config-only'; +} + /** Auth file info for Config tab */ export interface AuthFile { name: string; @@ -1327,6 +1344,12 @@ export const api = { method: 'PUT', body: JSON.stringify(data), }), + getRetrySettings: () => request('/cliproxy/retry'), + updateRetrySettings: (data: CliproxyRetryValues) => + request('/cliproxy/retry', { + method: 'PUT', + body: JSON.stringify(data), + }), aiProviders: { list: () => request('/cliproxy/ai-providers'), create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) => diff --git a/ui/src/lib/codex-effort.ts b/ui/src/lib/codex-effort.ts index ef884b0d..393b78af 100644 --- a/ui/src/lib/codex-effort.ts +++ b/ui/src/lib/codex-effort.ts @@ -2,7 +2,7 @@ export type CodexEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; export type CodexServiceTier = 'fast'; const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(minimal|low|medium|high|xhigh|fast)$/i; -const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = [ +export const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = [ 'minimal', 'low', 'medium', @@ -66,6 +66,12 @@ export function applyCodexEffortSuffix( return [normalizedModelId, effort, parsed.serviceTier].filter(Boolean).join('-'); } +export function applyCodexServiceTierSuffix(modelId: string | undefined, enabled: boolean): string { + const parsed = parseCodexTuningSuffix(modelId); + if (!parsed.baseModel) return parsed.baseModel; + return [parsed.baseModel, parsed.effort, enabled ? 'fast' : undefined].filter(Boolean).join('-'); +} + export function getCodexEffortVariants( modelId: string, maxEffort: CodexEffort | undefined, @@ -102,6 +108,16 @@ export function getCodexEffortVariants( return variantIds; } +export function getSelectableCodexEfforts( + maxEffort: CodexEffort | undefined, + efforts?: readonly CodexEffort[] +): CodexEffort[] { + if (efforts?.length) return [...efforts]; + if (!maxEffort) return [...CODEX_EFFORTS_IN_ORDER]; + const maxEffortIndex = CODEX_EFFORTS_IN_ORDER.indexOf(maxEffort); + return CODEX_EFFORTS_IN_ORDER.slice(0, maxEffortIndex + 1); +} + export function getCodexEffortDisplay( modelId: string | undefined, effortLabels?: { pinned: (effort: string) => string; auto: string } diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 39988ff4..6ac2da8a 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -7,6 +7,7 @@ const resources = { translation: { locale: { en: 'English', + 'pt-BR': 'Brazilian Portuguese', 'zh-CN': 'Simplified Chinese', vi: 'Vietnamese', ja: 'Japanese', @@ -228,6 +229,9 @@ const resources = { 'Pinned model is not currently advertised by the proxy: {{model}}', codexReasoningVariants: 'Reasoning variants', codexFastVariants: 'Fast variants', + effortLabel: 'Effort', + effortAuto: 'Auto', + fastTier: 'Fast', }, createAuthProfileDialog: { title: 'Create New Account', @@ -1582,7 +1586,7 @@ const resources = { // ======================================== heroSection: { title: 'CCS Config', - subtitle: 'Claude Code Switch Dashboard', + subtitle: 'Claude Codex Switch Dashboard', }, hubFooter: { logs: 'Logs', @@ -1954,6 +1958,11 @@ const resources = { sessionRecognitionTitle: 'Session recognition', sessionRecognitionDescription: 'CCS does not promise one universal precedence order here. In practice, upstream backends prefer explicit session or thread IDs first, then fall back to metadata fields and finally a hash based on the opening prompt history.', + retryTitle: 'Auto-retry', + retryHint: 'Retries / max wait (s)', + requestRetryLabel: 'Request retry count', + maxRetryIntervalLabel: 'Max retry interval (seconds)', + retryRangeError: 'Must be a whole number, 0 or greater.', }, extendedContext: { extendedContext: 'Extended Context', @@ -2347,6 +2356,7 @@ const resources = { noProfilesToSync: 'No profiles to sync', syncFailed: 'Sync failed: {{error}}', sessionAffinityUpdated: 'Session affinity {{state}}.', + cliproxyRetryUpdated: 'Retry settings updated.', providerAuthSuccess: '{{provider}} authentication successful', providerDeviceCodeInCallback: 'Provider returned Device Code flow in callback mode', providerAuthTimeout: 'Authentication timed out. Please try again.', @@ -2737,6 +2747,2817 @@ const resources = { }, }, }, + 'pt-BR': { + translation: { + locale: { + en: 'Inglês', + 'zh-CN': 'Chinês Simplificado', + vi: 'Vietnamita', + ja: 'Japonês', + ko: 'Coreano', + 'pt-BR': 'Português (Brasil)', + }, + layout: { + languageSwitcher: 'Idioma', + }, + nav: { + general: 'Geral', + home: 'Início', + analytics: 'Análises', + identityAccess: 'Identidade e Acesso', + apiProfiles: 'Perfis', + cliproxyPlus: 'CLIProxy Plus', + cliproxyOverview: 'Visão Geral', + controlPanel: 'Painel de Controle', + githubCopilot: 'GitHub Copilot', + cursorIde: 'Cursor IDE', + claudeExtension: 'Extensão Claude', + accounts: 'Contas', + allAccounts: 'Todas as Contas', + sharedData: 'Recursos Compartilhados', + compatibleClis: 'Compatíveis', + deprecated: 'Descontinuado', + factoryDroid: 'Factory Droid', + system: 'Sistema', + health: 'Saúde', + logs: 'Logs', + settings: 'Configurações', + openrouterTooltip: 'Destaque: OpenRouter + Alibaba Coding Plan + Ollama', + }, + home: { + profiles: 'Perfis', + cliproxy: 'CLIProxy', + accounts: 'Contas', + health: 'Saúde', + configurationRequired: 'Configuração Necessária', + }, + auth: { + dashboardTitle: 'Claude Codex Switch Dashboard', + protectedAccessLabel: 'Acesso protegido', + remoteGuardLabel: 'Proteção de acesso remoto', + loading: 'Verificando acesso ao painel…', + loginDescription: + 'Use o nome de usuário e a senha configurados para esta instância do CCS para acessar o painel.', + username: 'Nome de usuário', + password: 'Senha', + usernamePlaceholder: 'Digite o nome de usuário', + passwordPlaceholder: 'Digite a senha', + signIn: 'Entrar', + signingIn: 'Entrando...', + loginFailed: 'Falha no login', + lightMode: 'Claro', + darkMode: 'Escuro', + noDefaultCredentials: 'O CCS não vem com credenciais padrão.', + credentialsHint: + 'As credenciais são criadas com `ccs config auth setup` no host do CCS. Implantações Docker devem executar esse comando dentro do container.', + remoteSetupTitle: 'O acesso remoto precisa de configuração de autenticação', + remoteSetupDescription: + 'Este painel foi aberto a partir de um endereço não local, mas a autenticação do painel ainda não está habilitada para esta instância do CCS.', + incompleteSetupDescription: + 'A autenticação do painel está ativada, mas a configuração está incompleta. Finalize a configuração desta instância do CCS antes de entrar.', + safetyNoteRemote: + 'O gerenciamento remoto permanece bloqueado até que o proprietário do host CCS habilite a autenticação do painel.', + safetyNoteLocal: + 'Se você estiver na mesma máquina, a URL localhost continua sendo o caminho mais simples para entrar.', + safetyNoteSession: + 'Logins bem-sucedidos criam um cookie HttpOnly restrito a este host do painel.', + hostStepTitle: 'No host do CCS', + hostStepDescription: + 'Crie ou reative as credenciais do painel e, em seguida, reabra esta página a partir do dispositivo remoto.', + dockerStepDescription: + 'Implantação Docker? Execute a configuração dentro do container em execução:', + localStepTitle: 'Se esta for a sua máquina', + localStepDescription: + 'Abra a URL localhost exibida por `ccs config` em vez do endereço da LAN ou do Tailscale.', + showPassword: 'Mostrar senha', + hidePassword: 'Ocultar senha', + }, + commonToast: { + apiKeyRequired: 'A chave de API é obrigatória', + accountAddedPresetFailed: 'Conta adicionada, mas falha ao aplicar o preset padrão', + settingsCopied: 'Configurações copiadas para a área de transferência', + enterPresetName: 'Digite um nome de preset para salvar', + codeCopied: 'Código copiado para a área de transferência', + copyCodeFailed: 'Falha ao copiar o código', + appliedModelAllTiers: 'Modelo aplicado a todos os níveis', + settingsSaved: 'Configurações salvas', + defaultTargetUpdated: 'Destino padrão atualizado', + failedUpdateDefaultTarget: 'Falha ao atualizar o destino padrão para {{target}}{{suffix}}', + resourcesUpdated: 'Recursos compartilhados atualizados', + }, + profileDialog: { + editTitle: 'Editar Perfil', + createTitle: 'Criar Perfil de API', + name: 'Nome', + namePlaceholder: 'minha-api', + baseUrl: 'URL Base', + baseUrlPlaceholder: 'https://api.example.com', + apiKey: 'Chave de API', + defaultModel: 'Modelo Padrão (ANTHROPIC_MODEL)', + defaultModelHint: 'Deixe em branco para usar: {{model}}', + modelMappingTitle: 'Mapeamento de Modelos (Opus/Sonnet/Haiku)', + modelMappingDesc: + 'Configure IDs de modelo diferentes para cada nível. Útil para proxies de API que roteiam diferentes tipos de modelo para back-ends diferentes.', + opusModel: 'Modelo Opus (ANTHROPIC_DEFAULT_OPUS_MODEL)', + sonnetModel: 'Modelo Sonnet (ANTHROPIC_DEFAULT_SONNET_MODEL)', + haikuModel: 'Modelo Haiku (ANTHROPIC_DEFAULT_HAIKU_MODEL)', + cancel: 'Cancelar', + saving: 'Salvando...', + update: 'Atualizar', + create: 'Criar', + }, + modelTierMapping: { + title: 'Mapeamento de Nível de Modelo', + advanced: 'Avançado', + description: + 'Configure modelos diferentes para os níveis opus/sonnet/haiku do Claude Code.', + autoSuggest: 'Sugestão automática baseada em {{provider}}', + opusPlaceholder: 'ex.: anthropic/claude-opus-4', + sonnetPlaceholder: 'ex.: anthropic/claude-sonnet-4', + haikuPlaceholder: 'ex.: anthropic/claude-3.5-haiku', + footer: + 'Isso define ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL.', + }, + envEditor: { + sensitive: 'sensível', + none: 'Nenhuma variável de ambiente configurada.', + noneHint: 'Adicione variáveis usando o campo abaixo ou edite o JSON diretamente.', + addVariable: 'Adicionar Variável de Ambiente', + keyPlaceholder: 'NOME_DA_VARIAVEL', + valuePlaceholder: 'valor', + }, + setupVariant: { + using: 'Usando:', + variantNameRequired: 'Nome da Variante de Execução *', + variantNamePlaceholder: 'ex.: my-gemini, g3, flash', + invokeHintPrefix: 'Execute com esta variante:', + model: 'Modelo', + modelPlaceholder: 'ex.: gemini-2.5-pro, claude-opus-4-6-thinking', + choosePresetInstead: 'Escolher entre presets', + selectModel: 'Selecione um modelo', + customModelName: 'Nome de modelo personalizado...', + enterAnyModel: 'Digite qualquer nome de modelo suportado pelo CLIProxy', + defaultModel: 'Padrão: {{model}}', + providerDefault: 'padrão do provedor', + back: 'Voltar', + skip: 'Pular', + creating: 'Criando...', + createVariant: 'Criar Variante', + skipHint: + 'Pule se a configuração da conta já for suficiente e você não precisar de uma variante de execução separada', + }, + customPresetDialog: { + title: 'Preset Personalizado', + presetNameOptional: 'Nome do Preset (opcional)', + presetNamePlaceholder: 'ex.: Minha Configuração Personalizada', + codexTipPrefix: 'Dica do Codex: sufixos', + codexTipSuffix: 'fixam o esforço. Modelos sem sufixo usam as configurações de Pensamento.', + defaultModel: 'Modelo Padrão', + defaultModelDesc: 'Usado quando nenhum nível específico é solicitado', + opusModel: 'Opus (Mais capaz)', + opusModelDesc: 'Para tarefas de raciocínio complexas', + sonnetModel: 'Sonnet (Equilibrado)', + sonnetModelDesc: 'Equilíbrio entre velocidade e capacidade', + haikuModel: 'Haiku (Rápido)', + haikuModelDesc: 'Respostas rápidas para tarefas simples', + cancel: 'Cancelar', + savePreset: 'Salvar Preset', + applyPreset: 'Aplicar Preset', + deletePreset: 'Excluir Preset', + }, + componentModelSelector: { + selectModel: 'Selecionar modelo', + availableModelsCount: 'Modelos Disponíveis ({{count}})', + preview: 'Pré-visualização', + }, + searchableSelect: { + searchModels: 'Buscar modelos...', + noResults: 'Nenhum resultado encontrado.', + }, + copilotSettings: { + enableCopilot: 'Habilitar Copilot', + enableCopilotDesc: 'Permitir o uso da assinatura do GitHub Copilot', + basicSettings: 'Configurações Básicas', + accountType: 'Tipo de Conta', + accountTypeIndividual: 'Individual', + accountTypeBusiness: 'Empresarial', + accountTypeEnterprise: 'Corporativo', + rateLimiting: 'Limitação de Taxa', + rateLimitSeconds: 'Limite de Taxa (segundos)', + noLimit: 'Sem limite', + waitOnRateLimit: 'Aguardar no Limite de Taxa', + waitOnRateLimitDesc: 'Aguardar em vez de gerar erro ao atingir o limite', + daemonSettings: 'Configurações do Daemon', + autoStartDaemon: 'Iniciar Daemon Automaticamente', + autoStartDaemonDesc: 'Iniciar o copilot-api ao usar o perfil', + }, + providerModelSelector: { + selectModel: 'Selecionar modelo', + noModelsForProvider: 'Nenhum modelo disponível para este provedor', + broken: 'QUEBRADO', + deprecated: 'DESCONTINUADO', + paid: 'PAGO', + freeTier: 'Nível Gratuito', + paidTier: 'Nível Pago', + modelKnownIssues: 'Este modelo tem problemas conhecidos', + viewIssueDetails: 'Ver detalhes do problema', + modelDeprecated: 'Este modelo está descontinuado', + recommended: 'Recomendado', + allModelsCount: 'Todos os Modelos ({{count}})', + noModelsAvailable: 'Nenhum modelo disponível', + shadowed: 'Sombreado', + prefixOnly: 'Somente prefixo', + current: 'Atual', + currentValue: 'Valor atual', + customModel: 'Modelo personalizado', + preferredPinnedModel: 'Modelo fixado preferido:', + pinnedRouteStatus: 'Status da rota fixada:', + pinnedModelNotAdvertised: + 'O modelo fixado não está sendo anunciado atualmente pelo proxy: {{model}}', + codexReasoningVariants: 'Variantes de raciocínio', + codexFastVariants: 'Variantes rápidas', + effortLabel: 'Esforço de raciocínio', + effortAuto: 'Automático', + fastTier: 'Rápido', + }, + createAuthProfileDialog: { + title: 'Criar Nova Conta', + description: + 'Perfis de autenticação exigem login pela CLI do Claude. Execute o comando abaixo no seu terminal. Você pode editar o modo de sincronização, o grupo e a profundidade de continuidade depois, na tabela de Contas.', + profileName: 'Nome do Perfil', + profileNamePlaceholder: 'ex.: trabalho, pessoal, cliente', + invalidProfileName: + 'O nome deve começar com uma letra e conter apenas letras, números, hifens ou sublinhados.', + enableSharedHistory: + 'Habilitar sincronização de histórico compartilhado com outras contas ccs auth', + historySyncGroupOptional: 'Grupo de Sincronização de Histórico (opcional)', + historySyncGroupPlaceholder: 'default, sprint-a, cliente-x', + historySyncGroupHint: + 'Deixe em branco para usar o grupo compartilhado padrão. Espaços são normalizados para hifens.', + deeperContinuity: 'Avançado: modo de continuidade mais profunda', + deeperContinuityHint: + 'Adiciona sincronização de session-env, file-history, shell-snapshots e todos. As credenciais permanecem isoladas.', + invalidContextGroup: + 'O grupo deve começar com uma letra e usar apenas letras, números, hifens ou sublinhados (máx. {{max}} caracteres).', + command: 'Comando', + commandFallback: 'ccs auth create ', + afterRunPrefix: 'Depois de executar o comando:', + afterRunStep1: 'Complete o login do Claude no seu navegador', + afterRunStep2: 'Volte aqui e atualize para ver a nova conta', + poolingHint: + 'Prefere o roteamento agrupado de OAuth do Claude? Use o pool Claude do CLIProxy no botão de ação da página de Contas.', + close: 'Fechar', + copied: 'Copiado!', + copyCommand: 'Copiar Comando', + }, + accountRouteGuide: { + title: 'Rota recomendada de duas contas', + description: + 'Use perfis de conta separados para isolamento de tokens. Ative a sincronização de histórico somente quando as duas contas devem compartilhar a mesma continuidade local.', + status: { + empty: 'criar contas', + isolated: 'isolado pronto', + shared: 'contexto de projeto compartilhado', + deeper: 'sincronização profunda pronta', + mixed: 'configuração mista', + }, + cards: { + isolated: { + title: 'Os tokens permanecem separados', + desc: 'Cada conta ccs auth possui sua própria configuração, login e credenciais do Claude. As configurações de sincronização nunca copiam tokens.', + }, + select: { + title: 'Você escolhe a conta', + desc: 'Execute ccs work ou ccs personal para uso explícito. Defina Padrão apenas quando o ccs simples deve seguir uma conta.', + }, + settings: { + title: 'As configurações seguem a raiz', + desc: 'Contas não-padrão vinculam o settings.json através do estado compartilhado do CCS ao ~/.claude/settings.json. O ccs auth show confirma isso.', + }, + sync: { + title: 'A sincronização de histórico é opcional', + desc: 'As duas contas precisam do modo Compartilhado e do mesmo grupo. A sincronização profunda adiciona artefatos extras de continuidade local.', + }, + }, + commands: { + isolated: 'Uso de conta isolada', + sync: 'Histórico compartilhado opcional', + syncDesc: + 'Use o mesmo grupo, como "{{group}}", nas duas contas quando quiser que compartilhem histórico local.', + }, + }, + cliproxyModelCategory: { + google: 'Google (Gemini)', + openai: 'OpenAI (GPT)', + anthropic: 'Anthropic (Claude)', + antigravity: 'Antigravity', + other: 'Outro', + }, + cliproxyOverviewComponents: { + availableModels: 'Modelos Disponíveis', + noModelsAvailable: 'Nenhum modelo disponível', + startSessionHint: 'Inicie uma sessão do CLIProxy para buscar os modelos disponíveis', + totalCount: '{{count}} no total', + availableThroughGroupedByProvider: + 'Modelos disponíveis via {{backendLabel}}, agrupados por provedor', + moreCount: '+{{count}} mais', + }, + categorizedModelSelector: { + selectModel: 'Selecione um modelo', + noModelsAvailable: 'Nenhum modelo disponível', + modelPlaceholder: 'Modelo...', + }, + openrouterQuickStart: { + recommended: 'Recomendado', + title: 'Comece com o OpenRouter', + description: + 'Acesse {{modelCountLabel}} modelos da OpenAI, Anthropic, Google, Meta e mais - tudo por meio de uma única API.', + featureOneApi: 'Uma API, todos os provedores', + profileSummaryCount: '{{count}} perfil', + profileSummaryCount_other: '{{count}} perfis', + profileSummaryEmpty: 'Qualidade premium + controle local', + summaryBadgeDefaultQuality: 'Melhor qualidade por padrão', + summaryBadgeLocalLane: 'Faixa local quando a adequação à tarefa vence', + summaryExistingTitle: 'Escolha um perfil ou adicione outra faixa', + summaryEmptyTitle: 'Escolha sua primeira faixa de perfil', + summaryExistingDescription: + 'Você já tem {{count}} perfil neste workspace. Mantenha provedores de qualidade premium para codificação séria e adicione runtimes locais quando privacidade, custo ou trabalho offline importarem.', + summaryExistingDescription_other: + 'Você já tem {{count}} perfis neste workspace. Mantenha provedores de qualidade premium para codificação séria e adicione runtimes locais quando privacidade, custo ou trabalho offline importarem.', + summaryEmptyDescription: + 'Escolha a faixa que combina com o trabalho. Provedores premium continuam sendo o padrão para codificação confiável. Runtimes locais são melhores para privacidade, transformações mais baratas e experimentação.', + qualityLanesTitle: 'Melhores faixas de qualidade', + qualityLaneHighlightDefaultQuality: + 'Melhor faixa padrão para qualidade de codificação de alto risco', + alibabaLaneDescription: + 'Perfil de codificação direto e forte quando você quer uma faixa premium dedicada fora do catálogo do OpenRouter.', + alibabaLaneHighlightQuality: + 'Bom quando você quer qualidade premium com um endpoint dedicado', + localRuntimesTitle: 'Runtimes locais', + localOllamaTitle: 'Ollama + Gemma 4', + localOllamaLabel: 'Ollama', + localOllamaHighlight: + 'Ideal para prompts privados, limpeza local e transformações em lote baratas', + localLlamacppTitle: 'llama.cpp', + localLlamacppLabel: 'llama.cpp', + localLlamacppHighlight: + 'Ideal para configurações GGUF personalizadas e fluxos de trabalho locais avançados auto-hospedados', + localDetectedModels: '{{count}} modelo local detectado', + localDetectedModels_other: '{{count}} modelos locais detectados', + localNoModelsDetected: 'Nenhum modelo local detectado ainda', + localWaitingServer: 'Aguardando um servidor local e a lista de modelos', + localRuntimeCheckingBadge: 'Verificando', + localRuntimeReadyBadge: 'Pronto', + localRuntimeNeedsModelBadge: 'Precisa de modelo', + localRuntimeOfflineBadge: 'Offline', + localRuntimeCheckingDescription: + 'Verificando o status do runtime local antes de mostrar orientações de configuração.', + localRuntimeReadyDescription: + 'Ideal para prompts privados, fluxos de trabalho offline e transformações em lote mais baratas.', + localRuntimeNeedsModelDescription: + 'O runtime está ativo, mas o modelo local recomendado ainda precisa ser baixado ou carregado.', + localRuntimeOfflineDescription: + 'Use esta faixa para trabalhos sensíveis à privacidade, transformações locais baratas e sessões offline.', + localRuntimeCheckingFooter: 'Verificando o runtime local...', + localRuntimeReadyFooter: 'Endpoint pronto em {{endpoint}}', + localRuntimeMissingModelFooter: 'Execute `{{command}}` para concluir a configuração local.', + localRuntimeOfflineFooter: 'Execute `{{command}}` para colocar o endpoint local online.', + localRuntimeSetupAction: 'Configurar {{label}}', + localRuntimeUseAction: 'Usar {{label}}', + localRuntimeFinishAction: 'Concluir configuração de {{label}}', + runtimeProviderBadge: 'Configuração de provedor de runtime', + runtimeProviderTitle: 'Abrir Provedores de IA', + runtimeProviderManagedDescription: + 'Use isto quando precisar de conectores gerenciados pelo CLIProxy, segredos de provedor e controles avançados de roteamento.', + runtimeProviderFeatureConnectors: + 'Conectores Gemini, Codex, Claude, Vertex e compatíveis com OpenAI', + runtimeProviderFeatureSecrets: + 'Segredos compartilhados ficam fora dos perfis de API individuais', + runtimeProviderFooter: + 'Ideal quando vários perfis devem compartilhar uma única configuração de provedor.', + createOpenRouterProfile: 'Criar Perfil OpenRouter', + getApiKeyAt: 'Obtenha sua chave de API em', + createCustomProfile: 'Criar Perfil de API Personalizado', + }, + alibabaCodingPlanQuickStart: { + recommended: 'Recomendado', + title: 'Experimente o Alibaba Coding Plan', + description: + 'Use o Alibaba Cloud Coding Plan por meio de um endpoint compatível com Anthropic para cargas de trabalho de codificação.', + featureEndpoint: 'Endpoint compatível com Anthropic', + featureKeyFormat: 'Chaves de API sk-sp dedicadas', + createAlibabaProfile: 'Criar Perfil do Alibaba Coding Plan', + readGuideAt: 'Leia o guia de configuração em', + }, + alibabaCodingPlanPromo: { + title: 'Alibaba Coding Plan', + subtitle: 'Model Studio Coding Plan via endpoint Anthropic', + add: 'Adicionar', + }, + credentialHealth: { + title: 'Saúde das Credenciais', + ready: 'Pronto', + noCredentialsConfigured: + 'Nenhuma credencial configurada. Use os botões de login acima para autenticar.', + neverUsed: 'Nunca usado', + justNow: 'Agora mesmo', + unknown: 'Desconhecido', + daysAgo: 'há {{count}}d', + hoursAgo: 'há {{count}}h', + minutesAgo: 'há {{count}}min', + }, + proxyStatusWidget: { + justNow: 'agora mesmo', + minutesAgo: 'há {{count}}min', + hoursAgo: 'há {{count}}h', + syncReady: 'Sincronização Pronta', + noConfig: 'Sem Configuração', + remoteProxy: 'Proxy Remoto', + active: 'Ativo', + trafficAutoRouted: 'Tráfego roteado automaticamente para o servidor remoto', + tooltipSync: 'Sincronizar perfis com o CLIProxy', + tooltipRestart: 'Reiniciar', + tooltipStop: 'Parar', + tooltipClose: 'Fechar', + tooltipVersionSettings: 'Configurações de versão', + versionManagement: 'Gerenciamento de Versão', + selectVersionPlaceholder: 'Selecione a versão a instalar...', + stable: '(estável)', + install: 'Instalar', + versionsAboveUnstable: 'Versões acima de {{version}} têm problemas conhecidos', + versionsKnownIssues: 'A versão {{version}} tem problemas conhecidos', + lastChecked: 'Última verificação {{time}}', + notRunning: 'Não está em execução', + start: 'Iniciar', + port: 'Porta {{port}}', + sessionCount: '{{count}} sessão', + sessionCount_other: '{{count}} sessões', + installFaultyTitle: 'Instalar Versão com Problemas Conhecidos?', + installFaultyDesc: + 'Você está prestes a instalar a v{{version}}, que está dentro do intervalo defeituoso conhecido {{range}}.', + installFaultyWarning: + 'Esta versão tem bugs conhecidos e pode falhar ou deixar o proxy em um estado inválido.', + installUnstableTitle: 'Instalar Versão Instável?', + installUnstableDesc: + 'Você está prestes a instalar a v{{version}}, que está acima da versão estável máxima v{{maxStable}}.', + installUnstableWarning: + 'Esta versão tem problemas de estabilidade conhecidos e pode causar comportamento inesperado.', + installUnstableConfirm: 'Tem certeza de que deseja continuar?', + cancel: 'Cancelar', + installAnyway: 'Instalar Mesmo Assim', + clickToDowngrade: 'Clique para fazer downgrade', + clickToUpdate: 'Clique para atualizar', + }, + syncStatusCard: { + profileSync: 'Sincronização de Perfil', + ready: 'Pronto', + noConfig: 'Sem Configuração', + syncsProfilesDesc: 'Sincroniza perfis de API com a configuração local do CLIProxy', + runDoctorHint: 'Execute ccs doctor --fix para gerar a configuração.', + details: 'Detalhes', + syncNow: 'Sincronizar Agora', + }, + syncDialog: { + title: 'Sincronizar Perfis com o CLIProxy Local', + description: 'Sincronize seus perfis de API do CCS com o config.yaml local do CLIProxy.', + noProfiles: 'Nenhum perfil configurado para sincronizar.', + createProfilesFirst: 'Crie perfis de API primeiro usando a aba Perfis.', + modelLabel: 'Modelo:', + ready: 'Pronto', + profilesToSync_one: '{{count}} perfil para sincronizar', + profilesToSync_other: '{{count}} perfis para sincronizar', + cancel: 'Cancelar', + syncing: 'Sincronizando...', + synced: 'Sincronizado!', + syncNow: 'Sincronizar Agora', + }, + errorLogs: { + title: 'Logs de Erro', + failedRequests_one: '{{count}} solicitação com falha', + failedRequests_other: '{{count}} solicitações com falha', + cliproxyDiagnostics: 'Diagnósticos do CLIProxy', + showingCount: 'Mostrando 50 de {{total}} logs', + selectLog: 'Selecione um log para ver os detalhes', + failedLoadContent: 'Falha ao carregar o conteúdo do log', + copyAbsolutePath: 'Copiar caminho absoluto', + copyRawContent: 'Copiar conteúdo bruto do log', + tabOverview: 'Visão Geral', + tabHeaders: 'Cabeçalhos', + tabRequest: 'Solicitação', + tabResponse: 'Resposta', + tabRaw: 'Bruto', + modelLabel: 'Modelo:', + quotaResetsIn: 'A cota é reiniciada em ', + method: 'Método', + provider: 'Provedor', + version: 'Versão', + endpoint: 'Endpoint', + url: 'URL', + timestamp: 'Registro de Data/Hora', + na: 'N/D', + noHeaders: 'Nenhum cabeçalho disponível', + noRequestBody: 'Nenhum corpo de solicitação', + noResponseBody: 'Nenhum corpo de resposta', + rateLimitedMsg: + 'Limite de taxa atingido. Troque para outra conta ou aguarde a redefinição da cota. O modelo {{model}} esgotou a cota.', + rateLimitedMsgNoModel: + 'Limite de taxa atingido. Troque para outra conta ou aguarde a redefinição da cota.', + authFailedMsg: + 'Falha na autenticação. Reautentique nas Configurações do CLIProxy ou verifique a chave de API.', + notFoundMsg: 'Endpoint não encontrado. Este endpoint pode não existir neste provedor.', + serverErrorMsg: + 'Erro de servidor upstream. Tente novamente mais tarde ou verifique a página de status do provedor.', + timeoutMsg: + 'A solicitação expirou. Verifique a conexão de rede ou aumente as configurações de tempo limite.', + unknown: 'desconhecido', + }, + editAccountContext: { + title: 'Editar Sincronização de Histórico', + description: + 'Configure como "{{name}}" compartilha histórico e continuidade com outras contas ccs auth.', + syncMode: 'Modo de Sincronização', + selectContextMode: 'Selecione o modo de contexto', + isolatedOption: 'Isolado', + sharedOption: 'Compartilhado', + sharedModeHint: + 'Reutiliza o contexto do workspace entre contas no mesmo grupo de histórico.', + isolatedModeHint: 'Mantém esta conta totalmente separada de outras contas ccs auth.', + historySyncGroup: 'Grupo de Sincronização de Histórico', + groupPlaceholder: 'default', + groupHint: + 'Normalizado para minúsculas (espaços viram hifens). Permitido: letras, números, _ , - (máx. {{max}} caracteres).', + invalidGroup: 'Digite um nome de grupo válido que comece com uma letra.', + continuityDepth: 'Profundidade de Continuidade', + selectContinuityDepth: 'Selecione a profundidade de continuidade', + standardOption: 'Padrão', + deeperOption: 'Profunda', + deeperHint: + 'Sincroniza session-env, file-history, shell-snapshots e todos de forma abrangente.', + standardHint: 'O modo padrão sincroniza apenas o contexto do workspace do projeto.', + credentialsIsolated: + 'As credenciais e o .anthropic permanecem isolados por conta em todos os modos.', + implicationTitle: 'O que isso significa após salvar', + isolatedImplication: + 'Esta conta permanece separada. Outras contas não poderão retomar o estado de continuidade dela.', + sameGroupRule: + 'As contas devem usar o mesmo grupo "{{group}}" antes de poder compartilhar continuidade entre si.', + noSameGroupPeer: 'Nenhuma outra conta compartilha este grupo no momento.', + sameGroupPeerCount_one: '{{count}} outra conta já compartilha este grupo.', + sameGroupPeerCount_other: '{{count}} outras contas já compartilham este grupo.', + deeperReady_one: + '{{count}} conta do mesmo grupo já usa continuidade profunda. Esta é a configuração de handoff mais forte disponível.', + deeperReady_other: + '{{count}} contas do mesmo grupo já usam continuidade profunda. Esta é a configuração de handoff mais forte disponível.', + deeperNeedsPeers: + 'A continuidade profunda está habilitada aqui, mas outra conta neste grupo ainda precisa de continuidade profunda para expectativas mais fortes de retomada entre contas.', + standardWarning: + 'O modo compartilhado padrão é bom apenas para o contexto do projeto. As expectativas de retomada entre contas são mais fortes quando ambas as contas usam continuidade profunda.', + resumeOriginalWarning: + 'Se a conversa antiga for importante, retome-a a partir da conta original antes de alterar a configuração.', + cancel: 'Cancelar', + save: 'Salvar', + saving: 'Salvando...', + }, + historySyncLearningMap: { + title: 'Como Funciona a Sincronização de Histórico', + description: + 'Isolado -> Compartilhado -> Profundo. A retomada entre contas precisa do mesmo grupo nas duas contas.', + learningMap: 'Mapa de Aprendizado', + isolated: 'Isolado', + shared: 'Compartilhado', + deeper: 'Profundo', + cliproxyManaged_one: + '{{count}} conta do pool Claude do CLIProxy é gerenciada na Central de Ações / página do CLIProxy.', + cliproxyManaged_other: + '{{count}} contas do pool Claude do CLIProxy são gerenciadas na Central de Ações / página do CLIProxy.', + showDetails: 'Mostrar detalhes: grupos, troca e política legada', + modeSwitch: 'Troca de Modo', + modeSwitchDesc: + 'Use Sincronizar em cada conta para alternar entre isolado/compartilhado e escolher a continuidade profunda.', + historySyncGroup: 'Grupo de Sincronização de Histórico', + historySyncGroupDesc: + 'O mesmo grupo é necessário antes que as contas possam compartilhar continuidade. O padrão de reserva é default.', + sameGroupRule: + 'No momento, as contas só compartilham continuidade quando ambas estão no modo compartilhado no mesmo grupo.', + deeperRecommendation: + 'O grupo "{{group}}" está compartilhado, mas a continuidade profunda ainda é a escolha mais segura se os usuários esperam retomada entre contas.', + partialGroup: + 'O grupo "{{group}}" está em boa forma, mas outras contas ou grupos ainda precisam da configuração do mesmo grupo ou de continuidade profunda.', + readyGroup: + 'O grupo "{{group}}" já tem continuidade profunda em várias contas. Esta é a configuração de handoff mais forte disponível.', + legacyConfirmation_one: '{{count}} conta legada ainda precisa de confirmação explícita.', + legacyConfirmation_other: + '{{count}} contas legadas ainda precisam de confirmação explícita.', + }, + continuityReadiness: { + title: 'Verificação de Retomada entre Contas', + description: + 'Use este cartão para ver o que está faltando antes que os usuários troquem de conta.', + state: { + single: 'conta única', + isolated: 'retomada desativada', + 'shared-alone': 'compartilhado mas incompleto', + 'shared-standard': 'apenas projetos', + partial: 'parcialmente pronto', + ready: 'handoff mais forte pronto', + }, + metrics: { + isolated: 'Isolado', + sharedPeers: 'Compartilhado com Par', + deeperReady: 'Profundo Pronto', + }, + messages: { + single: { + title: 'Apenas uma conta de autenticação está configurada.', + description: + 'O handoff entre contas ainda não se aplica. Adicione outra conta de autenticação antes de configurar a continuidade compartilhada.', + }, + isolated: { + title: 'A retomada entre contas está desativada no momento.', + description: + 'Todas as contas visíveis estão isoladas, portanto uma sessão criada em uma conta permanecerá nessa conta.', + }, + 'shared-alone': { + title: 'O modo compartilhado existe, mas nenhum grupo tem um par ainda.', + description_one: + '{{count}} conta compartilhada ainda está esperando por outra conta no mesmo grupo.', + description_other: + '{{count}} contas compartilhadas ainda estão esperando por outra conta no mesmo grupo.', + }, + 'shared-standard': { + title: + 'O grupo "{{group}}" compartilha o contexto do projeto, mas a continuidade profunda ainda não está pareada.', + description: + 'Os usuários ainda podem esperar mais do que o compartilhamento de projeto. Ative a continuidade profunda nas duas contas deste grupo para expectativas de handoff mais fortes.', + }, + partial: { + title: 'Um grupo está pronto, mas a configuração geral ainda está mista.', + description: + 'Pelo menos um grupo já usa continuidade profunda, mas outras contas ainda estão isoladas, sozinhas em um grupo compartilhado ou sem continuidade profunda.', + }, + ready: { + title: 'O grupo "{{group}}" tem a configuração de continuidade mais forte disponível.', + description: + 'Várias contas neste grupo já usam continuidade profunda. O comportamento de retomada ainda depende do que o Claude armazena upstream.', + }, + }, + stepsTitle: 'O que os usuários devem fazer a seguir', + steps: { + syncBoth: 'Abra Sincronizar nas duas contas, não apenas na que você está trocando.', + sameGroup: + 'Use o mesmo Grupo de Sincronização de Histórico nas duas contas. "{{group}}" é o grupo recomendado atualmente.', + enableDeeper: + 'Se os usuários esperam retomada entre contas em vez de compartilhamento apenas de projeto, ative a continuidade profunda nas duas contas.', + resumeOriginal: + 'Se a conversa original for importante, retome-a a partir da conta original antes de alterar as configurações de continuidade.', + }, + singleSteps: { + addAccount: + 'Crie uma segunda conta ccs auth antes de planejar qualquer handoff entre contas.', + sameGroupLater: + 'Quando essa segunda conta existir, coloque as duas contas no mesmo Grupo de Sincronização de Histórico.', + enableDeeperLater: + 'Se os usuários esperarem retomada entre contas em vez de compartilhamento apenas de projeto, ative a continuidade profunda nas duas contas.', + resumeOriginal: + 'Se a conversa original for importante, continue retomando-a a partir da conta original até que a segunda conta esteja pronta.', + }, + }, + continuityOverview: { + plainLaneTitle: 'O ccs simples está usando uma faixa de retomada diferente', + plainLaneDescription: + 'O ccs simples atualmente retoma a partir de {{lane}}, não desta faixa de conta. Recupere a faixa original antes de alterar as configurações de sincronização.', + setDefaultHint: + 'Use a ação Definir Padrão na conta que você quer que o ccs simples siga em sessões futuras.', + recommendBadge: 'Recomendar {{group}}', + partialBadge: 'Sincronização parcial {{group}}', + lane: { + native: 'Claude nativo (~/.claude)', + accountDefault: 'conta {{name}}', + accountInherited: 'conta {{name}} via herança de continuidade', + profileDefault: 'perfil padrão {{name}} via faixa nativa do Claude', + }, + }, + accountsTable: { + name: 'Nome', + type: 'Tipo', + defaultBadge: 'padrão', + created: 'Criado', + lastUsed: 'Último Uso', + historySync: 'Sincronização de Histórico', + sharedResources: 'Recursos', + actions: 'Ações', + syncTitle: 'Editar modo de sincronização, grupo e profundidade de continuidade', + resourcesTitle: 'Editar herança de recursos compartilhados', + confirmLegacyTitle: 'Confirmar o modo atual desta conta legada como explícito', + sync: 'Sincronizar', + confirm: 'Confirmar', + active: 'Ativo', + setDefault: 'Definir Padrão', + deleteAccount: 'Excluir conta', + cannotDeleteDefault: 'Não é possível excluir a conta padrão', + noAccounts: 'Nenhuma conta ccs auth encontrada. Use ccs auth create para adicionar contas.', + resetToDefault: 'Redefinir para o Padrão do CCS', + deleteDialogTitle: 'Excluir Conta', + deleteDialogDesc: + 'Tem certeza de que deseja excluir a conta "{{name}}"? Isso removerá o perfil e todos os dados de sessão dele. Esta ação não pode ser desfeita.', + cancel: 'Cancelar', + delete: 'Excluir', + sharedGroupStandard: 'compartilhado ({{group}}, apenas projetos)', + sharedGroupDeeper: 'compartilhado ({{group}}, continuidade profunda)', + sharedGroupLegacy: 'compartilhado ({{group}}, apenas projetos, legado)', + isolatedLegacy: 'isolado (padrão legado)', + isolated: 'isolado', + resourcesShared: 'compartilhado', + resourcesProfileLocal: 'local do perfil', + noSameGroupPeer: 'Ainda sem par no mesmo grupo', + sameGroupPeerCount_one: '{{count}} par no mesmo grupo', + sameGroupPeerCount_other: '{{count}} pares no mesmo grupo', + legacyReview: 'Revise e confirme este padrão inferido.', + noHandoff: 'A retomada entre contas permanece na conta original.', + badges: { + shared: 'Compartilhado', + deeper: 'Profundo', + legacy: 'Legado', + isolated: 'Isolado', + profileLocal: 'Local', + }, + }, + addAccountDialog: { + title: 'Adicionar Conta {{displayName}}', + descKiro: + 'Escolha um método de autenticação Kiro e depois autentique via navegador ou importe da IDE Kiro.', + descDeviceCode: + 'Clique em Autenticar. Um código de verificação aparecerá para você digitar no site do provedor.', + descOauth: + 'Clique em Autenticar para obter uma URL OAuth. Abra-a em qualquer navegador para entrar.', + powerUserEnabled: 'Modo usuário avançado habilitado', + powerUserSkipped: + 'Configurações > modo usuário avançado do Proxy está ignorando a checklist de responsabilidade do AGY e a frase de risco do painel Gemini. Você aceita total responsabilidade pelo risco de OAuth/conta.', + powerUserLoadFailed: + 'Falha ao carregar as configurações do modo usuário avançado. Verifique Configurações > Proxy e tente novamente.', + powerUserLoading: + 'Carregando configurações de segurança do modo usuário avançado. Aguarde um momento e tente novamente.', + powerUserUnavailableRetry: + 'O modo usuário avançado está indisponível. Complete a etapa de segurança do provedor exigida e tente novamente.', + authMethod: 'Método de Autenticação', + selectKiroAuthMethod: 'Selecione o método de autenticação Kiro', + gitlabAuthMethod: 'Método de autenticação do GitLab', + selectGitlabAuthMethod: 'Selecione o método de autenticação do GitLab', + gitlabAuthOAuth: 'OAuth pelo Navegador', + gitlabAuthPat: 'Token de Acesso Pessoal', + gitlabAuthHint: + 'Use OAuth pelo Navegador para gitlab.com, ou PAT para instalações auto-hospedadas e gerenciadas por administrador.', + gitlabUrl: 'URL do GitLab', + gitlabUrlPlaceholder: 'https://gitlab.com', + gitlabUrlHint: + 'Opcional. Deixe em branco para gitlab.com, ou defina a URL base do seu GitLab auto-hospedado.', + gitlabPat: 'Token de Acesso Pessoal', + gitlabPatPlaceholder: 'glpat-...', + gitlabPatHint: 'O token deve incluir pelo menos', + gitlabPatRequired: 'O Token de Acesso Pessoal do GitLab é obrigatório para login via PAT.', + nicknameRequired: 'Apelido (obrigatório)', + nicknameOptional: 'Apelido (opcional)', + nicknamePlaceholder: 'ex.: trabalho, pessoal', + nicknameRequiredHint: + 'Obrigatório para este provedor. Use um nome amigável exclusivo (ex.: trabalho, pessoal).', + nicknameOptionalHint: + 'Um nome amigável para identificar esta conta. Deixe em branco para usar um identificador gerado com segurança.', + waitingForAuth: 'Aguardando autenticação...', + deviceCodeHint: + 'Uma caixa de diálogo com o código de verificação aparecerá em breve. Digite o código no site do provedor.', + browserHint: + 'Complete a autenticação no seu navegador. Esta caixa de diálogo fecha automaticamente.', + openUrlLabel: 'Abra esta URL em qualquer navegador para entrar:', + copied: 'Copiado', + copy: 'Copiar', + open: 'Abrir', + popupBlocked: 'Pop-up bloqueado. Copie a URL acima e abra-a manualmente no seu navegador.', + redirectPasteLabel: 'O redirecionamento não funcionou? Cole a URL de callback:', + callbackPlaceholder: 'Cole a URL de redirecionamento aqui...', + submitCallback: 'Enviar Callback', + submitting: 'Enviando...', + preparingUrl: 'Preparando URL de login...', + cancel: 'Cancelar', + importingToken: 'Importando token da IDE Kiro...', + authenticate: 'Autenticar', + importFromIde: 'Importar da IDE', + importing: 'Importando...', + }, + flowViz: { + backToProviders: 'Voltar para provedores', + showDetails: 'Mostrar Detalhes', + hideDetails: 'Ocultar Detalhes', + showPausedAccounts: 'Mostrar Pausadas ({{count}})', + hidePausedAccounts: 'Ocultar Pausadas ({{count}})', + visibleTotalRequests: 'Solicitações Visíveis', + visibleAccounts: 'Contas Visíveis', + excludingPausedAccounts: 'Excluindo {{count}} pausadas', + resetLayout: 'Redefinir layout', + provider: 'Provedor', + totalRequests: 'Total de Solicitações', + accounts: 'Contas', + connectionTimeline: 'Linha do Tempo de Conexões', + noRecentConnections: 'Nenhuma conexão recente', + }, + quickStats: { + totalRequests: 'Total de Solicitações', + successRate: 'Taxa de Sucesso', + totalTokens: 'Total de Tokens', + activeModels: 'Modelos Ativos', + }, + authMonitor: { + accounts: 'Contas', + accountsCount: '{{count}} contas', + success: 'Sucesso', + failed: 'Falha', + successRate: 'Taxa de Sucesso', + }, + accountCard: { + resumeAccount: 'Retomar conta', + pauseAccount: 'Pausar conta', + quotaLoading: 'Cota...', + quota: 'Cota', + quotaUnavailable: 'Limites de cota indisponíveis', + reauthNeeded: 'Reautenticação necessária', + failureLabelReauth: 'Reautenticar', + failureLabelWorkspace: 'Workspace', + failureLabelNoAccess: 'Sem Acesso', + failureLabelRetry: 'Tentar Novamente', + failureLabelReconnect: 'Reconectar', + failureLabelTemporary: 'Temporário', + failureLabelUnavailable: 'Indisponível', + failureHintReauth: 'Atualize esta conta executando o fluxo de autenticação novamente.', + failureHintWorkspace: + 'Mova esta conta de volta para um workspace ativo e depois remova e adicione-a novamente.', + failureHintNoAccess: 'Esta conta não pode acessar o endpoint de cota do provedor.', + failureHintRetry: 'Aguarde um pouco e tente a solicitação de cota novamente.', + failureHintReconnect: 'Remova a conta obsoleta e autentique-a novamente.', + failureHintTemporary: 'Tente novamente mais tarde. Isso parece temporário.', + removeAndReadd: 'Remover e readicionar conta', + autoRefreshFailed: 'Falha na atualização automática', + }, + antigravityChecklist: { + title: 'Responsabilidade OAuth do Antigravity', + subtitle: 'Complete todas as 4 etapas antes de poder autenticar.', + mandatory: 'Obrigatório', + completion: 'Conclusão', + stepsCount: '{{current}}/4 etapas', + }, + accountSafetyWarning: { + title: 'Aviso de Segurança da Conta OAuth', + subtitle: 'Issue #509 · Risco OAuth Gemini + AGY', + firstLine: + 'A issue #509 documenta relatos de suspensão/banimento vinculados ao uso do ccs agy e ao uso de conta compartilhada entre ccs gemini e ccs agy.', + secondLine: + 'Continue somente se você aceitar total responsabilidade pelo risco de OAuth e de acesso à conta.', + issueLabel: 'Ler a issue #509', + proxySettingsLabel: 'Controles Gemini + AGY: Configurações > Proxy', + }, + cliproxyDialog: { + name: 'Nome', + provider: 'Provedor', + account: 'Conta', + accountOptional: 'Conta (opcional)', + model: 'Modelo', + modelOptional: 'Modelo (opcional)', + defaultTarget: 'Destino Padrão', + defaultTier: 'Nível Padrão', + tierConfig: 'Configuração de Nível', + selectProvider: 'Selecione o provedor...', + useDefaultAccount: 'Usar conta padrão', + defaultSuffix: '(padrão)', + opus: 'Opus', + sonnet: 'Sonnet', + haiku: 'Haiku', + claudeCode: 'Claude Code', + factoryDroid: 'Factory Droid', + cancel: 'Cancelar', + create: 'Criar', + creating: 'Criando...', + saveChanges: 'Salvar Alterações', + saving: 'Salvando...', + placeholderName: 'my-gemini', + placeholderModel: 'gemini-2.5-pro', + placeholderComposite: 'my-composite', + placeholderModelId: 'model-id', + placeholderAccountId: 'account-id', + editCompositeTitle: 'Editar Variante Composta: {{name}}', + editSingleTitle: 'Editar Variante Única: {{name}}', + }, + profileEditor: { + profileInfo: 'Informações do Perfil', + profileName: 'Nome do Perfil', + filePath: 'Caminho do Arquivo', + lastModified: 'Última Modificação', + defaultTarget: 'Destino Padrão', + quickUsage: 'Uso Rápido', + runWithProfile: 'Executar com o perfil', + runOnDroid: 'Executar no Droid', + runOnDroidWithFlag: 'Executar no Droid (--target)', + droidAliasExplicit: 'Alias do Droid (explícito)', + overrideToClaude: 'Substituir por Claude', + overrideToClaudeExplicit: 'Substituir por Claude (explícito)', + targetHintPreferredAlias: 'Alias explícito preferido:', + targetHintClaudeDefault: 'Comando padrão:', + targetHintLegacyAlias: 'O atalho legado ainda funciona:', + targetHintOverride: 'Você ainda pode substituir cada execução com', + setAsDefault: 'Definir como padrão', + provider: 'Provedor', + custom: 'Personalizado', + providerChooserHint: + 'Comece com um preset e depois ajuste a URL, a chave de API ou o mapeamento de modelo conforme necessário.', + featuredProviders: 'Provedores em Destaque', + scrollHint: 'Role se sua tela for estreita', + customEndpoint: 'Endpoint Personalizado', + morePresets: 'Mais Presets', + customEndpointDescription: + 'Comece com uma URL compatível com Anthropic em branco e configure tudo manualmente.', + }, + settings: { + sectionLoadFailed: 'Falha ao carregar a seção', + unknownError: 'Ocorreu um erro desconhecido', + reloadPage: 'Recarregar página', + refresh: 'Atualizar', + copy: 'Copiar', + copied: 'Copiado', + saved: 'Salvo', + loading: 'Carregando...', + configFileNotFound: 'Arquivo de configuração não encontrado', + }, + settingsTabs: { + browser: 'Navegador', + web: 'Web', + image: 'Imagem', + channels: 'Canais', + env: 'Ambiente', + think: 'Pensamento', + proxy: 'Proxy', + auth: 'Autenticação', + backup: 'Backup', + }, + settingsAuth: { + failedFetchTokens: 'Falha ao buscar tokens de autenticação', + failedSaveTokens: 'Falha ao salvar tokens', + tokensUpdated: 'Tokens atualizados. Reinicie o CLIProxy para aplicar.', + failedRegenerate: 'Falha ao regenerar o segredo', + secretRegenerated: + 'Novo segredo de gerenciamento gerado. Reinicie o CLIProxy para aplicar.', + failedReset: 'Falha ao redefinir os tokens', + tokensReset: 'Tokens redefinidos para os padrões. Reinicie o CLIProxy para aplicar.', + description: + 'Configure os tokens de autenticação do CLIProxy. As alterações exigem reinicialização do CLIProxy.', + apiKey: 'Chave de API', + custom: 'Personalizado', + apiKeyDesc: 'Usado pelo Claude Code para se autenticar com o CLIProxy', + apiKeyPlaceholder: 'Chave de API', + managementSecret: 'Segredo de Gerenciamento', + managementSecretDesc: + 'Usado pelo painel do CCS para acessar as APIs de gerenciamento do CLIProxy', + managementSecretPlaceholder: 'Segredo de gerenciamento', + generateSecret: 'Gerar novo segredo seguro', + resetDefaults: 'Redefinir para os Padrões', + resetDesc: 'Redefine a chave de API e o segredo de gerenciamento para seus valores padrão.', + saving: 'Salvando...', + save: 'Salvar', + }, + settingsGlobalEnv: { + description: + 'Variáveis de ambiente injetadas em todos os perfis de assinatura não-Claude (gemini, codex, agy, ghcp, etc.)', + enabled: 'Ambiente Global habilitado', + disabled: 'Ambiente Global desabilitado', + enabledDesc: 'As variáveis de ambiente serão injetadas em perfis de terceiros', + disabledDesc: 'As variáveis de ambiente não serão injetadas', + envVars: 'Variáveis de Ambiente', + noneConfigured: 'Nenhuma variável de ambiente configurada', + addNew: 'Adicionar Nova Variável', + keyName: 'NOME_DA_CHAVE', + value: 'valor', + add: 'Adicionar', + quickAdd: 'Adicionar Rapidamente Variáveis Comuns', + allConfigured: 'Todas as variáveis comuns estão configuradas', + }, + settingsBackups: { + failedFetch: 'Falha ao buscar backups', + failedRestore: 'Falha ao restaurar o backup', + restoreSuccess: 'Backup restaurado com sucesso', + title: 'Backups de Configurações', + description: + 'Restaure versões anteriores do seu arquivo settings.json. Os backups são criados automaticamente quando as configurações são modificadas.', + none: 'Nenhum backup disponível', + noneHint: 'Os backups aparecerão aqui quando você modificar as configurações', + latest: 'Mais recente', + restoring: 'Restaurando...', + restore: 'Restaurar', + restoreConfirmTitle: 'Restaurar Backup?', + restoreConfirmPrefix: 'Isso substituirá suas configurações atuais pelo backup de', + restoreConfirmSuffix: 'Esta ação não pode ser desfeita.', + cancel: 'Cancelar', + }, + settingsWebsearch: { + description: + 'Busca na web baseada em CLI para perfis de terceiros (gemini, codex, agy, etc.)', + enabled: 'Busca na Web habilitada', + disabled: 'Busca na Web desabilitada', + checking: 'Verificando status...', + providers: 'Provedores', + noneConfigured: 'Nenhum provedor configurado', + noneConfiguredHint: 'Instale ferramentas de CLI para habilitar provedores de busca na web', + checkProviders: 'Verificar provedores', + installed: 'instalado', + notInstalled: 'não instalado', + model: 'Modelo:', + howInstall: 'Como instalar a CLI do {{name}}', + installGlobally: 'Instalar globalmente', + requiresKey: '(requer chave de API xAI)', + freeTier: '(nível GRATUITO disponível)', + viewDocs: 'Ver documentação', + }, + settingsProxy: { + failedLoadAgyMode: 'Falha ao carregar o modo usuário avançado', + failedUpdateAgyMode: 'Falha ao atualizar o modo usuário avançado', + failedVerifyAgyMode: 'Falha ao verificar a persistência do modo usuário avançado', + notPersistedAgyMode: + 'O modo usuário avançado não foi persistido. A configuração pode ter sido modificada por outro processo.', + agyModeEnabled: 'Modo usuário avançado habilitado.', + agyModeDisabled: 'Modo usuário avançado desabilitado.', + typePhraseToContinue: 'Digite "{{value}}" para continuar.', + description: + 'Configure a conexão local ou remota do {{backend}} para perfis baseados em proxy', + backendPlus: 'CLIProxy Plus', + backend: 'CLIProxy', + instanceStatus: 'Status da Instância', + connectionMode: 'Modo de Conexão', + local: 'Local', + remote: 'Remoto', + localDesc: 'Executar o binário do {{backend}} nesta máquina', + remoteDesc: 'Conectar a um servidor {{backend}} remoto', + backendBinary: 'Binário do Backend', + stopProxyToSwitch: 'Pare o proxy em execução em Status da Instância para trocar o backend.', + default: 'Padrão', + plusDesc: + 'Faixa opcional para provedores extras. Ainda suportada, mas atualmente mantida pela comunidade em vez de mantida upstream.', + originalDesc: 'Backend padrão, sempre disponível, para a faixa de provedores principal.', + plusFallbackNotice: + 'A faixa de provedores Plus não está descontinuada, mas o CLIProxy local ainda recorre ao backend original enquanto o caminho do fork mantido está sendo restabelecido.', + variantsIncompatible: + 'As variantes plus-extra existentes ({{providers}}) não funcionarão no backend original. Mantenha-as visíveis para referência, mas mude para o Plus antes de usá-las.', + safety: 'Segurança', + agyModeTitle: 'Modo Usuário Avançado do Antigravity + Gemini', + agyModeDesc: + 'Pula a checklist de responsabilidade do AGY e a confirmação digitada do painel Gemini.', + agyWarning: + 'Use apenas se você entender totalmente o padrão de risco de suspensão/banimento de OAuth (#509). O CCS não pode assumir responsabilidade pela perda da conta.', + finalConfirm: 'Confirmação final necessária', + finalConfirmDesc: + 'Habilitar isso pulará os pontos de verificação de segurança do AGY e a barreira da frase de risco do painel Gemini. Revise a issue #509 e digite a frase exata para prosseguir.', + step1: 'Etapa 1', + readIssue: 'Ler a issue #509', + step2: 'Etapa 2', + typePrefix: 'Digite', + typeSuffix: 'para habilitar.', + typePhraseAria: + 'Digite I ACCEPT RISK para habilitar o modo usuário avançado do Antigravity + Gemini', + exactPhrase: 'A frase exata é obrigatória.', + enableAgyMode: 'Habilitar Modo Usuário Avançado do Antigravity + Gemini', + toggleAgyMode: 'Alternar o modo usuário avançado do Antigravity + Gemini', + fallbackSettings: 'Configurações de Fallback', + enableFallback: 'Habilitar fallback para local', + enableFallbackDesc: 'Usar proxy local se o remoto estiver inacessível', + autoStartLocal: 'Iniciar proxy local automaticamente', + autoStartLocalDesc: 'Iniciar automaticamente o proxy local no fallback', + advanced: 'Avançado', + debugMode: 'Modo de Depuração', + debugModeDesc: 'Habilitar diagnósticos de desenvolvedor no console do navegador', + debugModeEnabled: + 'Modo de depuração habilitado. Verifique o console do navegador para logs detalhados.', + localProxy: 'Proxy Local', + port: 'Porta', + autoStart: 'Início automático', + localAutoStartDesc: 'Iniciar o proxy local automaticamente quando necessário', + remoteConfigTitle: 'Configuração do Servidor Remoto', + host: 'Host', + hostPlaceholder: '192.168.1.100 ou proxy.example.com', + defaultPort: 'padrão: {{value}}', + portPlaceholder: 'Deixe vazio para {{value}}', + protocol: 'Protocolo', + http: 'HTTP', + https: 'HTTPS', + apiKeyOptional: 'Chave de API (opcional)', + apiKeyPlaceholder: 'Para endpoints de API /v1/*', + apiKeyDesc: 'Usado para solicitações de API para /v1/chat/completions', + managementKeyOptional: 'Chave de Gerenciamento (opcional)', + managementKeyPlaceholder: 'Para endpoints /v0/management/*', + managementKeyDesc: + 'Usado para APIs de gerenciamento do painel. Recorre à Chave de API se não estiver definida.', + testing: 'Testando...', + testConnection: 'Testar Conexão', + connectedLatency: 'Conectado ({{value}}ms)', + connectionFailed: 'Falha na conexão', + invalidPortOrEmpty: + 'A porta deve ser um número inteiro entre 1 e 65535, ou vazia para o padrão', + invalidLocalPort: 'A porta local deve ser um número inteiro entre 1 e 65535', + backendPlusApi: 'CLIProxyAPIPlus', + backendApi: 'CLIProxyAPI', + }, + settingsThinking: { + description: 'Configure o pensamento estendido/raciocínio para modelos suportados.', + supportedProviders: 'Provedores Suportados', + modeTitle: 'Modo de Pensamento', + modeAutoDesc: 'Define o pensamento automaticamente com base no nível do modelo', + modeOffDesc: 'Desabilitar pensamento estendido', + modeManualDesc: + 'Defina um nível de substituição persistente ou use flags de CLI por execução', + tierDefaults: 'Padrões de Nível', + tierDefaultsDesc: + 'Nível de pensamento padrão para cada nível de modelo no modo automático.', + persistentOverride: 'Substituição Persistente', + persistentOverrideDesc: + 'Aplicado a todas as sessões. As flags de CLI ainda têm prioridade.', + enterCustomBudget: 'Digite um orçamento personalizado', + apply: 'Aplicar', + providerOverrides: 'Substituições de Provedor ({{count}})', + providerOverridesDesc: + 'Substitua os padrões de nível para provedores específicos. Adicione chaves de provedor personalizadas conforme necessário.', + addProviderPlaceholder: 'Adicionar chave de provedor (ex.: qwen)', + showWarnings: 'Mostrar Avisos', + showWarningsDesc: + 'Exibir avisos quando os valores de pensamento forem limitados ou ajustados', + cliEnvOverride: 'Substituição por CLI e Ambiente', + supportLine1Prefix: 'Orçamento de pensamento:', + supportLine1Suffix: '(baseado em tokens)', + supportLine2Prefix: 'Esforço de raciocínio:', + supportLine2SuffixPrefix: '(sufixo ou ', + supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)', + supportLine3Prefix: 'Os sufixos do Codex fixam o esforço (por exemplo ', + supportLine3Suffix: '); modelos sem sufixo usam o modo Pensamento.', + }, + analytics: { + title: 'Análises', + noDailyUsage: 'Nenhum dado de uso para hoje', + noUsageData: 'Nenhum dado de uso disponível', + subtitle: 'Acompanhe uso e insights', + month: 'Mês', + allTime: 'Todo o Período', + updated: 'Atualizado {{value}}', + }, + analyticsSummary: { + totalTokens: 'Total de Tokens', + totalTokensSubtitle: '{{input}} entrada / {{output}} saída', + totalCost: 'Custo Total', + totalCostSubtitle: 'média de ${{value}}/dia', + cacheTokens: 'Tokens de Cache', + cacheTokensSubtitle: '${{cost}} ({{percent}}% do custo)', + inputCost: 'Custo de Entrada', + outputCost: 'Custo de Saída', + tokensSubtitle: '{{value}} tokens', + }, + apiProfiles: { + title: 'Perfis', + sidebarTitle: 'Perfis de API', + sidebarSubtitle: 'APIs premium, runtimes locais, endpoints personalizados', + new: 'Novo', + searchPlaceholder: 'Buscar perfis...', + loadingProfiles: 'Carregando perfis...', + failedLoadTitle: 'Falha ao carregar perfis', + failedLoadDesc: 'Não foi possível buscar os perfis de API. Tente novamente.', + retry: 'Tentar Novamente', + noProfilesYet: 'Ainda não há perfis de API', + noProfilesDesc: + 'Crie seu primeiro perfil para se conectar a endpoints de API personalizados', + createProfile: 'Criar Perfil', + noProfileMatch: 'Nenhum perfil corresponde a "{{query}}"', + profileCount: '{{count}} perfil', + profileCount_other: '{{count}} perfis', + configuredCount: '{{count}} configurado(s)', + deleteProfileTitle: 'Excluir Perfil', + deleteProfileDesc: + 'Tem certeza de que deseja excluir "{{name}}"? Isso removerá o arquivo de configurações e não pode ser desfeito.', + delete: 'Excluir', + unsavedChangesTitle: 'Alterações Não Salvas', + unsavedChangesDesc: + 'Você tem alterações não salvas em "{{current}}". Descartar e trocar para "{{next}}"?', + discardSwitch: 'Descartar e Trocar', + noOrphansFound: 'Nenhuma configuração de perfil órfã encontrada', + confirmRegisterOrphans: + 'Encontrado(s) {{total}} arquivo(s) de configuração de perfil órfão(s), com {{valid}} pronto(s) para registrar. Registrar agora?', + registeredWithSkipped: ' ({{count}} ignorado(s))', + registeredProfiles: '{{count}} perfil(is) registrado(s)', + copyPrompt: 'Copiar "{{name}}" para qual novo nome de perfil?', + destinationEmpty: 'O nome do perfil de destino não pode estar vazio', + exportRedacted: + 'Exportação criada com o token oculto. Use o fluxo include-secrets na CLI se necessário.', + exportDownloaded: 'Exportação do perfil baixada', + importFailed: 'Falha ao importar o pacote de perfil', + discoverOrphans: 'Descobrir perfis órfãos', + importProfileBundle: 'Importar pacote de perfil', + }, + accountsPage: { + title: 'Contas', + managePrefix: 'Gerencie', + manageSuffix: 'contas e integração de pool a partir de um único painel.', + mobileManageSuffix: 'continuidade por conta.', + primaryActions: 'Ações Principais', + createAccount: 'Criar Conta', + authClaudeInPool: 'Autenticar Claude no Pool', + openClaudePoolSettings: 'Abrir Configurações do Pool Claude', + openCliProxyClaudePool: 'Abrir Pool Claude do CLIProxy', + migrationFollowup: 'Acompanhamento de Migração', + legacyContextPending: + '{{count}} conta ainda precisa de confirmação de modo pela primeira vez.', + legacyContextPending_other: + '{{count}} contas ainda precisam de confirmação de modo pela primeira vez.', + legacyContinuityPending: + '{{count}} conta compartilhada permanece na profundidade de continuidade legada padrão.', + legacyContinuityPending_other: + '{{count}} contas compartilhadas permanecem na profundidade de continuidade legada padrão.', + confirmingLegacy: 'Confirmando Políticas Legadas...', + confirmLegacy: 'Confirmar Políticas Legadas ({{count}})', + noLegacyFollowup: 'Nenhum acompanhamento legado pendente.', + continuityGuide: 'Guia de Continuidade', + expandWhenNeeded: 'Expanda apenas quando necessário.', + sharedStandard: 'Compartilhado Padrão', + sharedStandardDesc: + 'Apenas workspace compartilhado. Coloque as duas contas no mesmo grupo antes de esperar qualquer handoff.', + sharedDeeper: 'Compartilhado Profundo', + sharedDeeperPrefix: 'Adiciona', + sharedDeeperDesc: + 'Adiciona session-env, file-history, shell-snapshots e todos. Recomendado quando os usuários esperam retomada entre contas.', + isolated: 'Isolado', + isolatedDesc: 'Sem vínculo. As conversas permanecem com a conta original.', + quickCommands: 'Comandos Rápidos', + quickCommandsDesc: 'Copie e execute no terminal.', + workspaceBadge: 'Workspace ccs auth', + historySyncBadge: 'Controles de Histórico e Retomada', + resourcesBadge: 'Controles de Recursos Compartilhados', + resourcesShared: 'Compartilhado', + resourcesProfileLocal: 'Local do Perfil', + authAccounts: 'Contas de Autenticação', + tableScopePrefix: 'Esta tabela é intencionalmente restrita a', + tableScopeMiddle: 'contas. Use', + tableScopeSuffix: 'para alterações de modo/grupo/profundidade.', + accountMatrix: 'Matriz de Contas', + sharedTotalDesc: + 'Total compartilhado: {{count}}. As ações incluem configurações de Sincronização e confirmação legada.', + loadingAccounts: 'Carregando contas...', + }, + health: { + justNow: 'agora mesmo', + secondsAgo: 'há {{count}}s', + minutesAgo: 'há {{count}}min', + hoursAgo: 'há {{count}}h', + systemHealth: 'Saúde do Sistema', + build: 'build {{version}}', + lastScan: 'Última verificação:', + autoRefresh: 'Atualização automática:', + refresh: 'Atualizar', + checks: 'Verificações', + version: 'Versão', + platform: 'Plataforma', + liveMonitoring: 'Monitoramento ao vivo ativo', + copied: 'Copiado para a área de transferência', + refreshing: 'Atualizando verificações de saúde...', + fix: 'Corrigir', + applyFix: 'Aplicar Correção', + healthLabel: 'saúde', + }, + updates: { + actionRequired: 'Ação Necessária', + done: 'Concluído', + all: 'Todos', + inboxTitle: 'Central de Atualizações', + inboxSubtitle: + 'Concentre-se nas ações e depois marque as atualizações como concluídas ou dispensadas.', + needsAction: 'Precisa de Ação', + doneCount: 'Concluído', + searchPlaceholder: 'Buscar ações ou comandos', + noNotices: 'Nenhum aviso corresponde a esta visualização.', + noUpdates: 'Nenhuma atualização disponível.', + published: 'Publicado em {{date}}', + markDone: 'Marcar como concluído', + dismiss: 'Dispensar', + reopen: 'Reabrir', + doNext: 'Fazer a Seguir', + impactedIntegrations: 'Integrações Impactadas', + impactedDesc: 'Áreas relacionadas com base no escopo e no roteamento da atualização.', + whyMatters: 'Por Que Importa', + whyMattersDesc: 'Apenas contexto curto, sem notas de lançamento extensas.', + open: 'Abrir', + progressNeedsAction: 'Precisa de Ação', + progressInReview: 'Em Revisão', + progressDone: 'Concluído', + progressDismissed: 'Dispensado', + statusNew: 'Novo', + statusStable: 'Estável', + statusPlanned: 'Planejado', + dashboard: 'Painel', + cliUsage: 'Uso via CLI', + }, + cliproxyPage: { + connected: 'Conectado', + notConnected: 'Não conectado', + variant: 'variante', + parentNotConnected: 'Pai não conectado', + viaProvider: 'via {{provider}}', + emptyTitle: 'Gerenciador de Perfis CCS', + emptyDesc: + 'Gerencie autenticação OAuth, preferências de conta e seleção de modelo para provedores do CLIProxy. Configure como o CCS roteia solicitações para diferentes back-ends de IA.', + emptyControlPanelPrefix: + 'Para estatísticas de uso ao vivo e monitoramento em tempo real, visite o', + controlPanel: 'Painel de Controle', + quickSetup: 'Configuração Rápida', + addAccount: 'Adicionar Conta', + advancedVariant: 'Variante Avançada', + setupActionsHint: + 'Adicione contas aqui. Crie variantes apenas quando precisar de um runtime/perfil separado.', + accountManagement: 'Gerenciamento de contas em nível de CCS', + providers: 'Provedores', + variants: 'Variantes', + providerCount: '{{count}} provedor', + providerCount_other: '{{count}} provedores', + connectedCount: '{{count}} conectado(s)', + variantDisplay: '{{name}} (variante de {{provider}})', + }, + copilotPage: { + title: 'Copilot', + subtitle: 'Ponte de compatibilidade descontinuada do GitHub Copilot', + deprecatedBadge: 'Descontinuado', + deprecationTitle: 'Ponte de compatibilidade descontinuada', + deprecationItem1: 'A cobrança do Copilot por uso do GitHub começa em 1º de junho de 2026', + deprecationItem2: + 'As configurações locais existentes continuam disponíveis para compatibilidade', + deprecationItem3: 'Prefira o Codex ou outro provedor ativo para trabalhos novos', + setup: 'Configuração', + auth: 'Autenticação', + daemon: 'Daemon', + unofficialTitle: 'API Não Oficial - Use por Sua Conta e Risco', + unofficialItem1: 'API de engenharia reversa - pode quebrar a qualquer momento', + unofficialItem2: 'O uso excessivo pode acionar restrições na conta', + unofficialItem3: 'Sem garantia, sem responsabilidade do CCS', + yes: 'Sim', + no: 'Não', + installed: 'Instalado', + missing: 'Ausente', + integration: 'Integração', + enabled: 'Habilitado', + disabled: 'Desabilitado', + github: 'GitHub', + connected: 'Conectado', + notConnected: 'Não Conectado', + authenticate: 'Autenticar', + authenticating: 'Autenticando...', + status: 'Status', + running: 'Em execução', + stopped: 'Parado', + port: 'Porta', + stopping: 'Parando...', + stop: 'Parar', + starting: 'Iniciando...', + start: 'Iniciar', + installing: 'Instalando...', + installCopilotApi: 'Instalar copilot-api', + proxy: 'Proxy', + active: 'Ativo', + inactive: 'Inativo', + }, + cursorPage: { + title: 'Cursor', + beta: 'Beta', + deprecated: 'Descontinuado', + subtitle: 'Ponte local descontinuada para configurações legadas do Cursor', + unofficialTitle: 'Ponte legada descontinuada - prefira o Cursor com suporte do CLIProxy', + unofficialItem1: + 'Caminho suportado: autenticação e gerenciamento de conta do Cursor com suporte do CLIProxy', + unofficialItem2: + 'Esta ponte local de engenharia reversa permanece apenas para configurações existentes', + unofficialItem3: 'O CCS não oferece garantia para este caminho legado', + supportedPathTitle: 'Caminho suportado', + supportedPathDesc: + 'Use a autenticação e o gerenciamento de conta do Cursor com suporte do CLIProxy para novas configurações. Mantenha a ponte legada abaixo apenas se você ainda depender do fluxo antigo do daemon local.', + startCliproxyAuth: 'Iniciar Autenticação do Cursor via CLIProxy', + openCliproxyCursor: 'Abrir Cursor via CLIProxy', + integration: 'Integração', + authentication: 'Autenticação', + daemon: 'Daemon', + enabled: 'Habilitado', + disabled: 'Desabilitado', + expired: 'Expirado', + connected: 'Conectado', + notConnected: 'Não conectado', + running: 'Em execução', + stopped: 'Parado', + actions: 'Ações Legadas', + disableIntegration: 'Desabilitar Integração', + enableIntegration: 'Habilitar Integração', + autoDetectAuth: 'Detecção Automática Legada da IDE', + manualAuthImport: 'Importação Manual Legada', + stopDaemon: 'Parar Daemon', + startDaemon: 'Iniciar Daemon', + port: 'Porta', + configuration: 'Configuração do Cursor', + lastModified: 'Última modificação:', + neverSaved: 'Nunca salvo', + saving: 'Salvando...', + save: 'Salvar', + modelConfig: 'Configuração de Modelo', + settings: 'Configurações', + info: 'Informações', + presets: 'Presets', + presetsDesc: 'Aplicar mapeamentos de modelo pré-configurados', + modelMapping: 'Mapeamento de Modelo', + modelMappingDesc: 'Configure quais modelos usar para cada nível', + defaultModel: 'Modelo Padrão', + defaultModelDesc: 'Usado quando nenhum nível específico é solicitado.', + opusModel: 'Opus (Mais capaz)', + opusModelDesc: 'Raciocínio complexo e respostas da mais alta qualidade.', + sonnetModel: 'Sonnet (Equilibrado)', + sonnetModelDesc: 'Codificação geral e cargas de trabalho de chat do dia a dia.', + haikuModel: 'Haiku (Rápido)', + haikuModelDesc: 'Caminhos de solicitação de baixa latência e leves.', + runtimeSettings: 'Configurações de Runtime', + autoStartDaemon: 'Iniciar Daemon Automaticamente', + autoStartDesc: 'Iniciar o daemon do Cursor automaticamente quando a integração for usada.', + ghostMode: 'Modo Fantasma', + ghostModeDesc: 'Solicitar x-ghost-mode para reduzir a telemetria.', + provider: 'Provedor', + filePath: 'Caminho do Arquivo', + availableModels: 'Modelos Disponíveis', + loadingModels: 'Carregando modelos...', + noModels: 'Nenhum metadado de modelo disponível ainda. Inicie o daemon e atualize.', + default: 'Padrão', + rawConfiguration: 'Configuração Bruta (JSON)', + manualImportTitle: 'Importação Manual de Autenticação do Cursor', + manualImportDesc: + 'Forneça o token de acesso e o ID da máquina do Cursor se a detecção automática estiver indisponível.', + accessToken: 'Token de Acesso', + accessTokenPlaceholder: 'Cole o token de acesso do Cursor', + machineId: 'ID da Máquina', + machineIdPlaceholder: 'hex de 32 caracteres (UUID sem hifens)', + cancel: 'Cancelar', + import: 'Importar', + selectModel: 'Selecionar modelo', + useDefaultModel: 'Usar Modelo Padrão', + availableModelCount: 'Modelos Disponíveis ({{count}})', + invalidPort: 'A porta deve ser um número inteiro entre 1 e 65535', + defaultModelRequired: 'O modelo padrão é obrigatório', + savedConfig: 'Configuração do Cursor e mapeamento de modelo salvos', + failedSaveConfig: 'Falha ao salvar a configuração', + modelsLoadingWait: 'Os modelos ainda estão carregando. Aguarde antes de aplicar um preset.', + noModelsAvailable: 'Nenhum modelo disponível ainda. Inicie o daemon e atualize primeiro.', + appliedCodexPreset: 'Preset GPT-5.3 Codex aplicado', + appliedClaudePreset: 'Preset Claude 4.6 aplicado', + appliedGeminiPreset: 'Preset Gemini 3 aplicado', + integrationEnabled: 'Integração do Cursor habilitada', + integrationDisabled: 'Integração do Cursor desabilitada', + failedUpdateIntegration: 'Falha ao atualizar o estado da integração', + credentialsImported: 'Credenciais do Cursor importadas', + autoDetectFailed: 'Falha na detecção automática', + manualRequired: 'Token e ID da máquina são obrigatórios', + manualImportFailed: 'Falha na importação manual', + failedStartDaemon: 'Falha ao iniciar o daemon', + daemonStarted: 'Daemon iniciado', + daemonStartedWithPid: 'Daemon iniciado (PID: {{pid}})', + failedStopDaemon: 'Falha ao parar o daemon', + daemonStopped: 'Daemon parado', + rawLoading: 'As configurações brutas ainda estão carregando. Aguarde e tente novamente.', + invalidJson: 'JSON inválido', + rawSaved: 'Configurações brutas salvas', + rawChanged: + 'As configurações brutas foram alteradas externamente. Atualize e tente novamente.', + failedSaveRaw: 'Falha ao salvar as configurações brutas', + savedAll: 'Configuração do Cursor salva', + liveProbe: 'Teste ao Vivo', + runLiveProbe: 'Executar Teste ao Vivo', + rerunLiveProbe: 'Executar Teste ao Vivo Novamente', + probing: 'Executando teste...', + probeNotRun: 'Teste ainda não executado', + probeLocalReadinessHint: + 'As verificações de prontidão local podem passar mesmo com o acesso ao runtime ao vivo ainda falhando. Use o teste ao vivo para verificar o caminho completo do Cursor.', + probeStage: 'Estágio', + probeHttpStatus: 'Status HTTP', + probeDuration: 'Duração', + probeModel: 'Modelo', + probeMessage: 'Mensagem', + probeSucceeded: 'Teste bem-sucedido', + probeFailed: 'Teste falhou', + probeSaveFirst: + 'Salve ou descarte as alterações do Cursor antes de executar o teste ao vivo', + refreshStatus: 'Atualizar status do Cursor', + refreshConfiguration: 'Atualizar configuração do Cursor', + }, + droidPage: { + loadingDiagnostics: 'Carregando diagnósticos do Droid...', + failedDiagnostics: 'Falha ao carregar os diagnósticos do Droid.', + overview: 'Visão Geral', + byok: 'BYOK', + docs: 'Docs', + runtimeInstall: 'Runtime e Instalação', + status: 'Status', + detected: 'Detectado', + notFound: 'Não Encontrado', + detectionSource: 'Origem da detecção', + binaryPath: 'Caminho do binário', + installDirectory: 'Diretório de instalação', + version: 'Versão', + overridePath: 'Substituição (CCS_DROID_PATH)', + configFiles: 'Arquivos de Configuração', + path: 'Caminho', + resolved: 'Resolvido', + size: 'Tamanho', + lastModified: 'Última modificação', + parseWarning: 'Aviso de análise: {{value}}', + readWarning: 'Aviso de leitura: {{value}}', + warnings: 'Avisos', + byokSummary: 'Resumo BYOK', + activeModelSelector: 'Seletor de modelo ativo', + customModels: 'Modelos personalizados', + ccsManaged: 'Gerenciado pelo CCS', + userManaged: 'Gerenciado pelo usuário', + malformedEntries: 'Entradas malformadas', + providers: 'Provedores', + none: 'nenhum', + customModelsTitle: 'Modelos Personalizados', + modelName: 'Nome / Modelo', + provider: 'Provedor', + baseUrl: 'URL Base', + noCustomModels: 'Nenhum modelo personalizado', + docsAlignedNotes: 'Notas Alinhadas à Documentação', + factoryDocs: 'Documentação da Factory', + providerFactCheckDocs: 'Documentação de Verificação de Provedores', + providerValues: 'Valores do provedor: {{value}}', + settingsHierarchy: 'Hierarquia de configurações: {{value}}', + settingsTitle: 'Configurações BYOK do Droid', + fixJsonBeforeQuickSettings: + 'Corrija a sintaxe JSON antes de usar os controles de configuração rápida.', + fixJsonBeforeReasoning: + 'Corrija a sintaxe JSON antes de atualizar as configurações de raciocínio BYOK.', + unableUpdateReasoning: + 'Não foi possível atualizar a configuração de raciocínio do modelo BYOK selecionado.', + fixJsonBeforeBudget: 'Corrija a sintaxe JSON antes de atualizar o orçamento de pensamento.', + anthropicOnlyBudget: + 'O orçamento de pensamento está disponível apenas para modelos Anthropic BYOK.', + invalidJson: 'JSON inválido: {{value}}', + saved: 'Configurações do Droid salvas', + changedExternally: + 'As configurações do Droid foram alteradas externamente. Atualize e tente novamente.', + failedSave: 'Falha ao salvar as configurações do Droid', + }, + sharedPage: { + commands: 'Comandos', + skills: 'Habilidades', + agents: 'Agentes', + plugins: 'Plugins', + settings: 'Configurações', + title: 'Recursos Compartilhados', + subtitle: + 'Comandos, habilidades, agentes, plugins e configurações compartilhados entre contas', + totalShared: 'Total de Recursos', + visible: 'Visível', + markdownDetail: 'Visualização detalhada em markdown', + registryDetail: 'Visualização detalhada do registro', + maskedSettingsDetail: 'Visualização de configurações mascaradas', + filterPrefix: 'Filtro:', + configurationRequired: 'Configuração Necessária', + countsUnavailable: 'Contagens indisponíveis', + retryCounts: 'Tentar contagens novamente', + filterPlaceholder: 'Filtrar {{tab}} por nome, descrição ou caminho', + showing: 'Mostrando {{visible}} de {{total}} {{tab}}', + showingQuery: ' para "{{query}}"', + refreshing: ' (atualizando...)', + loadingShared: 'Carregando {{tab}} compartilhados...', + failedLoadShared: 'Falha ao carregar {{tab}} compartilhados', + retry: 'Tentar Novamente', + noSharedFound: + 'Nenhum {{tab}} compartilhado encontrado. Execute `ccs sync` ou adicione itens no seu diretório compartilhado.', + noPluginsFound: + 'Nenhum plugin compartilhado está instalado. Os arquivos de registro de plugins e as pastas de cache ficam ocultos até que um plugin real esteja presente.', + noMatch: 'Nenhum {{tab}} corresponde a "{{query}}".', + selectOne: 'Selecione um {{tab}} para ver o conteúdo completo.', + pathLabel: 'Caminho', + resolvedSource: 'Origem Resolvida', + loadingMarkdown: 'Carregando conteúdo markdown...', + loadingSettings: 'Carregando conteúdo de configurações...', + failedLoadContent: 'Falha ao carregar o conteúdo', + retryContent: 'Tentar conteúdo novamente', + noMarkdown: 'Nenhum conteúdo markdown disponível.', + settingsTitle: 'settings.json', + settingsDescription: + 'Este arquivo contém configurações compartilhadas vinculadas entre todas as contas no modo compartilhado.', + settingsPath: 'Caminho das Configurações Compartilhadas', + settingsActive: 'Ativo', + settingsInactive: 'Não Encontrado', + resourcePolicies: 'Políticas de recursos', + resourcePoliciesDescription: + 'Contas marcadas como compartilhadas herdam este hub. Contas locais de perfil mantêm comandos, habilidades, agentes, plugins e configurações separados.', + sharedAccounts: '{{count}} compartilhada(s)', + profileLocalAccounts: '{{count}} local(is) de perfil', + moreProfileLocalAccounts: '+{{count}} mais', + }, + editAccountSharedResources: { + title: 'Editar Recursos Compartilhados', + description: + 'Configure como "{{name}}" herda comandos, habilidades e configurações do diretório compartilhado do CCS.', + resourceMode: 'Modo de Recurso', + selectResourceMode: 'Selecione o modo de recurso', + sharedOption: 'Compartilhado', + sharedHint: 'Herda comandos, habilidades e agentes do diretório compartilhado.', + profileLocalOption: 'Local do Perfil', + profileLocalHint: 'Usa recursos isolados no diretório privado da conta.', + implicationTitle: 'O que isso significa', + sharedImplication: + 'Esta conta verá todas as ferramentas e configurações no hub de recursos compartilhados.', + profileLocalImplication: + 'Esta conta não verá ferramentas compartilhadas. Ela permanece totalmente isolada por segurança ou testes.', + cancel: 'Cancelar', + save: 'Salvar', + saving: 'Salvando...', + }, + + // ======================================== + // Domain 1: Navigation / Layout / Shared + // ======================================== + heroSection: { + title: 'CCS Config', + subtitle: 'Painel do Claude Code Switch', + }, + hubFooter: { + logs: 'Logs', + settings: 'Configurações', + github: 'GitHub', + copyright: '© {{year}} kaitranntt', + }, + themeToggle: { + srLabel: 'Alternar tema', + }, + ccsLogo: { + alt: 'Logo do CCS', + text: 'CCS Config', + }, + claudekitBadge: { + title: 'Desenvolvido com o Framework ClaudeKit', + alt: 'ClaudeKit', + poweredBy: 'Desenvolvido com', + claudekit: 'ClaudeKit', + }, + codeEditor: { + revealSensitive: 'Revelar valores sensíveis', + maskSensitive: 'Ocultar valores sensíveis', + valid: '{{language}} válido', + readOnly: '(Somente leitura)', + }, + commandBuilder: { + title: 'Construtor de Comandos', + searchPlaceholder: 'Digite ou selecione um comando...', + copy: 'Copiar', + run: 'Executar', + cmdConfig: 'Abrir interface de configuração', + cmdCreateProfile: 'Criar um novo perfil', + cmdSwitchProfile: 'Trocar para um perfil', + cmdDoctor: 'Verificar a saúde do sistema', + cmdListProviders: 'Listar provedores do CLIProxy disponíveis', + cmdAddProvider: 'Adicionar provedor do CLIProxy', + }, + confirmDialog: { + confirm: 'Confirmar', + cancel: 'Cancelar', + }, + connectionIndicator: { + connected: 'Conectado', + connecting: 'Conectando...', + disconnected: 'Desconectado', + reconnecting: 'Reconectando...', + }, + docsLink: { + title: 'Ver documentação', + }, + githubLink: { + title: 'Reportar um problema no GitHub', + }, + ccsBarButton: { + title: 'Abrir documentação do CCS Bar', + label: 'CCS Bar', + tooltip: 'Controles nativos de cota, custo e conta na barra de menu do macOS', + }, + globalEnvIndicator: { + injectedCount_one: + '{{count}} variável de ambiente global será injetada em tempo de execução', + injectedCount_other: + '{{count}} variáveis de ambiente globais serão injetadas em tempo de execução', + overriddenCount: '({{count}} substituída(s) pelo perfil)', + skippedLabel: 'Ignorada (o perfil já define):', + configureInSettings: 'Configurar em Configurações', + }, + localhostDisclaimer: { + remoteReadonlyAuthDisabledLong: + 'O acesso remoto ao painel é somente leitura porque a autenticação do painel está desabilitada no momento para esta instância do CCS. Reative-a no host do CCS. Implantações Docker devem fazer isso dentro do container em execução.', + remoteReadonlyAuthDisabledShort: + 'O painel remoto é somente leitura até que a autenticação do painel seja reativada para esta instância do CCS.', + remoteReadonlySetupLong: + 'O acesso remoto ao painel é somente leitura até que você execute ccs config auth setup para esta instância do CCS. Implantações Docker devem executá-lo dentro do container.', + remoteReadonlySetupShort: + 'O painel remoto é somente leitura até que a autenticação seja configurada para esta instância do CCS.', + localLong: 'Este painel roda localmente. Todos os dados permanecem na sua máquina.', + localShort: 'Painel local - os dados permanecem no seu dispositivo.', + dismiss: 'Dispensar aviso', + }, + privacyToggle: { + modeOn: 'Modo de privacidade ATIVADO - Clique para mostrar os dados', + modeOff: 'Modo de privacidade DESATIVADO - Clique para ocultar os dados', + }, + projectSelectionDialog: { + title: 'Selecionar Projeto do Google Cloud', + description: 'Escolha qual projeto usar para a autenticação do {{provider}}.', + autoSelectCountdown: '(Selecionando o padrão automaticamente em {{count}}s)', + default: 'Padrão', + allProjects: 'Todos os Projetos', + allProjectsDescription: 'Integrar todos os {{count}} projetos listados', + useDefault: 'Usar Padrão', + selecting: 'Selecionando...', + confirmSelection: 'Confirmar Seleção', + codeCopied: 'Código copiado', + copyVerificationCode: 'Copiar código de verificação', + }, + quickCommands: { + title: 'Comandos Rápidos', + startDefault: 'Iniciar Padrão', + startDefaultDesc: 'Iniciar o Claude com o perfil padrão', + glmProfile: 'Perfil GLM', + glmProfileDesc: 'Trocar para o modelo GLM', + healthCheck: 'Verificação de Saúde', + healthCheckDesc: 'Executar diagnósticos do sistema', + delegateTask: 'Delegar Tarefa', + delegateTaskDesc: 'Delegar para o perfil GLM', + }, + quotaTooltip: { + loadingQuota: 'Carregando cota...', + failedLoadQuota: 'Falha ao carregar a cota', + modelQuotas: 'Cotas de Modelo:', + rateLimits: 'Limites de Taxa:', + plan: 'Plano: {{plan}}', + quotaSnapshots: 'Instantâneos de Cota:', + unlimited: 'Ilimitado', + remaining: '{{remaining}}/{{entitlement}} restante(s)', + tier: 'Nível', + tierId: 'ID do Nível', + state: 'Estado', + credits: 'Créditos', + modelQuotasLower: 'Cotas de modelo:', + allBucketsReport: 'Todos os buckets reportam {{tokenType}}', + requestsRemaining: '{{count}} solicitações restantes', + inputTokensRemaining: '{{count}} tokens de entrada restantes', + outputTokensRemaining: '{{count}} tokens de saída restantes', + amountRemaining: '{{count}} restante(s)', + fiveHourLimit: 'Limite de uso de 5h', + weeklyLimit: 'Limite de uso semanal', + weeklyOpus: 'Uso semanal (Opus)', + weeklySonnet: 'Uso semanal (Sonnet)', + weeklyOAuthApps: 'Uso semanal (apps OAuth)', + weeklyCowork: 'Uso semanal (Cowork)', + extraUsage: 'Uso extra', + premiumInteractions: 'Interações Premium', + chat: 'Chat', + completions: 'Completions', + resets: 'Reinicia {{time}}', + fiveHourResets: 'Reinício de 5h {{time}}', + weeklyResets: 'Reinício semanal {{time}}', + }, + utils: { + codeReview5h: 'Revisão de código (5h)', + codeReviewWeekly: 'Revisão de código (semanal)', + codeReview: 'Revisão de código', + codexAdditional5h: '{{name}} (5h)', + codexAdditionalWeekly: '{{name}} (semanal)', + codexAdditional: '{{name}}', + }, + sponsorButton: { + title: 'Patrocinar este projeto no GitHub', + sponsor: 'Patrocinar', + }, + valueMetrics: { + apiCostSaved: 'Custo de API Economizado', + tokensSaved: 'Tokens Economizados', + queriesFaster: 'Consultas Mais Rápidas', + errorsReduced: 'Erros Reduzidos', + vsLastMonth: 'em relação ao mês passado', + throughCaching: 'através de cache', + averageSpeedup: 'aceleração média', + withRetryLogic: 'com lógica de nova tentativa', + performanceMetrics: 'Métricas de Desempenho', + monthlySummary: 'Resumo Mensal', + totalSaved: 'Total Economizado', + tokensProcessed: 'Tokens Processados', + queriesHandled: 'Consultas Atendidas', + uptime: 'Tempo de Atividade', + }, + updatesSpotlight: { + openUpdatesCenter: 'Abrir Central de Atualizações', + }, + deviceCodeDialog: { + authorize: 'Autorizar {{provider}}', + enterCodeAtPage: 'Digite o código abaixo na página de autorização.', + expiresIn: '(Expira em {{time}})', + codeExpired: '(Código expirado)', + copied: 'Copiado!', + copyCode: 'Copiar Código', + waitingForAuth: 'Aguardando autorização...', + openVerificationPage: 'Abrir página de verificação', + openProviderPage: 'Abrir {{provider}}', + copyCodeAria: 'Copiar código de verificação', + codeCopiedAria: 'Código copiado', + }, + settingsDialog: { + editProfile: 'Editar Perfil: {{name}}', + description: 'Configure variáveis de ambiente e configurações para este perfil.', + loadingSettings: 'Carregando configurações...', + envTab: 'Ambiente', + rawJsonTab: 'JSON Bruto', + generalTab: 'Geral', + noEnvVars: 'Nenhuma variável de ambiente configurada.', + noEnvVarsHint: 'Adicione variáveis no seu arquivo settings.json.', + loadingEditor: 'Carregando editor...', + profileInfo: 'Informações do Perfil', + profileInfoDesc: 'Detalhes sobre este arquivo de configuração.', + path: 'Caminho', + lastModified: 'Última Modificação', + cancel: 'Cancelar', + saving: 'Salvando...', + saveChanges: 'Salvar Alterações', + conflictTitle: 'Arquivo Modificado Externamente', + conflictDesc: + 'Este arquivo de configurações foi modificado por outro processo. Sobrescrever com suas alterações ou descartar?', + overwrite: 'Sobrescrever', + invalidJson: 'JSON inválido', + failedSave: 'Falha ao salvar', + }, + + // ======================================== + // Domain 2: Accounts / Auth + // ======================================== + setupWizard: { + title: 'Configuração de Variante Avançada', + stepProviderDesc: 'Selecione um provedor para a variante de execução', + stepAuthDesc: 'Autentique-se com seu provedor', + stepAccountDesc: 'Selecione qual conta usar', + stepVariantDesc: 'Opcional: criar uma variante de execução personalizada', + stepSuccessDesc: 'Configuração concluída!', + authStep: { + authenticateWith: 'Autentique-se com {{provider}} para adicionar uma conta', + authenticating: 'Autenticando...', + authenticateInBrowser: 'Autenticar no Navegador', + completeOAuth: 'Complete o fluxo OAuth no seu navegador...', + orUseTerminal: 'Ou use o terminal', + runCommandHint: 'Execute este comando no seu terminal:', + back: 'Voltar', + checking: 'Verificando...', + refreshStatus: 'Atualizar Status', + }, + accountStep: { + selectAccount: 'Selecione uma conta ({{count}})', + defaultAccount: 'Conta padrão', + or: 'Ou', + addNewAccount: 'Adicionar nova conta', + addNewAccountDesc: 'Autentique-se com uma conta diferente', + back: 'Voltar', + }, + variantStep: { + back: 'Voltar', + skip: 'Pular', + }, + successStep: { + title: 'Variante Criada!', + subtitle: 'Sua variante personalizada está pronta para uso', + usage: 'Uso:', + done: 'Concluído', + }, + }, + accountSurfaceCard: { + business: 'Emp', + free: 'Grátis', + personal: 'Pess', + variant: 'Variante', + }, + accountIdentity: { + business: 'Empresarial', + personal: 'Pessoal', + free: 'Gratuito', + team: 'Equipe', + plus: 'Plus', + pro: 'Pro', + workspace: 'Workspace {{id}}', + }, + accountCardStats: { + notUsedYet: 'Ainda não usado', + }, + accountQuotaPanel: { + weekly: 'Semanal', + loadingQuota: 'Carregando cota...', + }, + userMenu: { + signedInAs: 'Conectado como {{username}}', + }, + authMonitorLive: { + live: 'AO VIVO', + accountMonitor: 'Monitor de Contas', + updated: 'Atualizado {{time}}', + updatedNow: 'Atualizado agora', + requestsLabel: 'req', + stats: 'Estatísticas', + successRate: 'Taxa de Sucesso', + missingProjectId: 'ID do Projeto Ausente', + noActivity: 'sem atividade', + }, + providerCard: { + missingProjectIdAria: 'ID do Projeto Ausente', + }, + loginPage: { + showPassword: 'Mostrar senha', + hidePassword: 'Ocultar senha', + }, + + // ======================================== + // Domain 3: CLIProxy / Provider Editor + // ======================================== + cliproxyStatsOverview: { + sessionStatistics: 'Estatísticas da Sessão', + realTimeMetrics: 'Métricas de uso em tempo real do {{backend}}', + offline: 'Offline', + running: 'Em execução', + noActiveSession: 'Nenhuma Sessão Ativa', + noActiveSessionHint: + 'Inicie uma sessão do CLIProxy usando ccs gemini, ccs codex ou ccs agy para ver estatísticas em tempo real.', + failedLoadStats: 'Falha ao Carregar Estatísticas', + totalRequests: 'Total de Solicitações', + successCount: '{{count}} com sucesso', + successRate: 'Taxa de Sucesso', + totalTokens: 'Total de Tokens', + estimatedCost: '~${{cost}} estimado', + modelsUsed: 'Modelos Usados', + modelUsageDistribution: 'Distribuição de Uso de Modelos', + requestCount: '{{count}} solicitações', + }, + cliproxyTable: { + name: 'Nome', + provider: 'Provedor', + model: 'Modelo', + account: 'Conta', + status: 'Status', + default: 'Padrão', + actions: 'Ações', + }, + cliproxyTabs: { + overview: 'Visão Geral', + variants: 'Variantes', + aiProviders: 'Provedores de IA', + controlPanel: 'Painel de Controle', + }, + cliproxyHeader: { + ccsLevelAccountManagement: 'Gerenciamento de contas em nível de CCS', + cliproxyNotAvailable: 'CLIProxy Não Disponível', + cliproxyControlPanel: 'Painel de Controle do CLIProxy', + noVariants: 'Nenhuma variante do CLIProxy encontrada.', + addAccountToStart: 'Adicione uma conta para começar', + }, + routingGuidance: { + roundRobin: 'Round robin distribui o uso.', + fillFirst: 'Fill first mantém as contas de backup inativas até que sejam necessárias.', + routingStrategy: 'Estratégia de roteamento', + poolRouting: 'Roteamento de pool', + poolRoutingOn: 'Ligado', + poolRoutingOff: 'Desligado', + poolRoutingManaged: + 'O roteamento de pool está ligado: o CCS gerencia a estratégia (fill-first), a afinidade de sessão e o resfriamento para todo o proxy.', + poolRoutingOffHint: + 'O roteamento de pool está desligado. Ative-o pela CLI (ccs cliproxy pool --enable) para drenagem fill-first com resfriamento.', + poolRoutingApplyWarning: + 'O roteamento de pool está ligado. As alterações de estratégia e afinidade de sessão não terão efeito até que você o desative: ccs cliproxy pool --disable', + poolRoutingLocalOnly: + 'O roteamento de pool é gerenciado localmente; este proxy remoto pode não refletir isso.', + poolMaxRetry: 'Máximo de tentativas {{count}}', + drainOrderHint: + 'Ordem de drenagem por conta e estado de resfriamento: execute ccs cliproxy quota ou ccs cliproxy accounts order .', + optionalRouting: 'Roteamento opcional', + sessionAffinity: 'Afinidade de sessão', + sessionAffinityOn: 'Ligada', + sessionAffinityOff: 'Desligada', + sessionAffinityEnabled: 'Habilitada', + sessionAffinityDisabled: 'Desabilitada', + enableSessionAffinity: 'Habilitar afinidade de sessão', + disableSessionAffinity: 'Desabilitar afinidade de sessão', + sessionAffinityUnavailable: 'Afinidade de sessão indisponível', + localOnly: 'Somente local', + localOnlySetting: 'Configuração somente local', + ttlBadge: 'TTL {{ttl}}', + sessionAffinityDescription: + 'Mantenha uma conversa fixada na mesma conta quando possível. O CLIProxy prefere identificadores explícitos de sessão ou thread quando os clientes os enviam, depois recorre a metadados da solicitação ou ao histórico do prompt inicial quando precisa inferir uma chave estável.', + sessionRecognitionTitle: 'Reconhecimento de sessão', + sessionRecognitionDescription: + 'O CCS não promete uma ordem de precedência universal aqui. Na prática, os back-ends upstream preferem IDs explícitos de sessão ou thread primeiro, depois recorrem a campos de metadados e, por fim, a um hash baseado no histórico do prompt inicial.', + retryTitle: 'Nova tentativa automática', + retryHint: 'Tentativas / espera máxima (s)', + requestRetryLabel: 'Tentativas da solicitação', + maxRetryIntervalLabel: 'Intervalo máximo entre tentativas (segundos)', + retryRangeError: 'Deve ser um número inteiro igual ou maior que 0.', + }, + extendedContext: { + extendedContext: 'Contexto Estendido', + }, + cliproxyConfig: { + unsavedChanges: 'Alterações não salvas', + original: 'Original', + modified: 'Modificado', + reviewChanges: 'Revisar Alterações', + loadingEditor: 'Carregando editor...', + }, + providerEditor: { + provider: 'Provedor', + filePath: 'Caminho do Arquivo', + lastModified: 'Última Modificação', + defaultTarget: 'Destino Padrão', + quickUsage: 'Uso Rápido', + modelMapping: 'Mapeamento de Modelo', + status: 'Status', + loadingSettings: 'Carregando configurações...', + loadingEditor: 'Carregando editor...', + accounts: 'Contas', + addAccount: 'Adicionar Conta', + noAccountsConnected: 'Nenhuma conta conectada', + addAccountToStart: 'Adicione uma conta para começar', + gcpProjectIdReadonly: 'ID do Projeto GCP (somente leitura)', + projectIdNA: 'ID do Projeto: N/D', + missingProjectId: 'ID do Projeto Ausente', + missingProjectIdHint: + 'Isso pode causar erros. Remova a conta e adicione-a novamente para buscar o ID do projeto.', + useIncognito: 'Usar modo anônimo', + aliases: 'Aliases', + current: 'Atual', + currentValue: 'Valor atual', + composite: 'composto', + defaultLabel: 'padrão', + requiredSetup: 'Configuração necessária', + connectorName: 'Nome do Conector', + proxyUrl: 'URL do Proxy', + proxyUrlSet: 'URL do proxy definida', + excludedModels: 'Modelos Excluídos', + headers: 'Cabeçalhos', + secret: 'Segredo', + prefix: 'Prefixo', + modelMappings: 'Mapeamentos de Modelo', + baseUri: 'URL Base', + apiKeys: 'Chaves de API', + presets: 'Aplicar mapeamentos de modelo pré-configurados', + createVariant: 'Criar Variante do CLIProxy', + agyDenylist: + 'Lista de bloqueio do Antigravity: Claude Opus 4.5 e Claude Sonnet 4.5 estão descontinuados.', + }, + providerEditorAccountItem: { + modelsUsed: 'Modelos Usados', + }, + bulkActionBar: { + applyPreset: 'Aplicar preset', + }, + modelConfigSection: { + defaultModel: 'Modelo Padrão', + }, + rawEditorSection: { + rawConfig: 'Configuração Bruta', + }, + providerEditorHeader: { + connectorName: 'Nome do Conector', + }, + aiProvidersFamilyRail: { + current: 'Atual', + }, + aiProvidersEntryCard: { + apiKeys: 'Chaves de API', + }, + aiProvidersEntryDialog: { + connectorName: 'Nome do Conector', + baseUri: 'URL Base', + proxyUrl: 'URL do Proxy', + secret: 'Segredo', + prefix: 'Prefixo', + excludedModels: 'Modelos Excluídos', + headers: 'Cabeçalhos', + modelMappings: 'Mapeamentos de Modelo', + requiredSetup: 'Configuração necessária', + optionalRouting: 'Roteamento opcional', + }, + + // ======================================== + // Domain 4: Compatible CLI Tabs + // ======================================== + codex: { + controlCenter: 'Central de Controle', + overview: 'Visão Geral', + docs: 'Docs', + nativeCodexRuntime: 'Runtime Nativo do Codex', + ccsCodexProvider: 'Atalho do Cliproxy / ponte gerenciada', + codexDocs: 'Documentação do Codex', + howCodexWorks: 'Como o Codex funciona no CCS', + supportedFlows: 'Fluxos suportados', + twoSupportedPaths: 'Dois caminhos suportados:', + nativeLabel: 'Nativo:', + nativeDesc: 'O Codex é um destino de primeira classe, somente runtime, no CCS v1.', + nativeConfigLabel: 'Configuração nativa:', + nativeConfigDesc: 'ccs-codex e ccsx iniciam o Codex nativo usando seus padrões salvos.', + transientOverridesLabel: 'Substituições transitórias:', + transientOverridesDesc: + 'ccsxp permanece no Codex nativo e adiciona a substituição do provedor cliproxy; ccs codex --target codex continua sendo a ponte gerenciada pelo CCS explícita.', + cliproxyDefaultLabel: 'Padrão do CLIProxy:', + cliproxyDefaultDesc: + 'Para fazer com que o codex simples use o CLIProxy, defina model_provider = "cliproxy" e adicione a receita abaixo.', + ccsBridge: 'Ponte CCS', + apiProfilesDefault: 'Os perfis de API continuam usando Claude ou Droid por padrão.', + runtimeInstall: 'Instalação do runtime', + detected: 'Detectado', + notFound: 'Não encontrado', + detectionSource: 'Origem da detecção', + binaryPath: 'Caminho do binário', + installDirectory: 'Diretório de instalação', + versionLabel: 'Versão', + nativeAliases: 'Aliases nativos', + ccsProviderShortcut: 'Atalho do Cliproxy', + configOverrideSupport: 'Suporte à substituição --config', + available: 'Disponível', + missing: 'Ausente', + cliproxyNativeCodex: 'Codex nativo com suporte do CLIProxy', + builtInLabel: 'Integrado:', + builtInCcsxpDesc: 'Use ccsxp para o Codex nativo com o atalho do provedor cliproxy.', + managedBridgeLabel: 'Ponte gerenciada:', + managedBridgeDesc: + 'Use ccs codex --target codex quando quiser explicitamente a rota mais rígida gerenciada pelo CCS.', + nativeRecipeLabel: 'Nativo:', + nativeRecipeDesc: + 'Configure a receita abaixo para usar o CLIProxy diretamente com o codex.', + saveProviderNamedCliproxy: + 'Salve um provedor chamado cliproxy com a URL base e a chave de ambiente acima.', + inTopLevelSetDefault: + 'Nas configurações de nível superior, defina o provedor Padrão como cliproxy.', + exportCliproxyApiKey: + 'Exporte CLIPROXY_API_KEY no seu shell antes de iniciar o Codex nativo.', + noConfigOverrides: + 'Este build do Codex ainda pode usar o caminho nativo simples, mas o ccsxp e a ponte explícita do Codex gerenciada pelo CCS permanecem indisponíveis até que o binário detectado do Codex exponha substituições --config.', + configFile: 'Arquivo de configuração', + path: 'Caminho', + resolved: 'Resolvido', + size: 'Tamanho', + lastModified: 'Última modificação', + tomlWarning: 'Aviso de TOML', + readWarning: 'Aviso de leitura', + currentUserLayerSummary: 'Resumo da camada de usuário atual', + activeProfile: 'Perfil ativo', + notSet: 'Não definido', + providersCount: 'provedores: {{count}}', + profilesCount: 'perfis: {{count}}', + enabledFeaturesCount: 'recursos habilitados: {{count}}', + mcpServersCount: 'servidores mcp: {{count}}', + userLayerKeysPresent: 'Chaves da camada de usuário presentes', + recommendedSetupFlow: 'Fluxo de configuração recomendado', + fastestPath: 'Caminho mais rápido', + officialChannels: 'Canais Oficiais', + codexCli: 'CLI do Codex', + openNativeCodex: 'Abrir Codex nativo', + runBuiltInCodex: 'Executar Codex nativo com o atalho do cliproxy', + runBuiltInCodexExplicit: 'Executar a ponte do Codex gerenciada pelo CCS explicitamente', + openCodexDashboard: 'Abrir painel do Codex', + nativeShortAlias: 'Alias curto nativo', + ccsCodexShortcut: 'Atalho do Cliproxy', + explicitProviderRoute: 'Rota da ponte gerenciada', + status: 'Status', + profiles: 'Perfis', + createNewProfile: 'Criar novo perfil', + createNewProvider: 'Criar novo provedor', + createNewMcpServer: 'Criar novo servidor MCP', + defaultProvider: 'Provedor padrão', + useDefault: 'Usar padrão', + useGlobalProvider: 'Usar provedor global', + useProviderDefault: 'Usar padrão do provedor', + quickFillWarning: 'Somente preenchimento rápido. Revise antes de salvar.', + thisFileUpstreamOwned: 'Este arquivo é de propriedade upstream da CLI do Codex.', + notes: 'Notas', + approvalPolicy: 'Política de aprovação', + sandboxMode: 'Modo sandbox', + reasoningEffort: 'Esforço de raciocínio', + useGlobalEffort: 'Usar esforço global', + reasoningEffortCapitalized: 'Esforço de Raciocínio', + thinkingBudgetTokens: 'Tokens de Orçamento de Pensamento', + modelContextWindow: 'Janela de contexto do modelo', + autoCompactTokenLimit: 'Limite de tokens de compactação automática', + toolOutputTokenLimit: 'Limite de tokens de saída de ferramenta', + webSearch: 'Busca na web', + personality: 'Personalidade', + model: 'Modelo', + rawOnly: 'Somente bruto', + trusted: 'confiável', + untrusted: 'não confiável', + noProjectTrustEntries: 'Nenhuma entrada explícita de confiança de projeto salva.', + codexNativeRecipe: 'Receita nativa do Codex salva', + runtimeVsProvider: 'Runtime vs provedor', + honorsSavedNativeConfig: 'Respeita a configuração nativa de usuário salva', + usesTransientOverrides: 'Usa substituições transitórias', + unavailableNoConfig: 'Indisponível (o build do Codex não tem suporte a --config).', + gptContextCap: 'Limite de contexto do GPT-5.4', + usageLimitCost: 'Custo de limite de uso acima de 272K', + longContextOverride: 'Substituição de contexto longo', + counts2x: 'Conta 2x', + normalUsageWindow: 'Janela de uso normal', + useCodexDefault: 'Usar padrão do Codex', + stdio: 'stdio', + streamableHttp: 'streamable-http', + responses: 'responses', + defaultTargetCli: 'CLI de Destino Padrão', + executionChain: 'Cadeia de execução', + targetPath: 'Caminho de destino atual', + userConfig: 'Configuração do usuário', + configYaml: 'config.yaml', + flow: 'Fluxo', + docsTab: 'Docs', + yes: 'Sim', + no: 'Não', + warningsTitle: 'Avisos', + features: 'Recursos', + featuresDesc: + 'Alterne as flags de recursos suportadas do Codex que o CCS pode gerenciar com segurança.', + configOnlyFlags: 'Flags existentes somente na configuração', + configOnlyFlagsDesc: + 'Essas chaves de recurso já existem no seu `config.toml`, então o CCS pode exibi-las sem alegar cobertura completa do catálogo.', + existing: 'existente', + nonBooleanForm: + 'Armazenado em uma forma não booleana. Use o TOML bruto se precisar editá-lo.', + discoveredFromFile: + 'Descoberto a partir do arquivo atual em vez do catálogo integrado do CCS.', + featureMultiAgentLabel: 'Multiagente', + featureMultiAgentDesc: 'Habilitar ferramentas de colaboração de subagentes.', + featureUnifiedExecLabel: 'Exec unificado', + featureUnifiedExecDesc: 'Usar a ferramenta de exec unificado baseada em PTY.', + featureShellSnapshotLabel: 'Snapshot de shell', + featureShellSnapshotDesc: 'Reutilizar snapshots do ambiente do shell.', + featureApplyPatchLabel: 'Aplicar patch', + featureApplyPatchDesc: 'Habilitar edições apply_patch em formato livre.', + featureJsReplLabel: 'REPL JS', + featureJsReplDesc: 'Habilitar o REPL de JavaScript baseado em Node.', + featureRuntimeMetricsLabel: 'Métricas de runtime', + featureRuntimeMetricsDesc: 'Coletar métricas de runtime do Codex.', + featurePreventIdleSleepLabel: 'Evitar suspensão por inatividade', + featurePreventIdleSleepDesc: 'Manter a máquina ativa enquanto estiver em uso.', + featureFastModeLabel: 'Modo rápido', + featureFastModeDesc: 'Permitir o caminho de nível de serviço rápido.', + featureAppsLabel: 'Apps', + featureAppsDesc: 'Habilitar suporte a Apps e conectores do ChatGPT.', + featureSmartApprovalsLabel: 'Aprovações inteligentes', + featureSmartApprovalsDesc: 'Rotear aprovações elegíveis pelo fluxo guardian.', + auth: { + terminalOnlyTooltip: + 'Use ccsx auth switch ou ccsx auth remove no terminal.', + terminalOnlyTooltipRich: + 'Use ccsx auth switch <nome> ou ccsx auth remove <nome> no terminal.', + activeSourceBadge: '{{source}}', + statusOk: 'OK', + statusInvalid: '[!] autenticação inválida', + loading: 'Carregando perfis de autenticação...', + loadError: '[!] Falha ao carregar perfis codex-auth.', + emptyRegistry: + '[i] Nenhum perfil codex-auth. Execute ccsx auth create para criar um.', + emptyRegistryRich: + '[i] Nenhum perfil codex-auth. Execute ccsx auth create <nome> para criar um.', + legacyCodexHome: 'O Codex usará o local padrão ~/.codex.', + legacyCodexHomeRich: 'O Codex usará o local padrão ~/.codex.', + legacyMode: + '[i] Nenhum perfil ativo. Usando ~/.codex (legado). Execute ccsx auth switch no terminal para ativar um.', + legacyModeRich: + '[i] Nenhum perfil ativo. Usando ~/.codex (legado). Execute ccsx auth switch <nome> no terminal para ativar um.', + externalCodexHome: + '[i] $CODEX_HOME definido externamente para {{path}}. O registro de perfis não está em uso nesta sessão.', + externalCodexHomeRich: + '[i] $CODEX_HOME definido externamente para {{path}}. O registro de perfis não está em uso nesta sessão.', + activeProfile: 'Perfil ativo:', + unknownProfile: '(desconhecido)', + planLabel: 'Plano:', + switchAction: 'Trocar', + removeAction: 'Remover', + sourceDefault: 'padrão', + sourceEnv: '$CCS_CODEX_PROFILE', + sourceExplicitCodexHome: '$CODEX_HOME', + col: { + name: 'Nome', + email: 'E-mail', + plan: 'Plano', + lastUsed: 'Último uso', + status: 'Status', + actions: 'Ações', + }, + }, + }, + droidSettings: { + quickControls: 'Controles Rápidos', + reasoningControls: 'Controles de Raciocínio', + thinkingBudget: 'Orçamento de Pensamento', + anthropicOnly: 'Somente modelos Anthropic', + byokCustomModels: 'Modelos Personalizados BYOK', + }, + rawJsonSettingsEditor: { + title: 'Editor de Configurações Brutas', + }, + copilotConfigForm: { + copilotConfiguration: 'Configuração do Copilot', + deprecatedModels: 'Modelos descontinuados do Copilot detectados', + failedLoadStatus: 'Falha ao carregar o status', + useWithClaudeCode: 'Use sua assinatura do GitHub Copilot com o Claude Code', + githubCopilotControls: 'Os controles do GitHub Copilot limitam prompt/contexto upstream.', + provider: 'Provedor', + filePath: 'Caminho do Arquivo', + status: 'Status', + enabled: 'Habilitado', + disabled: 'Desabilitado', + loadingEditor: 'Carregando editor...', + modelMapping: 'Mapeamento de Modelo', + quickUsage: 'Uso Rápido', + noPremiumUsage: 'Nenhuma contagem de uso premium', + }, + copilotPresets: { + gpt5Codex: 'GPT-5.3 Codex', + claude46: 'Claude 4.6', + gemini3: 'Gemini 3', + }, + + // ======================================== + // Domain 5: Logs / Monitoring / Health / Analytics + // ======================================== + healthCard: { + allSystemsNominal: 'Todos os Sistemas Normais', + machineChecks: 'Verificações da máquina', + }, + analyticsCards: { + cacheCost: 'Custo de Cache', + hitRate: 'Taxa de Acerto', + inputOutputRatio: 'Proporção Entrada/Saída', + noCacheData: 'Nenhum dado de cache disponível', + noModelData: 'Nenhum dado de modelo disponível', + noSessionData: 'Nenhum dado de sessão disponível', + noTokenData: 'Nenhum dado de token disponível', + totalCost: 'Custo Total', + totalTokens: 'Total de Tokens', + usageInsights: 'Insights de Uso', + }, + dateRangeFilter: { + pickADate: 'Escolha uma data', + }, + logsConfig: { + level: 'Nív', + message: 'Mensagem', + source: 'Origem', + time: 'Hora', + proc: 'Proc', + open: 'Abrir', + run: 'Executar', + refreshEntries: 'Atualizar Entradas', + }, + logsDetailPanel: { + details: 'Detalhes', + }, + logsFilters: { + filters: 'Filtros', + }, + logsOverviewCards: { + overview: 'Visão Geral', + }, + logsPageSkeleton: { + loadingLogs: 'Carregando logs...', + }, + monitoringErrorLogs: { + logContent: 'Conteúdo do Log', + }, + analyticsPages: { + chartsGrid: 'Gráficos', + costByModel: 'Custo por Modelo', + }, + + // ======================================== + // Domain 6: Hooks / Toasts / Error Transport + // ======================================== + toasts: { + profileCreated: 'Perfil criado com sucesso', + profileUpdated: 'Perfil atualizado com sucesso', + profileDeleted: 'Perfil excluído com sucesso', + orphanProfilesComplete: 'Registro de perfis órfãos concluído', + profileCopied: 'Perfil copiado com sucesso', + profileImported: 'Perfil importado com sucesso', + authRequired: 'Autorização do {{provider}} necessária', + authSuccess: 'Autenticação do {{provider}} bem-sucedida!', + authFailed: 'Falha na autenticação do {{provider}}', + deviceCodeExpired: 'O código do dispositivo expirou. Tente novamente.', + codeCopied: 'Código copiado para a área de transferência', + failedCopy: 'Falha ao copiar o código', + configSaved: 'Configuração salva com sucesso', + configSaveFailed: 'Falha ao salvar: {{error}}', + invalidYaml: 'Não é possível salvar YAML inválido', + configUpdatedExternally: 'Configuração atualizada externamente', + settingsFileUpdated: 'Arquivo de configurações atualizado', + accountsUpdated: 'Contas atualizadas', + noProfilesToSync: 'Nenhum perfil para sincronizar', + syncFailed: 'Falha na sincronização: {{error}}', + sessionAffinityUpdated: 'Afinidade de sessão {{state}}.', + cliproxyRetryUpdated: 'Configurações de nova tentativa atualizadas.', + providerAuthSuccess: 'Autenticação do {{provider}} bem-sucedida', + providerDeviceCodeInCallback: 'O provedor retornou o fluxo Device Code no modo callback', + providerAuthTimeout: 'A autenticação expirou. Tente novamente.', + providerAccountRegistrationFailed: 'Não foi possível registrar a conta autenticada', + providerLostStatusEndpoint: 'Contato perdido com o endpoint de status de autenticação', + providerUnknown: 'Provedor desconhecido: {{provider}}', + providerStartOAuthFailed: 'Falha ao iniciar o OAuth', + providerCallbackFailed: 'Falha no envio do callback', + providerSubmitCallbackFailed: 'Falha ao enviar o callback', + loggingConfigSaved: 'Configuração de log salva.', + loggingConfigSaveFailed: 'Falha ao salvar a configuração de log.', + unifiedConfigUpdated: 'Configuração atualizada com sucesso', + migrationPreviewComplete: 'Pré-visualização da migração concluída', + migrationComplete: 'Migração concluída com sucesso', + migrationFailed: 'Falha na migração', + rollbackComplete: 'Reversão concluída com sucesso', + rollbackFailed: 'Falha na reversão', + defaultAccountSet: 'Conta padrão definida como "{{name}}"', + defaultAccountReset: 'Conta padrão redefinida para o CCS', + accountDeleted: 'Conta "{{name}}" excluída', + contextUpdated: 'Contexto de "{{name}}" atualizado para {{summary}}', + legacyConfirmError: + 'A conta "{{name}}" precisa de confirmação explícita. Use Editar Sincronização de Histórico nesta conta.', + legacyConfirmFailed: 'Falha na confirmação da conta legada "{{name}}": {{error}}', + legacyConfirmSuccess_one: '{{count}} conta legada confirmada', + legacyConfirmSuccess_other: '{{count}} contas legadas confirmadas', + legacyConfirmPartial: + '{{updated}} conta(s) legada(s) confirmada(s), mas {{failed}} atualização(ões) falhou(aram). Estado das contas atualizado.', + legacyConfirmAllFailed: + 'Falha ao confirmar {{failed}} conta(s) legada(s). Estado das contas atualizado.', + noLegacyAccounts: 'Nenhuma conta legada precisa de confirmação', + routingStrategySet: 'Estratégia de roteamento definida como {{strategy}}', + variantCreated: 'Variante criada com sucesso', + variantUpdated: 'Variante atualizada com sucesso', + variantDeleted: 'Variante excluída com sucesso', + defaultAccountUpdated: 'Conta padrão atualizada', + accountRemoved: 'Conta removida', + accountPaused: 'Conta pausada', + accountResumed: 'Conta retomada', + accountAdded: 'Conta adicionada para {{provider}}', + kiroImported: 'Conta Kiro importada: {{name}}', + kiroTokenImported: 'Token Kiro importado', + modelUpdated: 'Modelo atualizado', + presetSaved: 'Preset "{{name}}" salvo', + presetDeleted: 'Preset excluído', + cliproxyAlreadyRunning: 'O CLIProxy já estava em execução', + cliproxyStarted: 'CLIProxy iniciado com sucesso', + cliproxyStartFailed: 'Falha ao iniciar o CLIProxy', + cliproxyStopped: 'CLIProxy parado', + cliproxyStopFailed: 'Falha ao parar o CLIProxy', + presetApplied: 'Preset "{{name}}" aplicado', + presetAppliedCustom: 'Preset personalizado aplicado', + settingsSavedWithAdjustments: 'Configurações salvas com ajustes de modelo', + settingsSaved: 'Configurações salvas', + failedSaveSettings: 'Falha ao salvar as configurações', + codexRefreshFailed: + 'Falha ao atualizar o instantâneo do Codex. As edições brutas foram mantidas.', + codexRefreshError: 'Falha ao atualizar o instantâneo do Codex.', + codexFixToml: 'Corrija o TOML antes de salvar.', + codexSaved: 'config.toml do Codex salvo.', + codexChangedExternally: + 'O config.toml foi alterado externamente. Atualize e tente novamente.', + codexSaveFailed: 'Falha ao salvar o config.toml do Codex.', + codexUpdateFailed: 'Falha ao atualizar a configuração do Codex.', + noOrphanProfiles: 'Nenhuma configuração de perfil órfã encontrada', + profilesRegistered: '{{count}} perfil(is) registrado(s){{skipped}}', + destinationEmpty: 'O nome do perfil de destino não pode estar vazio', + profileExportDownloaded: 'Exportação do perfil baixada', + profileImportFailed: 'Falha ao importar o pacote de perfil', + }, + providerConfig: { + defaultDeviceCodeInstruction: 'Complete a autorização no seu navegador.', + trackLabel: 'Faixa', + sectionCoreLabel: 'Núcleo / backend original', + sectionCoreHint: 'Faixa de provedor padrão, sempre disponível', + sectionPlusLabel: 'Extras Plus / mantidos pela comunidade', + sectionPlusHint: 'Ainda suportada, mas separada do backend padrão por enquanto', + plusTrackNote: + 'Requer o backend Plus opcional enquanto essa faixa permanecer mantida pela comunidade.', + }, + + // ======================================== + // Domain 7: Profiles / Settings / Pages + // ======================================== + profileEditorSections: { + imageAnalysis: 'Análise de Imagem', + loadingImageSettings: 'Carregando configurações de imagem...', + skipPermissionPrompts: 'Pular avisos de permissão ao iniciar', + useNativeImageReading: 'Usar leitura nativa de imagem', + skipTransformer: 'Pular transformador', + friendlyUi: 'Interface Amigável', + info: 'Informações', + }, + imageAnalysisStatus: { + sectionTitle: 'Imagem', + openSettings: 'Abrir Configurações', + useNativeImageReading: 'Usar leitura nativa de imagem', + refreshingPreview: 'Atualizando pré-visualização', + savedStatus: 'Status salvo', + livePreview: 'Pré-visualização ao vivo', + disabledGlobally: 'Desabilitado globalmente', + targetBypassesHook: '{{target}} ignora o hook', + nativeImageReading: 'Leitura nativa de imagem', + setupNeeded: 'Configuração necessária', + needsAuth: 'Precisa de autenticação', + needsProxy: 'Precisa de proxy', + nativeFallback: 'Fallback nativo', + transformerReady: 'Transformador pronto', + badgeDisabled: 'Desabilitado', + badgeBypassed: 'Ignorado', + badgeNative: 'Nativo', + badgeSetup: 'Configuração', + badgeAuth: 'Autenticação', + badgeProxy: 'Proxy', + badgeReady: 'Pronto', + capabilityVerified: 'Verificado', + capabilityUnknown: 'Desconhecido', + toggleSummaryNativeCapable: + '{{model}} parece pronto para imagens. O CCS vai ignorar o transformador aqui.', + toggleSummaryNativeModel: 'O CCS vai preferir a leitura nativa para {{model}}.', + toggleSummaryNativeDefault: + 'O CCS vai preferir a leitura nativa de imagem para este perfil.', + toggleSummaryNativeFileAccess: + 'Este perfil atualmente permanece no acesso nativo a arquivos.', + toggleSummaryInactiveTarget: + 'O roteamento de imagem salvo do lado do Claude está inativo enquanto {{target}} está selecionado.', + toggleSummaryTransformerRoute: 'Rota do transformador: {{backend}}{{modelSuffix}}.', + noteDisabledGlobally: 'A imagem está desabilitada globalmente nas configurações do CCS.', + noteTargetBypassesHook: 'O destino atual {{target}} ignora o hook de Leitura do Claude.', + notePersistHook: + 'Persista o hook do perfil antes que o roteamento do transformador possa funcionar aqui.', + targetLabel: { + claude: 'Claude Code', + droid: 'Factory Droid', + codex: 'Codex CLI', + }, + }, + openrouterBadge: { + new: 'NOVO', + integration: 'Integração OpenRouter', + }, + openrouterBanner: { + accessModels: 'Acesse mais de {{count}} modelos via OpenRouter', + add: 'Adicionar', + }, + openrouterModelPicker: { + searchModels: 'Buscar Modelos', + newestModels: 'Modelos Mais Recentes', + }, + openrouterPromoCard: { + title: 'OpenRouter', + description: 'Acesse centenas de modelos a partir de um único endpoint de API.', + }, + profileCard: { + profile: 'Perfil', + openRouter: 'Perfil OpenRouter', + claudeCode: 'Claude Code', + claudeCodeDefault: 'Claude Code (padrão)', + factoryDroid: 'Factory Droid', + codexCli: 'CLI do Codex', + ccsProfile: 'Perfil CCS', + }, + profileDeck: { + profiles: 'Perfis', + failedToLoad: 'Falha ao carregar perfis: {{message}}', + noProfiles: 'Nenhum perfil configurado. Crie seu primeiro perfil para começar.', + }, + profilesTable: { + name: 'Nome', + provider: 'Provedor', + model: 'Modelo', + target: 'Destino', + lastModified: 'Última Modificação', + actions: 'Ações', + edit: 'Editar', + }, + profileCreateDialog: { + createProfile: 'Criar Perfil', + appliedModelToTiers: '"{{model}}" aplicado a todos os níveis de modelo', + profileCreated: 'Perfil "{{name}}" criado', + failedCreate: 'Falha ao criar o perfil', + chooseProviderHint: + 'Escolha um preset de provedor ou configure um endpoint de API personalizado.', + basicInformation: 'Informações Básicas', + modelConfiguration: 'Configuração de Modelo', + usedInCli: 'Usado na CLI:', + apiBaseUrl: 'URL Base da API', + baseUrlPlaceholder: 'https://api.example.com/v1', + prefilledFromPreset: + 'Pré-preenchido a partir de {{name}}. Você pode personalizar se necessário.', + optionalForPreset: + 'Opcional para {{name}}. Deixe em branco para usar a autenticação nativa da Anthropic.', + endpointHint: 'O endpoint que aceita solicitações compatíveis com OpenAI e Anthropic', + optional: '(opcional)', + apiKeyOptionalPlaceholder: 'Opcional - somente se a autenticação estiver habilitada', + apiKeyPlaceholder: 'sk-...', + apiKeyOptionalHint: + 'Necessário apenas se o seu endpoint local tiver autenticação habilitada', + defaultTargetCli: 'CLI de Destino Padrão', + modelMapping: 'Mapeamento de Modelo', + modelMappingDesc: + 'Mapeie os níveis do Claude Code (Opus/Sonnet/Haiku) para modelos suportados pelo seu provedor.', + searchModelsPlaceholder: 'Digite para buscar (ex.: opus, sonnet, gpt-4o)...', + noModelsFound: 'Nenhum modelo encontrado para "{{query}}"', + loadingModels: 'Carregando modelos...', + defaultModel: 'Modelo Padrão', + sonnetMapping: 'Mapeamento Sonnet', + opusMapping: 'Mapeamento Opus', + haikuMapping: 'Mapeamento Haiku', + sonnetMappingPlaceholder: 'ex.: gpt-4o, claude-sonnet-4', + opusMappingPlaceholder: 'ex.: o1, claude-opus-4.5', + haikuMappingPlaceholder: 'ex.: gpt-4o-mini, claude-3.5-haiku', + free: 'Grátis', + }, + profileDialogLegacy: { + editProfile: 'Editar Perfil', + }, + supportEntryCard: { + actionRequired: 'Ação Necessária', + }, + settingsPage: { + title: 'Configurações', + loading: 'Carregando...', + failedLoad: 'Falha ao carregar as configurações.', + tabs: { + web: 'Web', + env: 'Ambiente', + think: 'Pensamento', + proxy: 'Proxy', + auth: 'Autenticação', + backup: 'Backup', + channels: 'Canais', + imageAnalysis: 'Imagem', + }, + websearchSection: { + title: 'Busca na Web', + description: 'Configuração de busca na web baseada em CLI.', + }, + thinkingSection: { + title: 'Pensamento', + description: 'Configure o pensamento estendido/raciocínio para modelos suportados.', + directOverride: 'Substituição direta', + youType: 'Você digita:', + ccsAdds: 'O CCS adiciona:', + executionChain: 'Cadeia de execução', + primaryBackends: 'Back-ends primários', + legacyCliFallbacks: 'Fallbacks legados de CLI', + managedPayload: 'Payload gerenciado', + sharedTargetMetadata: 'Metadados de destino compartilhados', + ideTargetMetadata: 'Metadados de destino da IDE', + ideSettingsPath: 'Caminho de configurações da IDE', + ideHost: 'Host da IDE', + resolvedBinding: 'Vínculo resolvido', + bindingName: 'Nome do vínculo', + inSync: 'Sincronizado', + currentTargetPath: 'Caminho de destino atual', + warnings: 'Avisos', + notes: 'Notas', + workspacePresets: 'Presets do workspace', + draft: 'Rascunho', + advanced: 'Avançado', + recommended: 'Fluxo de configuração recomendado', + configureModelFirst: 'Configure um modelo primeiro', + }, + proxySection: { + title: 'Proxy', + loadingImageSettings: 'Carregando configurações de imagem...', + }, + channelsSection: { + title: 'Canais Oficiais', + description: 'Veja e gerencie os canais de lançamento oficiais.', + }, + imageAnalysisSection: { + title: 'Análise de Imagem', + description: 'Configure as configurações de análise de imagem.', + loading: 'Carregando configurações de imagem...', + }, + browserSection: { + title: 'Navegador', + description: + 'Superfície principal de configuração para automação de navegador gerenciada no CCS.', + primaryTitle: + 'Configurações > Navegador é a superfície principal de configuração do navegador.', + primaryDescription: + 'Configure o Claude Browser Attach e as Ferramentas de Navegador do Codex aqui e depois use as orientações abaixo para iniciar ou verificar cada faixa.', + readiness: 'Prontidão', + nextStep: 'Próximo passo', + technicalDetails: 'Detalhes Técnicos', + diagnostics: 'Diagnósticos', + evalMode: { + label: 'acesso ao browser_eval', + options: { + disabled: 'Desabilitado', + readonly: 'Somente leitura', + readwrite: 'Leitura/escrita', + }, + }, + actions: { + saveClaude: 'Salvar configurações do Claude', + saveCodex: 'Salvar configurações do Codex', + testConnection: 'Testar conexão', + copyLaunchCommand: 'Copiar comando de inicialização', + }, + messages: { + statusRefreshed: 'Status do navegador atualizado', + launchCommandCopied: 'Comando de inicialização copiado', + }, + claude: { + title: 'Claude Browser Attach', + description: + 'Conecta as inicializações com destino Claude a uma sessão gerenciada do Chrome com depuração remota habilitada.', + enabledLabel: 'Habilitar Claude Browser Attach', + enabledDescription: + 'Quando habilitado, o CCS prepara o runtime MCP de navegador gerenciado para inicializações com destino Claude.', + userDataDir: 'Diretório de dados de usuário do Chrome', + userDataDirHint: + 'Use um diretório de perfil do Chrome dedicado para que o estado do modo attach permaneça isolado do seu navegador diário.', + devtoolsPort: 'Porta do DevTools', + devtoolsPortHint: 'Use a mesma porta ao iniciar o Chrome no modo attach.', + devtoolsPortInvalid: 'Digite uma porta válida entre 1 e 65535.', + effectivePath: 'Caminho de attach efetivo', + recommendedPath: 'Caminho recomendado', + managedRuntime: 'Runtime de navegador gerenciado', + evalModeHint: + 'Controla o acesso ao browser_eval para o Claude Browser Attach. Somente leitura permite inspeção; leitura/escrita também permite mutações no lado da página.', + overrideMessage: + 'Uma substituição de ambiente está atualmente ativa vindo de {{source}}. Este painel volta a ser a fonte da verdade assim que essa substituição for removida.', + launchGuidance: 'Orientação de inicialização', + launchGuidanceHint: + 'Inicie o Chrome com a depuração remota habilitada e depois execute novamente Testar conexão se necessário.', + }, + codex: { + title: 'Ferramentas de Navegador do Codex', + description: + 'Controla o caminho de substituição do Playwright MCP gerenciado para inicializações com destino Codex.', + enabledLabel: 'Habilitar Ferramentas de Navegador do Codex', + enabledDescription: + 'Mantenha isso ativado para tornar a página Navegador a superfície principal de configuração das ferramentas de navegador do Codex.', + serverName: 'Nome do servidor gerenciado', + overrideSupport: 'Suporte à substituição de configuração', + overrideSupported: 'Suportado', + overrideUnsupported: 'Não suportado', + binary: 'Binário do Codex detectado', + notDetected: 'Não detectado', + evalModeHint: + 'Armazenado para paridade das configurações do Navegador. Na Fase 1, a aplicação do browser_eval se aplica principalmente ao Claude Browser Attach.', + }, + }, + }, + codexPage: { + title: 'Codex', + controlCenter: 'Central de Controle', + overview: 'Visão Geral', + docs: 'Docs', + authProfiles: 'Perfis de Autenticação', + nativeRuntime: 'Runtime Nativo', + ccsProvider: 'Provedor CCS', + setup: 'Configuração', + }, + apiPage: { + title: 'Perfis de API', + subtitle: 'Gerencie seus perfis de API e endpoints.', + }, + claudeExtensionPage: { + title: 'Extensão Claude', + subtitle: 'Configurações de integração da Extensão Claude.', + claudeAuth: 'Autenticação do Claude', + status: 'Status', + targetMetadata: 'Metadados de destino da IDE', + }, + sharedPageV2: { + title: 'Compartilhado', + subtitle: 'Gerenciamento de dados compartilhados.', + }, + homePageV2: { + title: 'Início', + logsMoved: 'Os logs foram movidos para um workspace dedicado', + profiles: 'Perfis', + cliproxy: 'CLIProxy', + accounts: 'Contas', + health: 'Saúde', + }, + analyticsPageV2: { + title: 'Análises', + subtitle: 'Análises e insights de uso.', + }, + logsPageV2: { + title: 'Logs', + subtitle: 'Veja e gerencie os logs do sistema.', + }, + healthPageV2: { + title: 'Saúde', + subtitle: 'Monitoramento da saúde do sistema.', + }, + aiProvidersPage: { + title: 'Provedores de IA', + subtitle: 'Gerencie as configurações de provedores de IA.', + unableToLoad: 'Não foi possível carregar os Provedores de IA', + }, + }, + }, 'zh-CN': { translation: { locale: { @@ -2745,6 +5566,7 @@ const resources = { vi: '越南语', ja: '日语', ko: '韩语', + 'pt-BR': '巴西葡萄牙语', }, layout: { languageSwitcher: '语言', @@ -2954,6 +5776,9 @@ const resources = { pinnedModelNotAdvertised: '固定模型当前未被代理广播:{{model}}', codexReasoningVariants: '推理变体', codexFastVariants: '快速变体', + effortLabel: '推理强度', + effortAuto: '自动', + fastTier: '快速', }, createAuthProfileDialog: { title: '创建新账号', @@ -4227,7 +7052,7 @@ const resources = { }, heroSection: { title: 'CCS Config', - subtitle: 'Claude Code Switch Dashboard', + subtitle: 'Claude Codex Switch Dashboard', }, hubFooter: { logs: '日志', @@ -4587,6 +7412,11 @@ const resources = { sessionRecognitionTitle: '会话识别', sessionRecognitionDescription: 'CCS 不承诺所有后端都使用同一优先级顺序。通常上游会优先使用显式会话或线程 ID,然后回退到元数据字段,最后再回退到基于开场提示历史的哈希。', + retryTitle: '自动重试', + retryHint: '重试次数 / 最大等待(秒)', + requestRetryLabel: '重试次数', + maxRetryIntervalLabel: '最大重试间隔(秒)', + retryRangeError: '必须是 0 或更大的整数。', }, extendedContext: { extendedContext: '扩展上下文', @@ -4963,6 +7793,7 @@ const resources = { noProfilesToSync: '没有可同步的配置', syncFailed: '同步失败:{{error}}', sessionAffinityUpdated: '会话粘性已{{state}}。', + cliproxyRetryUpdated: '重试设置已更新。', providerAuthSuccess: '{{provider}} 认证成功', providerDeviceCodeInCallback: '提供商在回调模式中返回了设备码流程', providerAuthTimeout: '认证超时,请重试。', @@ -5345,6 +8176,7 @@ const resources = { vi: 'Tiếng Việt', ja: 'Tiếng Nhật', ko: 'Tiếng Hàn', + 'pt-BR': 'Tiếng Bồ Đào Nha (Brazil)', }, layout: { languageSwitcher: 'Ngôn ngữ', @@ -5563,6 +8395,9 @@ const resources = { pinnedModelNotAdvertised: 'Mô hình ghim hiện không được proxy quảng bá: {{model}}', codexReasoningVariants: 'Biến thể lý luận', codexFastVariants: 'Biến thể nhanh', + effortLabel: 'Mức suy luận', + effortAuto: 'Tự động', + fastTier: 'Nhanh', }, createAuthProfileDialog: { title: 'Tạo tài khoản mới', @@ -6931,7 +9766,7 @@ const resources = { }, heroSection: { title: 'CCS Config', - subtitle: 'Bảng điều khiển Claude Code Switch', + subtitle: 'Bảng điều khiển Claude Codex Switch', }, hubFooter: { logs: 'Nhật ký', @@ -7295,6 +10130,11 @@ const resources = { sessionRecognitionTitle: 'Nhận diện phiên', sessionRecognitionDescription: 'CCS không cam kết một thứ tự ưu tiên chung cho mọi backend. Trên thực tế, backend upstream thường ưu tiên session hoặc thread id tường minh, rồi mới fallback sang metadata và cuối cùng là hàm băm của lịch sử prompt mở đầu.', + retryTitle: 'Tự động thử lại', + retryHint: 'Số lần thử lại / chờ tối đa (giây)', + requestRetryLabel: 'Số lần thử lại yêu cầu', + maxRetryIntervalLabel: 'Khoảng chờ thử lại tối đa (giây)', + retryRangeError: 'Phải là số nguyên từ 0 trở lên.', }, extendedContext: { extendedContext: 'Ngữ cảnh mở rộng', @@ -7676,6 +10516,7 @@ const resources = { noProfilesToSync: 'Không có hồ sơ để đồng bộ', syncFailed: 'Đồng bộ thất bại: {{error}}', sessionAffinityUpdated: 'Ghim phiên đã {{state}}.', + cliproxyRetryUpdated: 'Đã cập nhật cài đặt thử lại.', providerAuthSuccess: 'Xác thực {{provider}} thành công', providerDeviceCodeInCallback: 'Nhà cung cấp trả về Device Code flow trong chế độ callback', providerAuthTimeout: 'Đã hết thời gian xác thực. Vui lòng thử lại.', @@ -8070,6 +10911,7 @@ const resources = { vi: 'ベトナム語', ja: '日本語', ko: '韓国語', + 'pt-BR': 'ポルトガル語(ブラジル)', }, layout: { languageSwitcher: '言語', @@ -8288,6 +11130,9 @@ const resources = { pinnedModelNotAdvertised: 'ピンモデルは現在プロキシから通知されていません: {{model}}', codexReasoningVariants: '推論バリアント', codexFastVariants: '高速バリアント', + effortLabel: '推論レベル', + effortAuto: '自動', + fastTier: '高速', }, createAuthProfileDialog: { title: '新しいアカウントを作成', @@ -10118,7 +12963,7 @@ const resources = { }, heroSection: { title: 'CCS Config', - subtitle: 'Claude Code Switch Dashboard', + subtitle: 'Claude Codex Switch Dashboard', }, homePageV2: { title: 'ホーム', @@ -10475,6 +13320,11 @@ const resources = { sessionRecognitionTitle: 'セッション認識', sessionRecognitionDescription: 'CCS はすべてのバックエンドで同一の優先順位を保証しません。一般に上流バックエンドは明示的なセッション / スレッド ID を優先し、その後にメタデータ、最後に冒頭プロンプト履歴のハッシュへフォールバックします。', + retryTitle: '自動リトライ', + retryHint: 'リトライ回数 / 最大待機(秒)', + requestRetryLabel: 'リトライ回数', + maxRetryIntervalLabel: '最大リトライ間隔(秒)', + retryRangeError: '0 以上の整数を入力してください。', }, settingsDialog: { editProfile: 'プロファイルを編集: {{name}}', @@ -10706,6 +13556,7 @@ const resources = { noProfilesToSync: '同期するプロファイルがありません', syncFailed: '同期に失敗しました: {{error}}', sessionAffinityUpdated: 'セッション固定を{{state}}にしました。', + cliproxyRetryUpdated: '再試行設定を更新しました。', providerAuthSuccess: '{{provider}} の認証に成功しました', providerDeviceCodeInCallback: 'コールバックモードでプロバイダーがデバイスコードフローを返しました', @@ -10819,6 +13670,7 @@ const resources = { vi: '베트남어', ja: '일본어', ko: '한국어', + 'pt-BR': '브라질 포르투갈어', }, layout: { languageSwitcher: '언어', @@ -11036,6 +13888,9 @@ const resources = { pinnedModelNotAdvertised: '고정된 모델이 현재 프록시에서 알리지 않고 있습니다: {{model}}', codexReasoningVariants: '추론 변형', codexFastVariants: '빠른 변형', + effortLabel: '추론 강도', + effortAuto: '자동', + fastTier: '고속', }, createAuthProfileDialog: { title: '새 계정 생성', @@ -12390,7 +15245,7 @@ const resources = { // ======================================== heroSection: { title: 'CCS Config', - subtitle: 'Claude Code Switch 대시보드', + subtitle: 'Claude Codex Switch 대시보드', }, hubFooter: { logs: '로그', @@ -12762,6 +15617,11 @@ const resources = { sessionRecognitionTitle: '세션 인식', sessionRecognitionDescription: 'CCS는 여기서 보편적인 우선순위를 약속하지 않습니다. 실제로 업스트림 백엔드는 명시적 세션 또는 스레드 ID를 먼저 선호한 다음, 메타데이터 필드로 폴백하고 최종적으로 시작 프롬프트 기록 기반 해시로 폴백합니다.', + retryTitle: '자동 재시도', + retryHint: '재시도 횟수 / 최대 대기(초)', + requestRetryLabel: '재시도 횟수', + maxRetryIntervalLabel: '최대 재시도 간격(초)', + retryRangeError: '0 이상의 정수여야 합니다.', }, extendedContext: { extendedContext: '확장 컨텍스트', @@ -13158,6 +16018,7 @@ const resources = { noProfilesToSync: '동기화할 프로필이 없습니다', syncFailed: '동기화 실패: {{error}}', sessionAffinityUpdated: '세션 어피니티 {{state}}.', + cliproxyRetryUpdated: '재시도 설정이 업데이트되었습니다.', providerAuthSuccess: '{{provider}} 인증 성공', providerDeviceCodeInCallback: '프로바이더가 콜백 모드에서 디바이스 코드 흐름을 반환했습니다', diff --git a/ui/src/lib/locales.ts b/ui/src/lib/locales.ts index 087ad075..df97336e 100644 --- a/ui/src/lib/locales.ts +++ b/ui/src/lib/locales.ts @@ -1,6 +1,6 @@ export const LOCALE_STORAGE_KEY = 'ccs-ui-locale'; -export const SUPPORTED_LOCALES = ['en', 'zh-CN', 'vi', 'ja', 'ko'] as const; +export const SUPPORTED_LOCALES = ['en', 'pt-BR', 'zh-CN', 'vi', 'ja', 'ko'] as const; export type AppLocale = (typeof SUPPORTED_LOCALES)[number]; @@ -14,6 +14,7 @@ export function normalizeLocale(locale: string | null | undefined): AppLocale { if (locale.toLowerCase().startsWith('ja')) return 'ja'; if (locale.toLowerCase().startsWith('zh')) return 'zh-CN'; if (locale.toLowerCase().startsWith('vi')) return 'vi'; + if (locale.toLowerCase().startsWith('pt')) return 'pt-BR'; return 'en'; } diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 34c82ca4..b209a653 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -402,11 +402,13 @@ export const MODEL_CATALOGS: Record = { id: 'grok-4.5', name: 'Grok 4.5', description: 'Frontier model for coding, engineering, and agentic workflows', + reasoningLevels: ['low', 'medium', 'high'], }, { id: 'grok-4.3', name: 'Grok 4.3', description: 'General-purpose Grok model with a one-million-token context window', + reasoningLevels: ['none', 'low', 'medium', 'high'], }, { id: 'grok-4.20-0309-reasoning', @@ -980,6 +982,18 @@ export const MODEL_CATALOGS: Record = { haiku: 'claude-haiku-4-5-20251001', }, }, + { + id: 'claude-opus-5', + name: 'Claude Opus 5', + description: 'Latest premium model', + extendedContext: true, + presetMapping: { + default: 'claude-opus-5', + opus: 'claude-opus-5', + sonnet: 'claude-sonnet-5', + haiku: 'claude-haiku-4-5-20251001', + }, + }, { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', @@ -1147,6 +1161,10 @@ export function buildUiCatalog( ? undefined : (model.extendedContext ?? staticModel?.extendedContext), presetMapping: staticModel?.presetMapping, + reasoningLevels: model.reasoningLevels ?? staticModel?.reasoningLevels, + codexMaxEffort: model.codexMaxEffort ?? staticModel?.codexMaxEffort, + codexEfforts: model.codexEfforts ?? staticModel?.codexEfforts, + codexServiceTiers: model.codexServiceTiers ?? staticModel?.codexServiceTiers, }; }); diff --git a/ui/src/lib/reasoning-control.ts b/ui/src/lib/reasoning-control.ts new file mode 100644 index 00000000..9bf6489d --- /dev/null +++ b/ui/src/lib/reasoning-control.ts @@ -0,0 +1,63 @@ +import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; +import { + applyCodexEffortSuffix, + getSelectableCodexEfforts, + parseCodexEffort, + stripCodexEffortSuffix, + type CodexEffort, +} from '@/lib/codex-effort'; +import { findCatalogModel } from '@/lib/model-catalogs'; + +export interface ReasoningAdapter { + parse(value: string | undefined): string | undefined; + strip(value: string | undefined): string; + apply(value: string | undefined, level?: string): string; + getOptions(baseId: string, catalog: ProviderCatalog): string[] | null; +} + +const XAI_LEVEL_SUFFIX_REGEX = /\(([a-z]+)\)$/i; + +export function parseXaiReasoningLevel(modelId: string | undefined): string | undefined { + const match = modelId?.trim().match(XAI_LEVEL_SUFFIX_REGEX); + return match?.[1]?.toLowerCase(); +} + +export function stripXaiReasoningSuffix(modelId: string | undefined): string { + return modelId?.trim().replace(XAI_LEVEL_SUFFIX_REGEX, '') ?? ''; +} + +export function applyXaiReasoningSuffix(modelId: string | undefined, level?: string): string { + const baseModelId = stripXaiReasoningSuffix(modelId); + if (!baseModelId || !level) return baseModelId; + return `${baseModelId}(${level})`; +} + +const codexAdapter: ReasoningAdapter = { + parse: (value) => parseCodexEffort(value), + strip: (value) => stripCodexEffortSuffix(value), + apply: (value, level) => applyCodexEffortSuffix(value, level as CodexEffort | undefined), + getOptions: (baseId, catalog) => { + const model = baseId ? findCatalogModel(catalog.provider, baseId, catalog) : undefined; + return getSelectableCodexEfforts(model?.codexMaxEffort, model?.codexEfforts); + }, +}; + +const xaiAdapter: ReasoningAdapter = { + parse: parseXaiReasoningLevel, + strip: stripXaiReasoningSuffix, + apply: applyXaiReasoningSuffix, + getOptions: (baseId, catalog) => { + if (!baseId) return null; + const levels = findCatalogModel(catalog.provider, baseId, catalog)?.reasoningLevels; + return levels?.length ? [...levels] : null; + }, +}; + +const REASONING_ADAPTERS: Record = { + codex: codexAdapter, + xai: xaiAdapter, +}; + +export function getReasoningAdapter(provider: string | undefined): ReasoningAdapter | undefined { + return provider ? REASONING_ADAPTERS[provider.toLowerCase()] : undefined; +} diff --git a/ui/tests/unit/components/cliproxy/cliproxy-retry-control.test.tsx b/ui/tests/unit/components/cliproxy/cliproxy-retry-control.test.tsx new file mode 100644 index 00000000..fc467377 --- /dev/null +++ b/ui/tests/unit/components/cliproxy/cliproxy-retry-control.test.tsx @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../setup/test-utils'; +import { CliproxyRetryControl } from '@/components/cliproxy/cliproxy-retry-control'; + +const hookState = vi.hoisted(() => ({ + query: { + data: { + request_retry: 2, + max_retry_interval: 20, + source: 'live' as const, + target: 'local' as const, + reachable: true, + manageable: true, + message: undefined as string | undefined, + }, + isLoading: false, + isError: false, + error: null as Error | null, + }, + mutation: { + isPending: false, + mutate: vi.fn(), + }, +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useCliproxyRetryConfig: () => hookState.query, + useUpdateCliproxyRetryConfig: () => hookState.mutation, +})); + +describe('CliproxyRetryControl', () => { + beforeEach(() => { + hookState.query.data = { + request_retry: 2, + max_retry_interval: 20, + source: 'live', + target: 'local', + reachable: true, + manageable: true, + message: undefined, + }; + hookState.query.isLoading = false; + hookState.query.isError = false; + hookState.query.error = null; + hookState.mutation.isPending = false; + hookState.mutation.mutate.mockReset(); + }); + + it('updates the pair through the dedicated retry mutation', () => { + render(); + const requestRetry = screen.getByRole('textbox', { name: 'Request retry count' }); + fireEvent.change(requestRetry, { target: { value: '4' } }); + fireEvent.blur(requestRetry); + + expect(hookState.mutation.mutate).toHaveBeenCalledWith({ + request_retry: 4, + max_retry_interval: 20, + }); + expect(screen.getByText(/live · local/i)).toBeInTheDocument(); + }); + + it('rejects values outside the safe non-negative integer range', () => { + render(); + const requestRetry = screen.getByRole('textbox', { name: 'Request retry count' }); + fireEvent.change(requestRetry, { target: { value: String(Number.MAX_SAFE_INTEGER + 1) } }); + fireEvent.blur(requestRetry); + + expect(hookState.mutation.mutate).not.toHaveBeenCalled(); + expect(screen.getByText('Must be a whole number, 0 or greater.')).toBeInTheDocument(); + }); + + it('disables editing on query errors, unmanageable state, or a pending update', () => { + hookState.query.isError = true; + hookState.query.error = new Error('Retry management unavailable'); + const { rerender } = render(); + expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled(); + expect(screen.getByText('Retry management unavailable')).toBeInTheDocument(); + + hookState.query.isError = false; + hookState.query.error = null; + hookState.query.data = { ...hookState.query.data, manageable: false }; + rerender(); + expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled(); + + hookState.query.data = { ...hookState.query.data, manageable: true }; + hookState.mutation.isPending = true; + rerender(); + expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled(); + }); +}); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx index f26e9909..89bf5640 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -13,6 +13,29 @@ import { ModelConfigSection } from '@/components/cliproxy/provider-editor/model- import { MODEL_CATALOGS } from '@/lib/model-catalogs'; describe('ModelConfigSection presets', () => { + it('renders five tier selectors including the fable row', () => { + render( + + ); + + expect(screen.getAllByTestId('flexible-model-selector')).toHaveLength(5); + }); + it('groups codex presets by free and paid tiers', async () => { const onApplyPreset = vi.fn(); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx index 25c3e7ab..f330c78f 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx @@ -152,6 +152,44 @@ describe('useProviderEditor', () => { }); }); + it('derives fableModel and round-trips ANTHROPIC_DEFAULT_FABLE_MODEL updates', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/codex/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'codex', + settings: { + env: { + ANTHROPIC_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.6-sol-xhigh', + }, + }, + mtime: 1, + path: '~/.ccs/codex.settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('codex'), { wrapper }); + + await waitFor(() => expect(result.current.fableModel).toBe('gpt-5.6-sol-xhigh')); + + act(() => { + result.current.updateEnvValue('ANTHROPIC_DEFAULT_FABLE_MODEL', 'gpt-5.6-sol-max'); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('gpt-5.6-sol-max'); + }); + it('preserves explicit codex effort suffixes in editor state updates', async () => { vi.stubGlobal( 'fetch', diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index c1c29da3..738fc00d 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -70,7 +70,7 @@ describe('FlexibleModelSelector', () => { expect(screen.queryByText(/All Models \(/i)).not.toBeInTheDocument(); }); - it('surfaces Claude Sonnet 5 in the curated Claude picker', async () => { + it('surfaces current Claude models in the curated Claude picker', async () => { render( { await userEvent.click(screen.getByRole('button', { name: /select model/i })); expect(screen.getByRole('option', { name: /claude-sonnet-5/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /claude-opus-5/i })).toBeInTheDocument(); expect(screen.getByRole('option', { name: /claude-opus-4-8/i })).toBeInTheDocument(); expect(screen.getByRole('option', { name: /claude-sonnet-4-6/i })).toBeInTheDocument(); }); diff --git a/ui/tests/unit/ui/i18n/language-switcher.test.tsx b/ui/tests/unit/ui/i18n/language-switcher.test.tsx index 9c8e6643..d9ed6bdb 100644 --- a/ui/tests/unit/ui/i18n/language-switcher.test.tsx +++ b/ui/tests/unit/ui/i18n/language-switcher.test.tsx @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTranslation } from 'react-i18next'; import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; import i18n from '@/lib/i18n'; import { ContinuityOverview } from '@/components/account/continuity-overview'; @@ -31,6 +32,17 @@ function collectPlaceholders(text: string): string[] { return Array.from(text.matchAll(/\{\{[^{}]+\}\}/g), (match) => match[0]).sort(); } +function AuthCopyProbe() { + const { t } = useTranslation(); + + return ( + <> +

{t('auth.dashboardTitle')}

+

{t('auth.safetyNoteSession')}

+ + ); +} + describe('Dashboard i18n', () => { const storage = new Map(); const localStorageMock = window.localStorage as unknown as { @@ -94,6 +106,7 @@ describe('Dashboard i18n', () => { }, 10000); it.each([ + { locale: 'pt-BR', label: 'Brazilian Portuguese', browserLocale: 'pt-BR' }, { locale: 'vi', label: 'Vietnamese', browserLocale: 'vi-VN' }, { locale: 'ja', label: 'Japanese', browserLocale: 'ja-JP' }, { locale: 'ko', label: 'Korean', browserLocale: 'ko-KR' }, @@ -113,6 +126,28 @@ describe('Dashboard i18n', () => { } ); + it.each([ + ['pt-BR', 'pt-BR'], + ['pt-PT', 'pt-BR'], + ])('normalizes Portuguese locale %s to %s', (browserLocale, expectedLocale) => { + expect(normalizeLocale(browserLocale)).toBe(expectedLocale); + }); + + it('renders semantic Brazilian Portuguese auth copy when pt-BR is selected', async () => { + await i18n.changeLanguage('pt-BR'); + + render(); + + expect( + screen.getByRole('heading', { name: 'Claude Codex Switch Dashboard' }) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Logins bem-sucedidos criam um cookie HttpOnly restrito a este host do painel.' + ) + ).toBeInTheDocument(); + }); + it('restores locale from persisted storage', () => { persistLocale('ja'); expect(getInitialLocale()).toBe('ja'); diff --git a/ui/tests/unit/ui/lib/codex-effort.test.ts b/ui/tests/unit/ui/lib/codex-effort.test.ts index 557da198..a0f377be 100644 --- a/ui/tests/unit/ui/lib/codex-effort.test.ts +++ b/ui/tests/unit/ui/lib/codex-effort.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { applyCodexEffortSuffix, + applyCodexServiceTierSuffix, getCodexEffortDisplay, getCodexEffortVariants, + getSelectableCodexEfforts, parseCodexEffort, parseCodexServiceTier, stripCodexEffortSuffix, @@ -85,6 +87,15 @@ describe('codex effort helpers', () => { ]); }); + it('replaces effort while preserving the fast service tier', () => { + expect(applyCodexEffortSuffix('gpt-5.4-high-fast', 'low')).toBe('gpt-5.4-low-fast'); + expect(applyCodexEffortSuffix('gpt-5.4-high-fast', undefined)).toBe('gpt-5.4-fast'); + }); + + it('applies effort suffixes to routing-prefixed model ids', () => { + expect(applyCodexEffortSuffix('codex/gpt-5.6-sol', 'high')).toBe('codex/gpt-5.6-sol-high'); + }); + it('builds fast variants for models with a fast service tier', () => { expect(getCodexEffortVariants('gpt-5.4', 'high', ['fast'])).toEqual([ 'gpt-5.4', @@ -100,3 +111,69 @@ describe('codex effort helpers', () => { ]); }); }); + +describe('applyCodexServiceTierSuffix', () => { + it('adds the fast tier while preserving the effort suffix', () => { + expect(applyCodexServiceTierSuffix('gpt-5.4-high', true)).toBe('gpt-5.4-high-fast'); + }); + + it('removes only the fast tier', () => { + expect(applyCodexServiceTierSuffix('gpt-5.4-high-fast', false)).toBe('gpt-5.4-high'); + expect(applyCodexServiceTierSuffix('gpt-5.4-fast', false)).toBe('gpt-5.4'); + }); + + it('preserves routing prefixes', () => { + expect(applyCodexServiceTierSuffix('codex/gpt-5.6-sol', true)).toBe('codex/gpt-5.6-sol-fast'); + }); + + it('is idempotent and safe on empty input', () => { + expect(applyCodexServiceTierSuffix('gpt-5.4-fast', true)).toBe('gpt-5.4-fast'); + expect(applyCodexServiceTierSuffix('gpt-5.4', false)).toBe('gpt-5.4'); + expect(applyCodexServiceTierSuffix(undefined, true)).toBe(''); + expect(applyCodexServiceTierSuffix('', true)).toBe(''); + }); + + it('composes with applyCodexEffortSuffix in both orders', () => { + expect(applyCodexEffortSuffix(applyCodexServiceTierSuffix('gpt-5.4', true), 'high')).toBe( + 'gpt-5.4-high-fast' + ); + expect(applyCodexServiceTierSuffix(applyCodexEffortSuffix('gpt-5.4', 'high'), true)).toBe( + 'gpt-5.4-high-fast' + ); + expect( + applyCodexEffortSuffix(applyCodexServiceTierSuffix('gpt-5.4-high-fast', false), 'low') + ).toBe('gpt-5.4-low'); + }); +}); + +describe('getSelectableCodexEfforts', () => { + it('returns the explicit efforts list when provided', () => { + expect(getSelectableCodexEfforts('xhigh', ['low', 'medium', 'high', 'xhigh'])).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]); + }); + + it('returns efforts up to and including the max effort', () => { + expect(getSelectableCodexEfforts('xhigh')).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + ]); + expect(getSelectableCodexEfforts('medium')).toEqual(['minimal', 'low', 'medium']); + }); + + it('returns all efforts for custom models without catalog metadata', () => { + expect(getSelectableCodexEfforts(undefined)).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + ]); + }); +}); diff --git a/ui/tests/unit/ui/lib/model-catalogs-codex-merge.test.ts b/ui/tests/unit/ui/lib/model-catalogs-codex-merge.test.ts new file mode 100644 index 00000000..79b13e09 --- /dev/null +++ b/ui/tests/unit/ui/lib/model-catalogs-codex-merge.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { buildUiCatalog } from '@/lib/model-catalogs'; + +describe('buildUiCatalog codex tuning metadata merge', () => { + it('preserves static codex effort and service-tier metadata when the live catalog omits them', () => { + const merged = buildUiCatalog('codex', { + provider: 'codex', + displayName: 'Codex', + defaultModel: 'gpt-5.6-sol', + models: [{ id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol' }], + }); + + const model = merged?.models.find((entry) => entry.id === 'gpt-5.6-sol'); + expect(model?.codexMaxEffort).toBe('xhigh'); + expect(model?.codexEfforts).toEqual(['low', 'medium', 'high', 'xhigh']); + expect(model?.codexServiceTiers).toEqual(['fast']); + }); + + it('prefers live metadata over static when the live catalog provides it', () => { + const merged = buildUiCatalog('codex', { + provider: 'codex', + displayName: 'Codex', + defaultModel: 'gpt-5.6-sol', + models: [{ id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', codexServiceTiers: [] }], + }); + + const model = merged?.models.find((entry) => entry.id === 'gpt-5.6-sol'); + expect(model?.codexServiceTiers).toEqual([]); + }); +}); diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 68d12968..06615ca0 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -23,6 +23,7 @@ describe('claude preset utils', () => { expect(claudeCatalog.defaultModel).toBe('claude-sonnet-5'); expect(ids).toContain('claude-sonnet-5'); expect(ids).toContain('claude-fable-5'); + expect(ids).toContain('claude-opus-5'); expect(ids).toContain('claude-opus-4-8'); expect(ids).toContain('claude-opus-4-7'); expect(ids).toContain('claude-sonnet-4-6'); diff --git a/ui/tests/unit/ui/lib/reasoning-control.test.ts b/ui/tests/unit/ui/lib/reasoning-control.test.ts new file mode 100644 index 00000000..a31f815f --- /dev/null +++ b/ui/tests/unit/ui/lib/reasoning-control.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; +import { + applyXaiReasoningSuffix, + getReasoningAdapter, + parseXaiReasoningLevel, + stripXaiReasoningSuffix, +} from '@/lib/reasoning-control'; + +const xaiCatalog: ProviderCatalog = { + provider: 'xai', + displayName: 'xAI (Grok)', + defaultModel: 'grok-build-0.1', + models: [ + { id: 'grok-build-0.1', name: 'Grok Build 0.1' }, + { id: 'grok-4.5', name: 'Grok 4.5', reasoningLevels: ['low', 'medium', 'high'] }, + { id: 'grok-4.3', name: 'Grok 4.3', reasoningLevels: ['none', 'low', 'medium', 'high'] }, + ], +}; + +const codexCatalog: ProviderCatalog = { + provider: 'codex', + displayName: 'Codex', + defaultModel: 'gpt-5.3-codex', + models: [ + { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', codexMaxEffort: 'xhigh' }, + { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', codexMaxEffort: 'high' }, + { + id: 'gpt-5.6-sol', + name: 'GPT-5.6 Sol', + codexEfforts: ['low', 'medium', 'high', 'xhigh'], + }, + ], +}; + +describe('xai paren codec', () => { + it('parses paren level suffixes', () => { + expect(parseXaiReasoningLevel('grok-4.5(high)')).toBe('high'); + expect(parseXaiReasoningLevel('grok-4.3(none)')).toBe('none'); + expect(parseXaiReasoningLevel('grok-4.5(HIGH)')).toBe('high'); + }); + + it('returns undefined for unsuffixed or non-level values', () => { + expect(parseXaiReasoningLevel('grok-4.5')).toBeUndefined(); + expect(parseXaiReasoningLevel('grok-4(1m)')).toBeUndefined(); + expect(parseXaiReasoningLevel(undefined)).toBeUndefined(); + }); + + it('only matches the suffix at end-of-string', () => { + expect(parseXaiReasoningLevel('grok(high)-fast')).toBeUndefined(); + expect(stripXaiReasoningSuffix('grok(high)-fast')).toBe('grok(high)-fast'); + }); + + it('strips paren suffixes for catalog lookup', () => { + expect(stripXaiReasoningSuffix('grok-4.5(high)')).toBe('grok-4.5'); + expect(stripXaiReasoningSuffix('grok-4.5')).toBe('grok-4.5'); + expect(stripXaiReasoningSuffix(undefined)).toBe(''); + }); + + it('applies and replaces paren suffixes', () => { + expect(applyXaiReasoningSuffix('grok-4.5', 'high')).toBe('grok-4.5(high)'); + expect(applyXaiReasoningSuffix('grok-4.5(high)', 'low')).toBe('grok-4.5(low)'); + expect(applyXaiReasoningSuffix('grok-4.3', 'none')).toBe('grok-4.3(none)'); + }); + + it('strips the suffix when no level is given', () => { + expect(applyXaiReasoningSuffix('grok-4.5(high)', undefined)).toBe('grok-4.5'); + expect(applyXaiReasoningSuffix('grok-4.3(none)', undefined)).toBe('grok-4.3'); + expect(applyXaiReasoningSuffix(undefined, 'high')).toBe(''); + }); + + it('round-trips through parse and apply', () => { + const stored = applyXaiReasoningSuffix('grok-4.5', 'high'); + expect(parseXaiReasoningLevel(stored)).toBe('high'); + expect(applyXaiReasoningSuffix(stored, parseXaiReasoningLevel(stored))).toBe(stored); + }); +}); + +describe('getReasoningAdapter', () => { + it('returns adapters for codex and xai only', () => { + expect(getReasoningAdapter('codex')).toBeDefined(); + expect(getReasoningAdapter('xai')).toBeDefined(); + expect(getReasoningAdapter('XAI')).toBeDefined(); + expect(getReasoningAdapter('gemini')).toBeUndefined(); + expect(getReasoningAdapter(undefined)).toBeUndefined(); + }); +}); + +function requireAdapter(provider: string) { + const adapter = getReasoningAdapter(provider); + if (!adapter) throw new Error(`missing reasoning adapter for ${provider}`); + return adapter; +} + +describe('xai adapter', () => { + const adapter = requireAdapter('xai'); + + it('returns per-model levels from catalog metadata', () => { + expect(adapter.getOptions('grok-4.5', xaiCatalog)).toEqual(['low', 'medium', 'high']); + expect(adapter.getOptions('grok-4.3', xaiCatalog)).toEqual(['none', 'low', 'medium', 'high']); + }); + + it('hides the control for models without levels metadata', () => { + expect(adapter.getOptions('grok-build-0.1', xaiCatalog)).toBeNull(); + expect(adapter.getOptions('grok-unknown-model', xaiCatalog)).toBeNull(); + expect(adapter.getOptions('', xaiCatalog)).toBeNull(); + }); + + it('writes and clears suffixed values through apply', () => { + expect(adapter.apply('grok-4.5', 'high')).toBe('grok-4.5(high)'); + expect(adapter.apply('grok-4.5(high)', undefined)).toBe('grok-4.5'); + expect(adapter.apply('grok-4.3', 'none')).toBe('grok-4.3(none)'); + }); + + it('pre-selects stored suffixed values', () => { + expect(adapter.parse('grok-4.5(high)')).toBe('high'); + expect(adapter.strip('grok-4.5(high)')).toBe('grok-4.5'); + }); +}); + +describe('codex adapter', () => { + const adapter = requireAdapter('codex'); + + it('delegates parse and strip to codex-effort helpers', () => { + expect(adapter.parse('gpt-5.3-codex-high')).toBe('high'); + expect(adapter.parse('gpt-5.4-high-fast')).toBe('high'); + expect(adapter.parse('gpt-5.3-codex')).toBeUndefined(); + expect(adapter.strip('gpt-5.4-fast-high')).toBe('gpt-5.4'); + }); + + it('applies dash suffixes preserving fast tier and routing prefix', () => { + expect(adapter.apply('gpt-5.4-high-fast', 'low')).toBe('gpt-5.4-low-fast'); + expect(adapter.apply('gpt-5.4-high-fast', undefined)).toBe('gpt-5.4-fast'); + expect(adapter.apply('codex/gpt-5.6-sol', 'high')).toBe('codex/gpt-5.6-sol-high'); + }); + + it('returns catalog-driven effort options', () => { + expect(adapter.getOptions('gpt-5.4-mini', codexCatalog)).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + ]); + expect(adapter.getOptions('gpt-5.6-sol', codexCatalog)).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]); + }); + + it('falls back to all five efforts for custom or unselected models', () => { + expect(adapter.getOptions('my-org/custom-model', codexCatalog)).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + ]); + expect(adapter.getOptions('', codexCatalog)).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + ]); + }); +}); diff --git a/ui/tests/unit/vite-proxy-origin.test.ts b/ui/tests/unit/vite-proxy-origin.test.ts new file mode 100644 index 00000000..2db01199 --- /dev/null +++ b/ui/tests/unit/vite-proxy-origin.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { UserConfig } from 'vite'; + +vi.mock('vite', () => ({ defineConfig: (config: UserConfig) => config })); +vi.mock('@vitejs/plugin-react', () => ({ default: () => ({ name: 'react' }) })); +vi.mock('@tailwindcss/vite', () => ({ default: () => ({ name: 'tailwindcss' }) })); + +const { default: viteConfig } = await import('../../vite.config'); + +type ProxyRequest = { + getHeader(name: string): string | undefined; + setHeader(name: string, value: string): void; +}; + +type IncomingRequest = { + headers: { origin?: string }; +}; + +type ProxyListener = (proxyRequest: ProxyRequest, request: IncomingRequest) => void; + +function getProxyListener(route: '/api' | '/ws', event: 'proxyReq' | 'proxyReqWs'): ProxyListener { + const proxyOptions = (viteConfig as UserConfig).server?.proxy?.[route]; + if (!proxyOptions || typeof proxyOptions === 'string' || !proxyOptions.configure) { + throw new Error(`Missing configure hook for ${route}`); + } + + const listeners = new Map(); + const proxy = { + on(registeredEvent: string, listener: ProxyListener) { + listeners.set(registeredEvent, listener); + }, + }; + + proxyOptions.configure(proxy as never, proxyOptions); + + const listener = listeners.get(event); + if (!listener) { + throw new Error(`Missing ${event} listener for ${route}`); + } + + return listener; +} + +function applyOriginRewrite(listener: ProxyListener, origin?: string): string | undefined { + const headers = new Map(); + if (origin !== undefined) { + headers.set('origin', origin); + } + + listener( + { + getHeader: (name) => headers.get(name), + setHeader: (name, value) => headers.set(name, value), + }, + { headers: { origin } } + ); + + return headers.get('origin'); +} + +it('requires the trusted Vite development port', () => { + expect((viteConfig as UserConfig).server?.strictPort).toBe(true); +}); + +describe.each([ + ['/api', 'proxyReq'], + ['/ws', 'proxyReqWs'], +] as const)('%s Vite dev proxy origin handling', (route, event) => { + const listener = getProxyListener(route, event); + + it.each(['http://localhost:5173', 'http://127.0.0.1:5173', 'http://[::1]:5173'])( + 'rewrites trusted Vite origin %s to the backend origin', + (origin) => { + expect(applyOriginRewrite(listener, origin)).toBe('http://localhost:3000'); + } + ); + + it.each([ + 'http://attacker.example.test', + 'http://localhost.evil:5173', + 'http://localhost:5174', + 'https://localhost:5173', + ])('preserves untrusted origin %s', (origin) => { + expect(applyOriginRewrite(listener, origin)).toBe(origin); + }); + + it('preserves a missing origin', () => { + expect(applyOriginRewrite(listener)).toBeUndefined(); + }); +}); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 4a31e9f5..96e5459a 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,10 +1,33 @@ +import type { ClientRequest, IncomingMessage } from 'node:http'; import { defineConfig } from 'vite'; +import type { ProxyOptions } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; const UI_ROOT = __dirname; const REPO_ROOT = path.resolve(__dirname, '..'); +const BACKEND_ORIGIN = 'http://localhost:3000'; +const TRUSTED_VITE_DEV_ORIGINS = new Set([ + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'http://[::1]:5173', +]); + +function rewriteTrustedViteDevOrigin(proxyRequest: ClientRequest, request: IncomingMessage): void { + const origin = request.headers.origin; + if (origin && TRUSTED_VITE_DEV_ORIGINS.has(origin)) { + proxyRequest.setHeader('origin', BACKEND_ORIGIN); + } +} + +const configureHttpProxy: NonNullable = (proxy) => { + proxy.on('proxyReq', rewriteTrustedViteDevOrigin); +}; + +const configureWebSocketProxy: NonNullable = (proxy) => { + proxy.on('proxyReqWs', rewriteTrustedViteDevOrigin); +}; // https://vite.dev/config/ export default defineConfig({ @@ -58,14 +81,23 @@ export default defineConfig({ }, server: { port: 5173, + strictPort: true, fs: { allow: [UI_ROOT, REPO_ROOT], }, proxy: { - '/api': 'http://localhost:3000', + // Translate only trusted local Vite origins for the dashboard's CSRF guard. + // Preserve every other Origin so the backend can reject untrusted callers. + '/api': { + target: BACKEND_ORIGIN, + changeOrigin: true, + configure: configureHttpProxy, + }, '/ws': { target: 'ws://localhost:3000', ws: true, + changeOrigin: true, + configure: configureWebSocketProxy, }, }, },