diff --git a/.gitignore b/.gitignore index 8bc708e6..2a782db3 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ package-lock.json .claude/active-plan .claude/agent-memory/ +.claude/plans-registry.json* # Logs directory logs/ diff --git a/README.md b/README.md index cb981b5b..4aab06f4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,40 @@ ccs glm ccs ollama ``` +## OpenAI-Compatible Routing + +CCS can now bridge Claude Code into OpenAI-compatible providers through a local +Anthropic-compatible proxy instead of requiring a native Anthropic upstream. + +```bash +ccs api create --preset hf +ccs hf +``` + +Need to manage the proxy manually? + +```bash +ccs proxy start hf +eval "$(ccs proxy activate)" +``` + +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) + +### Related Project: claude-code-router + +[claude-code-router](https://github.com/musistudio/claude-code-router) is an +excellent standalone tool for routing Claude Code requests to OpenAI-compatible +providers. CCS's local proxy and SSE transformation work was directly informed +by CCR's transformer architecture. + +Use CCR when you want a standalone router without CCS profile management. +Use CCS when you want the routing flow integrated with CCS profiles, runtime +bridges, and the existing `ccs` command surface. + Need the full setup path instead of the short version? | Need | Start here | @@ -93,8 +127,10 @@ Deep dive: ![WebSearch Fallback](assets/screenshots/websearch.webp) 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. Deep dive: -[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch). +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). ## Docs Matrix @@ -110,6 +146,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) | | 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) | diff --git a/config/base-codebuddy.settings.json b/config/base-codebuddy.settings.json new file mode 100644 index 00000000..483f1630 --- /dev/null +++ b/config/base-codebuddy.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codebuddy", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "auto", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2.5", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v3-2-volc" + } +} diff --git a/config/base-codex.settings.json b/config/base-codex.settings.json index 5dae2d86..d3a5ecd4 100644 --- a/config/base-codex.settings.json +++ b/config/base-codex.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "gpt-5-codex", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5-codex", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-codex-mini" + "ANTHROPIC_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4-mini" } } diff --git a/config/base-cursor.settings.json b/config/base-cursor.settings.json new file mode 100644 index 00000000..2ad521a2 --- /dev/null +++ b/config/base-cursor.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/cursor", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "composer-2", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-4-sonnet", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-4-sonnet", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "cursor-small" + } +} diff --git a/config/base-gitlab.settings.json b/config/base-gitlab.settings.json new file mode 100644 index 00000000..2dff5165 --- /dev/null +++ b/config/base-gitlab.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/gitlab", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "gitlab-duo", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "duo-chat-opus-4-6", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "duo-chat-sonnet-4-6", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "duo-chat-haiku-4-5" + } +} diff --git a/config/base-kilo.settings.json b/config/base-kilo.settings.json new file mode 100644 index 00000000..7dc448dc --- /dev/null +++ b/config/base-kilo.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kilo", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "kilo/auto", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "kilo/auto", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "kilo/auto", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kilo/auto" + } +} diff --git a/docker/README.md b/docker/README.md index 414d4e02..7991b994 100644 --- a/docker/README.md +++ b/docker/README.md @@ -62,6 +62,8 @@ environment: CCS_DASHBOARD_PASSWORD_HASH: "" ``` +Running `ccs config auth setup` on the outer host shell updates that machine's own `~/.ccs`, not the Docker volume mounted into `ccs-cliproxy`. For the integrated stack, configure auth inside the container or provide the auth env vars in Compose. + Generate a bcrypt hash: ```bash diff --git a/docs/browser-automation.md b/docs/browser-automation.md new file mode 100644 index 00000000..27ba99a6 --- /dev/null +++ b/docs/browser-automation.md @@ -0,0 +1,174 @@ +# Browser Automation + +Last Updated: 2026-04-16 + +CCS provides browser automation through two separate runtime paths: + +- **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 + +These are related, but they are not the same implementation and they do not promise a shared browser session. + +## 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 + +### Via CLI + +```bash +ccs help browser +ccs browser status +ccs browser doctor +``` + +Use `ccs browser status` for the current state and `ccs browser doctor` for actionable +troubleshooting guidance. + +### Via Config File + +Edit `~/.ccs/config.yaml`: + +```yaml +browser: + claude: + enabled: false + user_data_dir: "~/.ccs/browser/chrome-user-data" + devtools_port: 9222 + codex: + enabled: true +``` + +Notes: + +- `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 + +## 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 | + +If an override is active, Browser status surfaces should report that the current session is being +managed externally by environment variables. + +Override precedence is: + +1. `CCS_BROWSER_USER_DATA_DIR` +2. `CCS_BROWSER_PROFILE_DIR` +3. the persisted `browser.claude.user_data_dir` config value + +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. + +## Managed Runtime Files + +- `~/.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 + +Do not treat the generic Codex MCP editor as the primary browser setup path. CCS-managed browser +entries should be configured from `Settings -> Browser`. + +## 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. + +## Troubleshooting + +### Browser status says Claude Browser Attach is disabled + +Enable Claude Browser Attach in `Settings -> Browser` or via 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. Create the directory or use the generated launch command +2. Start Chrome in attach mode with `--remote-debugging-port` +3. Rerun `ccs browser doctor` + +### Browser status says no running browser session was found + +CCS could not find usable DevTools attach metadata for the configured user-data directory. + +1. Make sure Chrome was started with `--remote-debugging-port=` +2. Make sure it is using the same `user_data_dir` configured in CCS +3. Rerun `ccs browser doctor` + +### Browser status says the DevTools endpoint is unreachable + +CCS found attach metadata, but the endpoint did not answer successfully. + +1. Restart the attach browser session +2. Confirm the expected port matches the real remote debugging port +3. 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 diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index c49a0f3a..b8cdd0c8 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -1,17 +1,20 @@ # Cursor IDE Integration -This guide covers the current CCS-owned Cursor runtime, including auth import, local daemon lifecycle, live probe checks, and dashboard controls. +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` -> `Cursor IDE`. +- Dedicated dashboard page: `ccs config` -> `Deprecated` -> `Cursor IDE (Legacy)`. ## What This Runtime Actually Does -`ccs cursor` does not launch Cursor IDE itself. +`ccs legacy cursor` does not launch Cursor IDE itself. The current workflow is: 1. import Cursor credentials from local SQLite or manual input @@ -32,7 +35,7 @@ Treat this as a CCS-managed Cursor bridge, not a generic CLIProxy-backed provide ### 1) Enable integration ```bash -ccs cursor enable +ccs legacy cursor enable ``` ### 2) Import credentials @@ -40,25 +43,25 @@ ccs cursor enable Auto-detect from Cursor local SQLite state: ```bash -ccs cursor auth +ccs legacy cursor auth ``` Manual fallback: ```bash -ccs cursor auth --manual --token --machine-id +ccs legacy cursor auth --manual --token --machine-id ``` ### 3) Start daemon ```bash -ccs cursor start +ccs legacy cursor start ``` ### 4) Run a live probe ```bash -ccs cursor probe +ccs legacy cursor probe ``` Use this to verify that the current build can complete one real authenticated request through the local daemon. @@ -66,26 +69,37 @@ Use this to verify that the current build can complete one real authenticated re ### 5) Run Cursor-backed Claude ```bash -ccs cursor "explain this repo" +ccs legacy cursor "explain this repo" ``` ### 6) Verify status ```bash -ccs cursor status +ccs legacy cursor status ``` -Use `ccs cursor` with bare or normal Claude args to run through the local Cursor proxy. +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 cursor help +ccs legacy cursor help ``` ### 7) Stop daemon ```bash -ccs cursor stop +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 @@ -96,7 +110,7 @@ ccs cursor stop - 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 cursor probe` or `POST /api/cursor/probe` +- 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. @@ -108,7 +122,7 @@ Open dashboard: ccs config ``` -Then navigate to `Cursor IDE` in the sidebar. +Then navigate to `Cursor IDE (Legacy)` in the `Deprecated` section. Available controls: @@ -131,9 +145,9 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr ### `Not authenticated` or `expired` in `ccs cursor status` -- Re-run `ccs cursor auth` (or manual auth command). +- Re-run `ccs legacy cursor auth` (or manual auth command). -### `ccs cursor probe` fails even though status is green +### `ccs legacy cursor probe` fails even though status is green - `status` proves local config/auth/daemon readiness only. - `probe` proves the live runtime path. @@ -148,4 +162,4 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr ### Daemon fails to start - Check if port `20129` is in use. -- Change port in dashboard config tab, then retry `ccs cursor start`. +- 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 index 0745f15e..f8c83fc5 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -12,6 +12,12 @@ Authentication is **disabled by default** for backward compatibility. Use the CL 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) @@ -193,6 +199,8 @@ dashboard_auth: 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. diff --git a/docs/openai-compatible-providers.md b/docs/openai-compatible-providers.md new file mode 100644 index 00000000..4478e58d --- /dev/null +++ b/docs/openai-compatible-providers.md @@ -0,0 +1,332 @@ +# OpenAI-Compatible Provider Routing + +CCS can route Claude Code traffic through a local Anthropic-compatible proxy when +your API profile points at an OpenAI-compatible chat completions endpoint. + +This is useful for providers such as: + +- Hugging Face Inference Providers +- OpenRouter +- Ollama +- llama.cpp servers +- OpenAI-compatible self-hosted gateways + +## Related Project: claude-code-router + +[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. + +When to use CCR: + +- you want a standalone router without CCS profile integration +- you do not need CCS account/runtime management around the request flow + +When to use CCS: + +- 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 + +## What CCS Does + +When you launch a compatible settings profile with the Claude target, CCS now: + +1. Starts a local proxy on `127.0.0.1` +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 +``` + +Then you can use the profile directly: + +```bash +ccs hf +``` + +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 activate --fish +``` + +`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` + +## One Active Proxy Profile + +The current runtime is a single local proxy daemon. + +- Reusing the same OpenAI-compatible profile is supported +- Starting a different OpenAI-compatible profile while one proxy is already + running is rejected instead of silently replacing the active upstream + +This is intentional to avoid breaking an in-flight Claude session by swapping +its upstream provider out from under it. + +## 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 + +### Port conflict on `3456` + +- Start with a fixed port: `ccs proxy start hf --port 3457` +- Re-run `ccs proxy activate` 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` + +### 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 +``` diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 4f977fdd..56d8394b 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-10 +Last Updated: 2026-04-14 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,10 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **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. diff --git a/package.json b/package.json index 566950a8..642ec2f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0", + "version": "7.71.0-dev.13", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", diff --git a/plans/20260415-1016-cursor-provider-legacy-split/phase-01-cli-routing-namespacing.md b/plans/20260415-1016-cursor-provider-legacy-split/phase-01-cli-routing-namespacing.md new file mode 100644 index 00000000..44d44c4b --- /dev/null +++ b/plans/20260415-1016-cursor-provider-legacy-split/phase-01-cli-routing-namespacing.md @@ -0,0 +1,127 @@ +--- +phase: 1 +title: "CLI Routing & Namespacing" +status: complete +effort: "6h" +--- + +# Phase 1: CLI Routing & Namespacing + +## Context Links + +- `plan.md` +- `src/ccs.ts` +- `src/auth/profile-detector.ts` +- `src/cursor/constants.ts` +- `src/commands/root-command-router.ts` +- `src/commands/command-catalog.ts` +- `src/commands/help-command.ts` +- `src/commands/cursor-command.ts` +- `src/commands/cursor-command-display.ts` +- `src/types/profile.ts` +- `src/config/reserved-names.ts` + +## Overview + +- Priority: P1 +- Owner scope: CLI entry, help, profile detection, command naming +- Goal: make `cursor` provider-first and move the deprecated bridge under `legacy cursor` + +## Key Insights + +- The current collision is structural, not cosmetic. `ccs cursor` means "legacy bridge" in `src/ccs.ts` and `src/auth/profile-detector.ts`, but `cursor` is also listed as a built-in CLIProxy provider. +- `shouldUseCursorCliproxyShortcut()` is only a heuristic escape hatch. It does not fix bare `ccs cursor`, quoted prompts, or help routing. +- Help is currently inconsistent: provider help exists generically, but `cursor` is excluded and routed to bridge help instead. + +## Requirements + +- Reserve `cursor` for CLIProxy runtime and CLIProxy admin flags. +- Introduce explicit legacy syntax: `ccs legacy cursor ...`. +- Keep a release-N alias for old legacy admin subcommands only. +- Rename internal bridge-only profile typing from ambiguous `cursor` to explicit `legacy-cursor`. +- Keep file ownership isolated to CLI/router/help files in this phase. + +## Data Flow + +- Provider path: + `argv -> root command resolution -> provider shortcut/help path -> ProfileDetector(type=cliproxy, provider=cursor) -> CLIProxy runtime` +- Legacy path: + `argv -> legacy root command -> legacy cursor subrouter -> ProfileDetector(type=legacy-cursor) or direct handler -> local bridge runtime` +- Deprecated alias path, release N only: + `argv=ccs cursor auth|status|... -> alias shim -> warning -> dispatch to legacy cursor handler` + +## Architecture + +- Add a new root command namespace: `ccs legacy`. +- Add nested routing under `legacy` with `cursor` as the first migrated leaf. Do not overload `cursor` itself any longer. +- Remove provider exceptions for `cursor` from the generic provider help/routing logic. `ccs cursor --help` should now use provider shortcut help. +- Convert bridge-only type checks from `profileInfo.type === 'cursor'` to `profileInfo.type === 'legacy-cursor'`. +- Keep `ccs cursor help` only as a release-N compatibility shim that prints: + - `Use "ccs cursor --help" for CLIProxy Cursor` + - `Use "ccs legacy cursor help" for the deprecated bridge` + +## Related Code Files + +- Modify: + - `src/ccs.ts` + - `src/auth/profile-detector.ts` + - `src/cursor/constants.ts` + - `src/commands/root-command-router.ts` + - `src/commands/command-catalog.ts` + - `src/commands/help-command.ts` + - `src/commands/cursor-command.ts` + - `src/commands/cursor-command-display.ts` + - `src/types/profile.ts` + - `src/config/reserved-names.ts` + - `src/shared/claude-extension-setup.ts` + - `src/targets/target-runtime-compatibility.ts` +- Create: + - `src/commands/legacy-command.ts` or `src/commands/legacy/index.ts` + - `src/commands/legacy/cursor-command.ts` if the team wants physical separation immediately + +## Implementation Steps + +1. Add the `legacy` root command route and its help surface. +2. Flip `src/ccs.ts` so `cursor` goes through normal CLIProxy provider routing; remove the special-case that gives the bridge ownership of the name. +3. Replace the `shouldUseCursorCliproxyShortcut()` hack with provider-first dispatch plus a compatibility alias table for the old legacy subcommands. +4. Update `ProfileDetector` priority order so `cursor` resolves as `cliproxy`, while `legacy cursor` resolves as `legacy-cursor`. +5. Rename bridge-only help text, summaries, and status text to say "legacy Cursor bridge" explicitly. +6. Audit all `profileType === 'cursor'` checks and convert only the bridge-specific ones to `legacy-cursor`. + +## Todo List + +- [x] Add `legacy cursor` routing +- [x] Make `ccs cursor` provider-first for bare, prompt, and `--help` usage +- [x] Add deprecated alias forwarding for old admin subcommands +- [x] Rename internal bridge profile path to `legacy-cursor` +- [x] Update provider help, completion, and command catalog summaries + +## Success Criteria + +- `ccs cursor "task"` resolves to CLIProxy Cursor. +- `ccs legacy cursor "task"` resolves to the old bridge. +- `ccs cursor --help` shows provider shortcut help. +- `ccs cursor auth` still works in release N, but prints an exact replacement warning. +- No CLI path depends on `shouldUseCursorCliproxyShortcut()` to disambiguate runtime meaning. + +## Risk Assessment + +- High likelihood / high impact: users with scripts calling `ccs cursor "task"` will hit the provider path immediately. + Mitigation: call this out in release notes, keep admin aliases, add explicit warning when legacy files/config are detected and the user invokes `ccs cursor` with no flags. +- Medium likelihood / medium impact: bridge-only type renames may break target compatibility checks or extension setup. + Mitigation: grep audit every `profileType === 'cursor'` branch before tests. + +## Rollback Plan + +- Re-enable the old `cursor` special-case in `src/ccs.ts` and `ProfileDetector`. +- Keep the new `legacy` namespace in place even if dormant; it is additive and safe to leave. +- Do not roll back migrated files in this phase; routing rollback alone is enough. + +## Security Considerations + +- No auth material moves in this phase. +- Preserve existing `CCS_HOME`-aware path resolution. Do not introduce `os.homedir()` shortcuts while adding the new namespace. + +## Next Steps + +- Phase 2 depends on the new command contract from this phase. diff --git a/plans/20260415-1016-cursor-provider-legacy-split/phase-02-storage-api-boundaries.md b/plans/20260415-1016-cursor-provider-legacy-split/phase-02-storage-api-boundaries.md new file mode 100644 index 00000000..05cf43e8 --- /dev/null +++ b/plans/20260415-1016-cursor-provider-legacy-split/phase-02-storage-api-boundaries.md @@ -0,0 +1,140 @@ +--- +phase: 2 +title: "Storage & API Boundaries" +status: partial +effort: "6h" +--- + +# Phase 2: Storage & API Boundaries + +## Context Links + +- `plan.md` +- `src/config/unified-config-types.ts` +- `src/config/unified-config-loader.ts` +- `src/cursor/cursor-auth.ts` +- `src/cursor/cursor-daemon-pid.ts` +- `src/cliproxy/config/path-resolver.ts` +- `src/cliproxy/config/env-builder.ts` +- `src/web-server/routes/index.ts` +- `src/web-server/routes/cursor-routes.ts` +- `src/web-server/routes/cursor-settings-routes.ts` +- `src/web-server/routes/cliproxy-stats-routes.ts` +- `src/api/services/profile-lifecycle-service.ts` + +## Overview + +- Priority: P1 +- Owner scope: config schema, path resolution, backend APIs, migration readers +- Goal: make legacy bridge storage explicit and guarantee CLIProxy Cursor never writes the legacy raw settings file + +## Key Insights + +- Top-level `config.cursor` is bridge-only configuration today and must move. +- The legacy bridge owns `~/.ccs/cursor.settings.json`, `~/.ccs/cursor/credentials.json`, and `~/.ccs/cursor/daemon.pid`. +- CLIProxy provider settings currently resolve through generic provider settings helpers and can still collide with the legacy file for provider `cursor`. +- `~/.ccs/cursor.settings.json` is historically documented as legacy-owned, so it is unsafe to auto-import it into provider storage by default. + +## Requirements + +- Canonical legacy config key: `legacy.cursor` +- Canonical legacy files: + - `~/.ccs/legacy/cursor.settings.json` + - `~/.ccs/legacy/cursor/credentials.json` + - `~/.ccs/legacy/cursor/daemon.pid` +- Canonical provider file for CLIProxy Cursor only: + - `~/.ccs/cliproxy/cursor.settings.json` +- Canonical legacy API namespace: + - `/api/legacy/cursor/*` +- Compatibility reads: + - read old `config.cursor` + - read old `~/.ccs/cursor.settings.json` + - read old `~/.ccs/cursor/*` +- Compatibility writes: + - write only the new `legacy.*` and `cliproxy/*` paths + +## Data Flow + +- Legacy config: + `load config -> prefer legacy.cursor -> fallback config.cursor -> normalize -> write legacy.cursor only` +- Legacy raw settings: + `load /api/legacy/cursor/settings/raw -> prefer ~/.ccs/legacy/cursor.settings.json -> fallback ~/.ccs/cursor.settings.json -> write new legacy path` +- Provider settings: + `CLIProxy env builder/stats updater -> read ~/.ccs/cliproxy/cursor.settings.json -> if absent use defaults -> never read/write ~/.ccs/cursor.settings.json` + +## Architecture + +- Add a `legacy` section to unified config types and loader. Keep old `cursor` as read-only migration input during the compatibility window. +- Move legacy bridge filesystem helpers under a `legacy/cursor` path prefix. +- Split API routing: + - new canonical mount: `/api/legacy/cursor` + - release-N alias: `/api/cursor` -> same handlers + deprecation header +- Special-case CLIProxy provider settings for `cursor` only in the provider path resolver. Do not expand this migration to every provider in this issue. +- Treat existing `~/.ccs/cursor.settings.json` as legacy-owned. Do not auto-copy it into provider storage unless a future explicit provider migration is added. + +## Related Code Files + +- Modify: + - `src/config/unified-config-types.ts` + - `src/config/unified-config-loader.ts` + - `src/cursor/cursor-auth.ts` + - `src/cursor/cursor-daemon-pid.ts` + - `src/cliproxy/config/path-resolver.ts` + - `src/cliproxy/config/env-builder.ts` + - `src/web-server/routes/index.ts` + - `src/web-server/routes/cursor-routes.ts` + - `src/web-server/routes/cursor-settings-routes.ts` + - `src/web-server/routes/cliproxy-stats-routes.ts` + - `src/api/services/profile-lifecycle-service.ts` +- Create: + - `src/web-server/routes/legacy-cursor-routes.ts` + - `src/web-server/routes/legacy-cursor-settings-routes.ts` + - `src/config/migrations/cursor-legacy-migration.ts` if migration logic should stay out of the loader + +## Implementation Steps + +1. Extend config types and loader to support `legacy.cursor`, with `legacy.cursor` taking precedence over old `cursor`. +2. Update legacy bridge credential and pid helpers to use `~/.ccs/legacy/cursor/`. +3. Update the raw settings route to use `~/.ccs/legacy/cursor.settings.json` as canonical and old root path as read fallback only. +4. Move legacy API mounts to `/api/legacy/cursor/*` and keep `/api/cursor/*` as a warned alias for release N. +5. Change CLIProxy Cursor provider settings resolution to `~/.ccs/cliproxy/cursor.settings.json`. +6. Update orphan detection and cleanup logic so old `cursor.settings.json` is treated as a migration target, not a permanent provider-owned file. + +## Todo List + +- [ ] Add `legacy.cursor` config schema and loader precedence +- [ ] Move bridge credentials/pid/raw settings under `~/.ccs/legacy/` +- [x] Add canonical `/api/legacy/cursor/*` routes +- [x] Keep release-N `/api/cursor/*` alias +- [x] Isolate CLIProxy Cursor settings away from `~/.ccs/cursor.settings.json` +- [ ] Update cleanup/orphan handling + +## Success Criteria + +- Saving legacy bridge settings writes only to `legacy.cursor` and `~/.ccs/legacy/*`. +- CLIProxy Cursor model/env updates write only to `~/.ccs/cliproxy/cursor.settings.json`. +- Existing legacy users can still read old config/files during the compatibility window. +- No backend route that serves the provider path references `~/.ccs/cursor.settings.json`. + +## Risk Assessment + +- High likelihood / high impact: old `~/.ccs/cursor.settings.json` contents are ambiguous between bridge and provider expectations. + Mitigation: treat the file as legacy-owned and do not auto-import it into provider storage. +- Medium likelihood / medium impact: route aliasing may mask which API is canonical. + Mitigation: add explicit response headers or payload flags marking `/api/cursor/*` as deprecated. + +## Rollback Plan + +- Keep read fallback from old paths even if the canonical write path changes back. +- If the new legacy API namespace causes regressions, remount `/api/cursor/*` as canonical temporarily and keep the new namespace dormant. +- Do not delete old files during release N; cleanup stays opt-in until release N+2. + +## Security Considerations + +- Preserve `0600` for migrated credentials and `0700` for directories. +- Use atomic temp-file writes exactly as current routes do. +- Never copy provider tokens into the legacy namespace or legacy tokens into provider storage automatically. + +## Next Steps + +- Phase 3 depends on the canonical API and path names from this phase. diff --git a/plans/20260415-1016-cursor-provider-legacy-split/phase-03-dashboard-deprecation-ux.md b/plans/20260415-1016-cursor-provider-legacy-split/phase-03-dashboard-deprecation-ux.md new file mode 100644 index 00000000..00f22c6f --- /dev/null +++ b/plans/20260415-1016-cursor-provider-legacy-split/phase-03-dashboard-deprecation-ux.md @@ -0,0 +1,121 @@ +--- +phase: 3 +title: "Dashboard & Deprecation UX" +status: partial +effort: "4h" +--- + +# Phase 3: Dashboard & Deprecation UX + +## Context Links + +- `plan.md` +- `ui/src/App.tsx` +- `ui/src/components/layout/app-sidebar.tsx` +- `ui/src/pages/cursor.tsx` +- `ui/src/hooks/use-cursor.ts` +- `ui/src/lib/i18n.ts` +- `src/web-server/routes/index.ts` +- `src/commands/cursor-command-display.ts` + +## Overview + +- Priority: P1 +- Owner scope: dashboard route ownership, labels, user-facing deprecation messaging +- Goal: align dashboard semantics with CLI semantics so `/cursor` means provider and legacy UI is clearly marked and isolated + +## Key Insights + +- The current dashboard already admits the bridge is deprecated, but the route `/cursor` still belongs to it. +- The page includes direct navigation to CLIProxy Cursor, which means the UX already wants a split; the route layer just has not caught up. +- Keeping `/cursor` for legacy while CLI uses `cursor` for provider would create the same ambiguity in a different surface. + +## Requirements + +- `/cursor` must become the provider-owned dashboard surface. +- The legacy bridge page must move to `/legacy/cursor`. +- Legacy bridge API hooks must move to `/api/legacy/cursor/*`. +- The deprecated UX must contain exact replacements, not generic warnings. +- Sidebar grouping must reflect support level: + - provider view under provider/cliproxy navigation + - legacy bridge under deprecated navigation + +## Data Flow + +- Provider dashboard: + `browser /cursor -> provider view or redirect wrapper -> /cliproxy?provider=cursor -> existing CLIProxy provider APIs` +- Legacy dashboard: + `browser /legacy/cursor -> legacy bridge page -> useLegacyCursor hook -> /api/legacy/cursor/*` +- Compatibility API path, release N only: + `old UI/tests -> /api/cursor/* -> alias handler -> same legacy payload + deprecation signal` + +## Architecture + +- Keep provider UI DRY by making `/cursor` a thin redirect or preselected wrapper around the existing CLIProxy provider page instead of building a second Cursor-provider page. +- Move the current `ui/src/pages/cursor.tsx` implementation to a new `legacy-cursor` page and rename its hook to `useLegacyCursor`. +- Change nav labels from generic "Cursor IDE" to explicit "Cursor Bridge (Legacy)" in the deprecated section. +- Update CLI and dashboard warnings to show both paths side-by-side: + - `ccs cursor --auth` / `/cursor` + - `ccs legacy cursor auth` / `/legacy/cursor` + +## Related Code Files + +- Modify: + - `ui/src/App.tsx` + - `ui/src/components/layout/app-sidebar.tsx` + - `ui/src/lib/i18n.ts` + - `src/commands/cursor-command-display.ts` +- Move or rename: + - `ui/src/pages/cursor.tsx` -> `ui/src/pages/legacy-cursor.tsx` + - `ui/src/hooks/use-cursor.ts` -> `ui/src/hooks/use-legacy-cursor.ts` +- Create: + - `ui/src/pages/cursor-provider-redirect.tsx` if a wrapper is preferred over direct router config + +## Implementation Steps + +1. Move the legacy page and hook to `legacy-*` names and update all imports. +2. Reassign `/cursor` to the provider path and add `/legacy/cursor` for the bridge page. +3. Update sidebar grouping and labels so the provider path is no longer listed under Deprecated. +4. Replace vague deprecated copy with concrete migration copy: + - old command + - new command + - old route + - new route +5. Keep the legacy page banner persistent until release N+2, not dismissible per session. + +## Todo List + +- [ ] Move legacy page/hook module names to `legacy-*` +- [x] Reassign `/cursor` and add `/legacy/cursor` +- [x] Update deprecated nav group and labels +- [x] Rewrite key banners, button copy, and path labels with exact replacements +- [x] Keep provider and legacy links visible from both surfaces during release N + +## Success Criteria + +- Opening `/cursor` lands on the CLIProxy Cursor provider surface. +- Opening `/legacy/cursor` lands on the bridge page with a persistent deprecation banner. +- No dashboard component serving the provider route uses the legacy API hook. +- Every warning banner shows the exact before/after command and route. + +## Risk Assessment + +- Medium likelihood / medium impact: users with bookmarked `/cursor` expect the legacy page. + Mitigation: provider page shows a top-level "Looking for the old bridge?" callout linking to `/legacy/cursor`. +- Low likelihood / medium impact: UI rename churn breaks lazy imports or tests. + Mitigation: do route and hook rename in one phase and leave compatibility API alias in place until tests pass. + +## Rollback Plan + +- Point `/cursor` back to the legacy page if the provider redirect breaks. +- Keep `/legacy/cursor` additive; it does not block rollback. +- Do not remove the deprecation banner on rollback; it still communicates future intent. + +## Security Considerations + +- No auth secrets should be exposed in UI copy or route params. +- Keep manual auth dialogs scoped to the legacy page only. Provider auth remains in CLIProxy flows. + +## Next Steps + +- Phase 4 owns test rewrites, docs updates, and release gating for these UI changes. diff --git a/plans/20260415-1016-cursor-provider-legacy-split/phase-04-tests-docs-rollout.md b/plans/20260415-1016-cursor-provider-legacy-split/phase-04-tests-docs-rollout.md new file mode 100644 index 00000000..9fcd17d1 --- /dev/null +++ b/plans/20260415-1016-cursor-provider-legacy-split/phase-04-tests-docs-rollout.md @@ -0,0 +1,148 @@ +--- +phase: 4 +title: "Tests Docs & Rollout" +status: complete +effort: "4h" +--- + +# Phase 4: Tests Docs & Rollout + +## Context Links + +- `plan.md` +- `docs/cursor-integration.md` +- `README.md` +- `docs/system-architecture/provider-flows.md` +- `docs/system-architecture/index.md` +- `tests/unit/cursor/cursor-shortcut-routing.test.ts` +- `tests/unit/web-server/cursor-settings-routes.test.ts` +- `tests/unit/web-server/cursor-routes.test.ts` +- `ui/tests/unit/hooks/use-cursor.test.tsx` +- `ui/tests/unit/ui/pages/cursor-page.test.tsx` + +## Overview + +- Priority: P1 +- Owner scope: compatibility rollout, validation, docs/help updates, release notes +- Goal: ship the namespace split without surprising existing bridge users or leaving docs/help inconsistent + +## Key Insights + +- This change has one intentional breaking behavior: positional `ccs cursor` stops being the legacy bridge. +- Everything else can use a compatibility window: admin subcommands, API aliases, old config reads, old file-path reads. +- Tests must lock both meanings so the ambiguity does not regress later. + +## Requirements + +- Document exact before/after commands and routes. +- Add a concrete migration path for three user groups: + - legacy bridge users + - CLIProxy Cursor users + - dashboard bookmark users +- Define removal windows for aliases and old path fallbacks. +- Run repo quality gates after implementation: + - root: `bun run format && bun run lint:fix && bun run validate && bun run validate:ci-parity` + - UI: `cd ui && bun run format && bun run lint:fix && bun run validate` + +## Test Matrix + +- Unit: + - provider-first cursor routing + - legacy alias forwarding + - `legacy.cursor` loader precedence + - path resolvers for legacy vs provider files + - deprecation help text snapshots +- Integration: + - `ccs cursor "task"` -> provider + - `ccs legacy cursor "task"` -> bridge + - `/api/legacy/cursor/*` canonical behavior + - `/api/cursor/*` alias behavior during release N +- UI: + - `/cursor` route ownership + - `/legacy/cursor` banner and actions + - hook path changes and raw settings save targets +- Manual release validation: + - migrate old config/files in a temp `CCS_HOME` + - verify provider path never writes `~/.ccs/cursor.settings.json` + +## User Migration Plan + +1. Legacy bridge users: + - replace `ccs cursor ...` with `ccs legacy cursor ...` + - run `ccs legacy cursor status` + - update scripts and dashboard bookmarks to `/legacy/cursor` +2. CLIProxy Cursor users: + - keep using `ccs cursor ...` + - if provider-specific settings are needed, re-save them under the new provider-owned path instead of relying on `~/.ccs/cursor.settings.json` +3. Mixed/unclear state: + - `ccs migrate` should move `config.cursor` and legacy files into the new legacy namespace + - do not auto-copy the old raw settings file into provider storage + +## Deprecation UX Plan + +- CLI warning text, release N: + - `ccs cursor auth` is deprecated. Use `ccs legacy cursor auth` for the old bridge or `ccs cursor --auth` for CLIProxy Cursor. +- Dashboard banner: + - visible on `/legacy/cursor` + - provider route links back to legacy route with "Looking for the old bridge?" +- Docs banner: + - top callout in `docs/cursor-integration.md` pointing users to CLIProxy Cursor as the supported path + +## Related Code Files + +- Modify tests: + - `tests/unit/cursor/cursor-shortcut-routing.test.ts` + - `tests/unit/web-server/cursor-settings-routes.test.ts` + - `tests/unit/web-server/cursor-routes.test.ts` + - `ui/tests/unit/hooks/use-cursor.test.tsx` + - `ui/tests/unit/ui/pages/cursor-page.test.tsx` +- Modify docs: + - `docs/cursor-integration.md` + - `README.md` if root command examples mention Cursor + - `docs/system-architecture/provider-flows.md` + - `docs/system-architecture/index.md` + - CLI help snapshots or generated references if present + +## Implementation Steps + +1. Rewrite tests around the new command contract and route ownership before removing aliases in later releases. +2. Update docs/help text in the same PR as code changes so the new syntax ships atomically. +3. Add migration notes to changelog/release notes with a bold callout that `ccs cursor "task"` now means CLIProxy Cursor. +4. Keep a removal checklist for release N+1 and N+2 in the plan or roadmap so the compatibility window does not become permanent. + +## Todo List + +- [x] Update unit, integration, and selected UI tests +- [x] Update docs and CLI help text +- [x] Add migration note and deprecation wording +- [x] Run root and UI quality gates +- [x] Record alias-removal follow-up for N+1 and old-path-removal follow-up for N+2 + +## Success Criteria + +- Test suite covers both provider and legacy cursor paths explicitly. +- Docs and help text match the shipped command contract exactly. +- Release notes include the migration table and deprecation window. +- Quality gates pass in both root and `ui/`. + +## Risk Assessment + +- High likelihood / medium impact: docs or tests lag behind the command flip and users keep invoking the wrong surface. + Mitigation: block merge until help text, docs, and tests all match the new contract. +- Medium likelihood / medium impact: compatibility shims never get removed. + Mitigation: create follow-up issues or roadmap entries for N+1 and N+2 removal work before merge. + +## Rollback Plan + +- If rollout messaging is incomplete, revert the command flip before removing aliases. +- If only docs/help are wrong, fix docs first and keep aliases until corrected. +- Old-path readers stay in place through N+1, so rollback does not strand migrated users. + +## Security Considerations + +- Use temp `CCS_HOME` in tests and manual verification. Never touch the real `~/.ccs`. +- Sanitize any migration logs or warnings so they mention paths, not token contents. + +## Next Steps + +- Implementation is complete when all four phases land together; do not ship phase 1 without phases 2-4. diff --git a/plans/20260415-1016-cursor-provider-legacy-split/plan.md b/plans/20260415-1016-cursor-provider-legacy-split/plan.md new file mode 100644 index 00000000..f57f3f29 --- /dev/null +++ b/plans/20260415-1016-cursor-provider-legacy-split/plan.md @@ -0,0 +1,93 @@ +--- +title: "Separate legacy Cursor bridge from CLIProxy Cursor provider" +description: "Reserve `cursor` for the CLIProxy provider, move the reverse-engineered bridge under `legacy`, and split storage/UI with a staged migration." +status: in_progress +priority: P1 +effort: 2d +branch: kai/feat/1016-missing-provider-integration +tags: [cursor, cliproxy, migration, dashboard, deprecation] +created: 2026-04-15 +blockedBy: [] +blocks: [] +--- + +# Separate legacy Cursor bridge from CLIProxy Cursor provider + +## Goal + +Make `cursor` mean one thing everywhere: the CLIProxy-backed provider. Move the deprecated local bridge to `legacy`, stop provider writes to `~/.ccs/cursor.settings.json`, and ship a low-risk migration window. + +## Current Collision Points + +- `src/ccs.ts` hardcodes `cursor` as a legacy command/profile, then reclaims only `--auth|--logout|--config|--accounts` for CLIProxy. +- `src/auth/profile-detector.ts` resolves `cursor` to the legacy runtime before CLIProxy provider detection. +- `src/commands/command-catalog.ts` and `src/commands/help-command.ts` advertise `cursor` as both bridge and provider. +- `src/config/unified-config-types.ts` + `src/config/unified-config-loader.ts` store bridge config under top-level `cursor`. +- `src/cliproxy/config/path-resolver.ts`, `src/cliproxy/config/env-builder.ts`, and `src/web-server/routes/cliproxy-stats-routes.ts` still use provider settings paths that collide with the legacy raw file. +- `src/web-server/routes/cursor-*.ts`, `ui/src/pages/cursor.tsx`, `ui/src/hooks/use-cursor.ts`, `ui/src/App.tsx`, and `ui/src/components/layout/app-sidebar.tsx` dedicate `/cursor` and `/api/cursor/*` to the legacy bridge. +- `docs/cursor-integration.md` documents `ccs cursor` as the bridge even though CLIProxy already exposes a `cursor` provider shortcut. + +## Command Contract + +Before: +```text +ccs cursor -> legacy bridge runtime +ccs cursor "task" -> legacy bridge runtime +ccs cursor auth|status|... -> legacy bridge admin +ccs cursor --auth|--config -> CLIProxy Cursor shortcut +``` + +After release N: +```text +ccs cursor -> CLIProxy Cursor runtime +ccs cursor "task" -> CLIProxy Cursor runtime +ccs cursor --auth|--config -> CLIProxy Cursor admin +ccs legacy cursor -> legacy bridge runtime +ccs legacy cursor "task" -> legacy bridge runtime +ccs legacy cursor auth|... -> legacy bridge admin +``` + +Compatibility window, release N only: +- `ccs cursor auth|status|probe|models|start|stop|enable|disable|help` forwards to `ccs legacy cursor ...` with a deprecation warning. +- Bare and positional `ccs cursor` switch immediately to the provider path; no silent legacy fallback. + +## Phase Plan + +| Phase | Scope | Output | +| --- | --- | --- | +| 1 | [CLI Routing & Namespacing](./phase-01-cli-routing-namespacing.md) | Provider-first `cursor`, explicit `legacy cursor`, updated help/catalog/type names | +| 2 | [Storage & API Boundaries](./phase-02-storage-api-boundaries.md) | `legacy.cursor` config, split file paths, `/api/legacy/cursor/*`, provider path isolation | +| 3 | [Dashboard & Deprecation UX](./phase-03-dashboard-deprecation-ux.md) | `/cursor` -> provider view, `/legacy/cursor` -> bridge view, clear migration UX | +| 4 | [Tests Docs & Rollout](./phase-04-tests-docs-rollout.md) | Compatibility plan, migration steps, test matrix, docs updates, rollback gates | + +## Rollout Sequence + +1. Release N: add new legacy namespace, flip `ccs cursor` to provider, keep old admin subcommands and `/api/cursor/*` as warned aliases, and split provider settings away from `~/.ccs/cursor.settings.json`. +2. Release N+1: move the remaining legacy backend/config namespaces fully under `legacy.cursor`, keep old file-path fallback and `/api/cursor/*` alias for one more release. +3. Release N+2: remove old `config.cursor` and root-level `~/.ccs/cursor*` fallback reads, delete stale alias docs/help, and let cleanup/migrate remove leftovers. + +## Current Implementation Status + +- Completed in this branch: + - `ccs cursor` is provider-first for runtime and `--help` + - `ccs legacy cursor` works as the explicit legacy bridge namespace + - old legacy admin subcommands under `ccs cursor ...` forward with deprecation warnings + - CLIProxy Cursor settings no longer collide with `~/.ccs/cursor.settings.json` + - `/cursor` redirects to the provider surface while `/legacy/cursor` serves the deprecated bridge page + - `/api/legacy/cursor/*` is mounted and the legacy page uses that namespace + - docs, completion, and core regression tests were updated +- Intentionally deferred follow-up: + - move top-level `config.cursor` to `legacy.cursor` + - move legacy credentials/pid/raw settings fully under `~/.ccs/legacy/cursor/*` + - rename `use-cursor` and `CursorPage` modules to explicit `legacy-*` + +## Success Criteria + +- `cursor` is provider-owned in CLI help, routing, dashboard nav, and docs. +- Legacy bridge is reachable only through `legacy cursor` and `legacy.cursor` storage. +- CLIProxy Cursor never reads or writes `~/.ccs/cursor.settings.json`. +- Existing legacy users have an explicit migration path, warning UX, and rollback-safe compatibility window. + +## Docs Impact + +Major. CLI reference, Cursor docs, dashboard tour, provider docs, and migration notes all change in the same release. diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index bc7fbab1..b0c8d1ca 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -26,6 +26,7 @@ import { getCcsDir } from '../utils/config-manager'; import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import type { TargetType } from '../targets/target-adapter'; import type { ProfileType } from '../types/profile'; @@ -253,9 +254,8 @@ class ProfileDetector { * Detect profile type and return routing information * * Priority order: - * 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen) - * 0.5. Copilot profile (if enabled in config) - * 0.75. Cursor profile (if enabled in config) + * 0. Hardcoded special runtime profiles (copilot, cursor) + * 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...) * 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1) * 2. User-defined CLIProxy variants (config.cliproxy section) [legacy] * 3. Settings-based profiles (config.profiles section) [legacy] @@ -267,16 +267,7 @@ class ProfileDetector { return this.resolveDefaultProfile(); } - // Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config - if (isCLIProxyProvider(profileName)) { - return { - type: 'cliproxy', - name: profileName, - provider: profileName, - }; - } - - // Priority 0.5: Check Copilot profile - GitHub Copilot subscription via copilot-api + // Priority 0: Check Copilot profile - GitHub Copilot subscription via copilot-api if (profileName === 'copilot') { const unifiedConfig = this.readUnifiedConfig(); const copilotConfig = unifiedConfig?.copilot; @@ -306,17 +297,17 @@ class ProfileDetector { }; } - // Priority 0.75: Check Cursor profile - local Cursor daemon runtime - if (profileName === 'cursor') { + // Priority 0.25: Check explicit legacy Cursor bridge profile. + if (profileName === LEGACY_CURSOR_PROFILE_NAME) { const cursorConfig = getCursorConfig(); if (!cursorConfig?.enabled) { const error = new Error( - 'Cursor profile is not enabled.\n\n' + + 'Legacy Cursor profile is not enabled.\n\n' + 'To enable Cursor integration:\n' + - ' 1. Run: ccs cursor enable\n' + - ' 2. Import auth: ccs cursor auth\n' + - ' 3. Start daemon: ccs cursor start\n\n' + + ' 1. Run: ccs legacy cursor enable\n' + + ' 2. Import auth: ccs legacy cursor auth\n' + + ' 3. Start daemon: ccs legacy cursor start\n\n' + 'Or manually edit ~/.ccs/config.yaml:\n' + ' cursor:\n' + ' enabled: true' @@ -329,11 +320,20 @@ class ProfileDetector { return { type: 'cursor', - name: 'cursor', + name: LEGACY_CURSOR_PROFILE_NAME, cursorConfig, }; } + // Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...) + if (isCLIProxyProvider(profileName)) { + return { + type: 'cliproxy', + name: profileName, + provider: profileName, + }; + } + // Priority 1: Try unified config if available const unifiedConfig = this.readUnifiedConfig(); if (unifiedConfig) { diff --git a/src/ccs.ts b/src/ccs.ts index 5c907085..3cedddbb 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -39,11 +39,15 @@ import { import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, + getEffectiveClaudeBrowserAttachConfig, resolveBrowserRuntimeEnv, - resolveConfiguredBrowserProfileDir, syncBrowserMcpToConfigDir, } from './utils/browser'; -import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; +import { + getBrowserConfig, + getGlobalEnvConfig, + getOfficialChannelsConfig, +} from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks, removeImageAnalysisProfileHook, @@ -65,7 +69,8 @@ import { resolveOfficialChannelsLaunchPlan, } from './channels/official-channels-runtime'; import { getOfficialChannelReadiness } from './channels/official-channels-store'; -import { isCursorSubcommandToken } from './cursor/constants'; +import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants'; +import { isCLIProxyProvider } from './cliproxy/provider-capabilities'; // Import centralized error handling import { handleError, runCleanup } from './errors'; @@ -77,6 +82,7 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger } from './services/logging'; import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides'; +import type { ProfileDetectionResult } from './auth/profile-detector'; // Import target adapter system import { @@ -97,6 +103,11 @@ import { } from './targets/droid-reasoning-runtime'; import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; +import { + buildOpenAICompatProxyEnv, + resolveOpenAICompatProfileConfig, + startOpenAICompatProxy, +} from './proxy'; // Version and Update check utilities import { getVersion } from './utils/version'; @@ -128,7 +139,7 @@ const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v function resolveCodexRuntimeConfigOverrides( target: ReturnType ): string[] { - if (target !== 'codex') { + if (target !== 'codex' || !getBrowserConfig().codex.enabled) { return []; } return buildCodexBrowserMcpOverrides(); @@ -147,6 +158,26 @@ function detectProfile(args: string[]): DetectedProfile { } } +function normalizeLegacyCursorArgs(args: string[]): string[] { + if (args[0] === 'legacy' && args[1] === 'cursor') { + return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)]; + } + + return args; +} + +function printCursorLegacySubcommandDeprecation(subcommand: string): void { + console.error( + info(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`) + ); + console.error( + info( + `Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.` + ) + ); + console.error(''); +} + function resolveRuntimeReasoningFlags( args: string[], envThinkingValue: string | undefined @@ -341,7 +372,7 @@ async function main(): Promise { registerTarget(new CodexAdapter()); const cliLogger = createLogger('cli'); - const args = process.argv.slice(2); + let args = process.argv.slice(2); const isCompletionCommand = args[0] === '__complete'; // Initialize UI colors early to ensure consistent colored output @@ -417,6 +448,8 @@ async function main(): Promise { return; } + args = normalizeLegacyCursorArgs(args); + cliLogger.info('command.start', 'CLI invocation started', { command: args[0] || 'default', argCount: args.length, @@ -484,6 +517,17 @@ async function main(): Promise { return; } + if ( + typeof firstArg === 'string' && + isCLIProxyProvider(firstArg) && + args.length > 1 && + (args.includes('--help') || args.includes('-h')) + ) { + const { showProviderShortcutHelp } = await import('./commands/help-command'); + await showProviderShortcutHelp(firstArg); + return; + } + // Special case: copilot command (GitHub Copilot integration) // Route known subcommands to command handler, keep all other args as profile passthrough. if (firstArg === 'copilot' && args.length > 1) { @@ -497,9 +541,8 @@ async function main(): Promise { } } - // Special case: cursor command (Cursor local proxy integration) - // Route known admin subcommands to the command handler, keep all other args as profile passthrough. - if (firstArg === 'cursor' && args.length > 1) { + // Special case: explicit legacy Cursor bridge namespace. + if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) { const { handleCursorCommand } = await import('./commands/cursor-command'); const cursorToken = args[1]; @@ -509,6 +552,19 @@ async function main(): Promise { } } + // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge + // for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy. + if (firstArg === 'cursor' && args.length > 1) { + const { handleCursorCommand } = await import('./commands/cursor-command'); + const cursorToken = args[1]; + + if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') { + printCursorLegacySubcommandDeprecation(cursorToken); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + } + // First-time install: offer setup wizard for interactive users // Check independently of recovery status (user may have empty config.yaml) // Skip if headless, CI, or non-TTY environment @@ -538,7 +594,7 @@ async function main(): Promise { // Detect profile (strip --target flags before profile detection) const cleanArgs = stripTargetFlag(args); const { profile, remainingArgs } = detectProfile(cleanArgs); - const profileInfo = detector.detectProfileType(profile); + const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); let resolvedTarget: ReturnType; try { resolvedTarget = resolveTargetType( @@ -1002,13 +1058,13 @@ async function main(): Promise { const imageAnalysisMcpReady = resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; - const browserProfileDir = + const browserAttachConfig = resolvedTarget === 'claude' - ? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR) + ? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) : undefined; if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); - if (browserProfileDir) { + if (browserAttachConfig?.enabled) { ensureBrowserMcpOrThrow(); } } @@ -1035,7 +1091,7 @@ async function main(): Promise { syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); if ( - browserProfileDir && + browserAttachConfig?.enabled && inheritedClaudeConfigDir && !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) ) { @@ -1245,10 +1301,13 @@ async function main(): Promise { // Explicitly inject effective settings env vars so stale ANTHROPIC_* // values from prior sessions cannot leak into the active profile. - if (browserProfileDir) { + if (browserAttachConfig?.enabled) { browserRuntimeEnv = { ...(await resolveBrowserRuntimeEnv({ - profileDir: browserProfileDir, + profileDir: browserAttachConfig.userDataDir, + devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort + ? String(browserAttachConfig.devtoolsPort) + : undefined, })), }; } @@ -1304,6 +1363,53 @@ async function main(): Promise { const browserArgs = browserRuntimeEnv ? appendBrowserToolArgs(imageAnalysisArgs) : imageAnalysisArgs; + const openAICompatProfile = resolveOpenAICompatProfileConfig( + profileInfo.name, + expandedSettingsPath, + settingsEnv + ); + if (openAICompatProfile) { + const proxyStart = await startOpenAICompatProxy(openAICompatProfile, { + insecure: openAICompatProfile.insecure, + }); + if (!proxyStart.success) { + console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy')); + process.exit(1); + } + + console.error( + info( + `Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}` + ) + ); + + const proxyEnv = { + ...envVars, + ...buildOpenAICompatProxyEnv( + openAICompatProfile, + proxyStart.port, + proxyStart.authToken || '', + inheritedClaudeConfigDir + ), + }; + delete proxyEnv.ANTHROPIC_API_KEY; + + const launchArgs = [ + '--settings', + expandedSettingsPath, + ...appendThirdPartyWebSearchToolArgs(browserArgs), + ]; + const traceEnv = createWebSearchTraceContext({ + launcher: 'ccs.settings-profile.proxy', + args: launchArgs, + profile: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + }); + + execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }); + return; + } const launchArgs = [ '--settings', expandedSettingsPath, @@ -1366,17 +1472,20 @@ async function main(): Promise { CCS_IMAGE_ANALYSIS_SKIP: '1', }; let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; - const browserProfileDir = + const browserAttachConfig = resolvedTarget === 'claude' - ? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR) + ? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) : undefined; if (resolvedTarget === 'claude') { - if (browserProfileDir) { + if (browserAttachConfig?.enabled) { ensureBrowserMcpOrThrow(); browserRuntimeEnv = { ...(await resolveBrowserRuntimeEnv({ - profileDir: browserProfileDir, + profileDir: browserAttachConfig.userDataDir, + devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort + ? String(browserAttachConfig.devtoolsPort) + : undefined, })), }; Object.assign(envVars, browserRuntimeEnv); @@ -1396,7 +1505,7 @@ async function main(): Promise { if (defaultContinuityInheritance.claudeConfigDir) { envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; if ( - browserProfileDir && + browserAttachConfig?.enabled && !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) ) { throw new Error( diff --git a/src/cliproxy/accounts/email-account-identity.ts b/src/cliproxy/accounts/email-account-identity.ts index 35c36bd1..d03f2337 100644 --- a/src/cliproxy/accounts/email-account-identity.ts +++ b/src/cliproxy/accounts/email-account-identity.ts @@ -1,6 +1,9 @@ import type { CLIProxyProvider } from '../types'; const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set(['codex']); +const FREE_PLAN_PARTS = new Set(['free']); +const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']); +const BUSINESS_PLAN_PARTS = new Set(['team']); // Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is // separate because the browser bundle cannot import this server module directly. @@ -49,6 +52,20 @@ function formatVariantPart(value: string): string { } } +function formatWorkspaceLabel(parts: string[]): string | null { + const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part)); + if (workspaceId) { + return `Workspace ${workspaceId.toLowerCase()}`; + } + + return parts.map(formatVariantPart).filter(Boolean).join(' · ') || null; +} + +function formatAudienceDetail(parts: string[]): string | null { + const label = parts.map(formatVariantPart).filter(Boolean).join(' · '); + return label || null; +} + export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean { return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider)); } @@ -136,8 +153,16 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st } const suffix = parts[parts.length - 1]?.toLowerCase(); - if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) { - return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)] + if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) { + return ['Business', formatWorkspaceLabel(parts.slice(0, -1))].filter(Boolean).join(' · '); + } + + if (suffix && FREE_PLAN_PARTS.has(suffix)) { + return ['Free', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · '); + } + + if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) { + return ['Personal', formatVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))] .filter(Boolean) .join(' · '); } diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 95e44980..3f28ce46 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -155,6 +155,10 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - GHCP: Device Code Flow (polling-based, NO callback port needed) * - Kimi: Device Code Flow (polling-based, NO callback port needed) + * - Cursor: Device-style browser polling (NO callback port needed) + * - GitLab: Authorization Code Flow with callback server on port 17171 + * - CodeBuddy: Device-style browser polling (NO callback port needed) + * - Kilo: Device Code Flow (polling-based, NO callback port needed) */ export const OAUTH_CALLBACK_PORTS: Partial> = CLIPROXY_PROVIDER_IDS.reduce( @@ -274,6 +278,34 @@ export const OAUTH_CONFIGS: Record = { scopes: ['api'], authFlag: '--kimi-login', }, + cursor: { + provider: 'cursor', + displayName: 'Cursor', + authUrl: 'https://cursor.com/loginDeepControl', + scopes: [], + authFlag: '--cursor-login', + }, + gitlab: { + provider: 'gitlab', + displayName: 'GitLab Duo', + authUrl: 'https://gitlab.com/oauth/authorize', + scopes: ['api', 'read_user'], + authFlag: '--gitlab-login', + }, + codebuddy: { + provider: 'codebuddy', + displayName: 'CodeBuddy (Tencent)', + authUrl: 'https://copilot.tencent.com/v2/plugin/auth/state', + scopes: [], + authFlag: '--codebuddy-login', + }, + kilo: { + provider: 'kilo', + displayName: 'Kilo AI', + authUrl: 'https://api.kilo.ai/api/device-auth/codes', + scopes: [], + authFlag: '--kilo-login', + }, }; /** @@ -373,6 +405,12 @@ export interface OAuthOptions { noIncognito?: boolean; /** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */ import?: boolean; + /** GitLab auth mode override. */ + gitlabAuthMode?: 'oauth' | 'pat'; + /** GitLab self-hosted base URL override. */ + gitlabBaseUrl?: string; + /** GitLab personal access token for PAT login. */ + gitlabPersonalAccessToken?: string; /** Enable paste-callback mode: show auth URL and prompt for callback paste */ pasteCallback?: boolean; /** If true, use port-forwarding mode (skip interactive prompt in headless) */ diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts deleted file mode 100644 index 543dc657..00000000 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ /dev/null @@ -1,437 +0,0 @@ -/** - * Gemini Token Refresh - * - * Handles proactive token validation and refresh for Gemini OAuth tokens. - * Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use. - * - * Token sources (priority order): - * 1. CLIProxy auth dir (~/.ccs/cliproxy/auth/) - CCS-managed tokens - * 2. Standard Gemini CLI (~/.gemini/oauth_creds.json) - backward compatibility - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { getProviderAuthDir } from '../config-generator'; -import { getDefaultAccount, getProviderAccounts } from '../account-manager'; -import { isTokenFileForProvider } from './token-manager'; - -/** Google OAuth token endpoint */ -const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; - -/** Refresh tokens 5 minutes before expiry */ -const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; - -const GEMINI_CLIENT_ID_ENV_KEYS = ['CCS_GEMINI_OAUTH_CLIENT_ID', 'OPENCLAW_GEMINI_OAUTH_CLIENT_ID']; -const GEMINI_CLIENT_SECRET_ENV_KEYS = [ - 'CCS_GEMINI_OAUTH_CLIENT_SECRET', - 'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET', -]; - -/** Gemini oauth_creds.json structure */ -interface GeminiOAuthCreds { - access_token: string; - refresh_token?: string; - expiry_date?: number; // Unix timestamp in milliseconds - scope?: string; - token_type?: string; - id_token?: string; - client_id?: string; - client_secret?: string; - token_uri?: string; -} - -/** Gemini credentials with source path for write-back */ -interface GeminiCredsWithSource { - creds: GeminiOAuthCreds; - sourcePath: string; -} - -/** CLIProxyAPI Gemini token structure (from GeminiTokenStorage Go struct) */ -interface CliproxyGeminiToken { - token: { - access_token: string; - refresh_token?: string; - expiry?: number; // Unix timestamp in milliseconds - client_id?: string; - client_secret?: string; - token_uri?: string; - }; - project_id: string; - email: string; - type: 'gemini'; -} - -/** Token refresh response from Google */ -interface TokenRefreshResponse { - access_token?: string; - expires_in?: number; - token_type?: string; - error?: string; - error_description?: string; -} - -interface GoogleOAuthClientCredentials { - clientId: string; - clientSecret: string; - tokenUrl: string; -} - -/** - * Get path to Gemini OAuth credentials file - */ -export function getGeminiOAuthPath(): string { - return path.join(os.homedir(), '.gemini', 'oauth_creds.json'); -} - -/** - * Map CLIProxyAPI token format to internal GeminiOAuthCreds format - */ -function mapCliproxyToGeminiCreds(cliproxy: CliproxyGeminiToken): GeminiOAuthCreds { - return { - access_token: cliproxy.token.access_token, - refresh_token: cliproxy.token.refresh_token, - expiry_date: cliproxy.token.expiry, - token_type: 'Bearer', - client_id: cliproxy.token.client_id, - client_secret: cliproxy.token.client_secret, - token_uri: cliproxy.token.token_uri, - }; -} - -/** - * Validate CLIProxyAPI token structure has required fields - */ -function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken { - if (typeof data !== 'object' || data === null) return false; - const obj = data as Record; - if (obj.type !== 'gemini') return false; - if (typeof obj.token !== 'object' || obj.token === null) return false; - const token = obj.token as Record; - return typeof token.access_token === 'string'; -} - -function getFirstEnvValue(keys: readonly string[]): string | undefined { - for (const key of keys) { - const value = process.env[key]?.trim(); - if (value) { - return value; - } - } - return undefined; -} - -function resolveGeminiRefreshCredentials(creds: GeminiOAuthCreds): { - credentials?: GoogleOAuthClientCredentials; - error?: string; -} { - const clientId = creds.client_id?.trim() || getFirstEnvValue(GEMINI_CLIENT_ID_ENV_KEYS); - const clientSecret = - creds.client_secret?.trim() || getFirstEnvValue(GEMINI_CLIENT_SECRET_ENV_KEYS); - - if (!clientId || !clientSecret) { - return { - error: - 'Gemini token refresh unavailable: missing OAuth client credentials in the token file. ' + - 'Re-authenticate with CLIProxy or set CCS_GEMINI_OAUTH_CLIENT_ID and CCS_GEMINI_OAUTH_CLIENT_SECRET.', - }; - } - - return { - credentials: { - clientId, - clientSecret, - tokenUrl: creds.token_uri?.trim() || GOOGLE_TOKEN_URL, - }, - }; -} - -/** - * Read Gemini token from CLIProxy auth directory - * Returns credentials with source path, or null if no valid token found - */ -function readCliproxyGeminiCreds(accountId?: string): GeminiCredsWithSource | null { - const authDir = getProviderAuthDir('gemini'); - if (!fs.existsSync(authDir)) return null; - - let tokenPath: string | null = null; - const normalizedAccountId = accountId?.trim(); - const accounts = getProviderAccounts('gemini'); - - // Account-specific refresh path (used by background worker) - if (normalizedAccountId) { - const targetAccount = accounts.find((account) => account.id === normalizedAccountId); - if (!targetAccount) { - return null; - } - - tokenPath = path.join(authDir, targetAccount.tokenFile); - } - - if (!normalizedAccountId) { - // Try to find default account's token file - const defaultAccount = getDefaultAccount('gemini'); - if (defaultAccount) { - tokenPath = path.join(authDir, defaultAccount.tokenFile); - if (!fs.existsSync(tokenPath)) tokenPath = null; - } - - // Fallback: find any gemini account token file - if (!tokenPath && accounts.length > 0) { - tokenPath = path.join(authDir, accounts[0].tokenFile); - if (!fs.existsSync(tokenPath)) tokenPath = null; - } - - // Last fallback: scan directory for gemini token files - if (!tokenPath) { - try { - const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json')); - for (const file of files) { - const filePath = path.join(authDir, file); - if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) { - tokenPath = filePath; - break; - } - } - } catch { - // Directory read failed - continue to return null - return null; - } - } - } - - if (!tokenPath) return null; - - try { - const content = fs.readFileSync(tokenPath, 'utf8'); - const data: unknown = JSON.parse(content); - - // Validate CLIProxyAPI format with proper type checking - if (isValidCliproxyToken(data)) { - return { - creds: mapCliproxyToGeminiCreds(data), - sourcePath: tokenPath, - }; - } - - return null; - } catch { - return null; - } -} - -/** - * Read Gemini OAuth credentials - * Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json - * Returns credentials with source path for correct write-back - */ -function readGeminiCreds(accountId?: string): GeminiCredsWithSource | null { - // 1. Try CLIProxy auth directory first (CCS-managed tokens) - const cliproxyResult = readCliproxyGeminiCreds(accountId); - if (cliproxyResult) { - return cliproxyResult; - } - - // Account-scoped refresh is only supported for CLIProxy account files. - // Do not fall back to ~/.gemini for a specific accountId. - if (accountId?.trim()) { - return null; - } - - // 2. Fall back to standard Gemini CLI location - const oauthPath = getGeminiOAuthPath(); - if (!fs.existsSync(oauthPath)) { - return null; - } - try { - const content = fs.readFileSync(oauthPath, 'utf8'); - return { - creds: JSON.parse(content) as GeminiOAuthCreds, - sourcePath: oauthPath, - }; - } catch { - return null; - } -} - -/** - * Write updated credentials to CLIProxy token file - * Preserves existing fields (email, project_id), only updates token subfields - */ -function writeCliproxyGeminiCreds(tokenPath: string, creds: GeminiOAuthCreds): string | undefined { - try { - const existing = JSON.parse(fs.readFileSync(tokenPath, 'utf8')); - const updated = { - ...existing, - token: { - ...existing.token, - access_token: creds.access_token, - refresh_token: creds.refresh_token, - expiry: creds.expiry_date, - }, - }; - fs.writeFileSync(tokenPath, JSON.stringify(updated, null, 2), { mode: 0o600 }); - return undefined; - } catch (err) { - return err instanceof Error ? err.message : 'Failed to write credentials'; - } -} - -/** - * Write Gemini OAuth credentials - * Writes back to the specified source location (CLIProxy or ~/.gemini) - * @param creds - The credentials to write - * @param sourcePath - The path where credentials were originally read from - * @returns error message if write failed, undefined on success - */ -function writeGeminiCreds(creds: GeminiOAuthCreds, sourcePath: string): string | undefined { - const geminiOAuthPath = getGeminiOAuthPath(); - - // If source is not the standard Gemini path, write to CLIProxy format - if (sourcePath !== geminiOAuthPath) { - return writeCliproxyGeminiCreds(sourcePath, creds); - } - - // Otherwise write to standard Gemini CLI location - const dir = path.dirname(geminiOAuthPath); - try { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(geminiOAuthPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); - return undefined; - } catch (err) { - return err instanceof Error ? err.message : 'Failed to write credentials'; - } -} - -/** - * Check if Gemini token is expired or expiring soon - */ -export function isGeminiTokenExpiringSoon(accountId?: string): boolean { - const result = readGeminiCreds(accountId); - if (!result || !result.creds.access_token) { - return true; // No token = needs auth - } - if (!result.creds.expiry_date) { - return false; // No expiry info = assume valid - } - const expiresIn = result.creds.expiry_date - Date.now(); - return expiresIn < REFRESH_LEAD_TIME_MS; -} - -/** - * Refresh Gemini access token using refresh_token - * @param accountId Optional account ID for account-scoped refresh - * @returns Result with success status, optional error, and expiry time - */ -export async function refreshGeminiToken(accountId?: string): Promise<{ - success: boolean; - error?: string; - expiresAt?: number; -}> { - const result = readGeminiCreds(accountId); - if (!result || !result.creds.refresh_token) { - return { success: false, error: 'No refresh token available' }; - } - - const { creds, sourcePath } = result; - const resolvedCredentials = resolveGeminiRefreshCredentials(creds); - if (!resolvedCredentials.credentials) { - return { success: false, error: resolvedCredentials.error }; - } - - const { clientId, clientSecret, tokenUrl } = resolvedCredentials.credentials; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - const response = await fetch(tokenUrl, { - method: 'POST', - signal: controller.signal, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: creds.refresh_token as string, // Already validated above - client_id: clientId, - client_secret: clientSecret, - }).toString(), - }); - - clearTimeout(timeoutId); - - const data = (await response.json()) as TokenRefreshResponse; - - if (!response.ok || data.error) { - return { - success: false, - error: data.error_description || data.error || `OAuth error: ${response.status}`, - }; - } - - if (!data.access_token) { - return { success: false, error: 'No access_token in response' }; - } - - // Update credentials file with new token - const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000; - const updatedCreds: GeminiOAuthCreds = { - ...creds, - access_token: data.access_token, - expiry_date: expiresAt, - }; - const writeError = writeGeminiCreds(updatedCreds, sourcePath); - if (writeError) { - return { success: false, error: `Token refreshed but failed to save: ${writeError}` }; - } - - return { success: true, expiresAt }; - } catch (err) { - clearTimeout(timeoutId); - if (err instanceof Error && err.name === 'AbortError') { - return { success: false, error: 'Token refresh timeout' }; - } - return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; - } -} - -/** - * Ensure Gemini token is valid, refreshing if needed - * @param verbose Log progress if true - * @param accountId Optional account ID for account-scoped refresh - * @returns true if token is valid (or was refreshed), false if refresh failed - */ -export async function ensureGeminiTokenValid( - verbose = false, - accountId?: string -): Promise<{ - valid: boolean; - refreshed: boolean; - error?: string; -}> { - const result = readGeminiCreds(accountId); - if (!result || !result.creds.access_token) { - return { valid: false, refreshed: false, error: 'No Gemini credentials found' }; - } - - if (!isGeminiTokenExpiringSoon(accountId)) { - return { valid: true, refreshed: false }; - } - - // Token is expired or expiring soon - try to refresh - if (verbose) { - console.log('[i] Gemini token expired or expiring soon, refreshing...'); - } - - const refreshResult = await refreshGeminiToken(accountId); - if (refreshResult.success) { - if (verbose) { - console.log('[OK] Gemini token refreshed successfully'); - } - return { valid: true, refreshed: true }; - } - - return { valid: false, refreshed: false, error: refreshResult.error }; -} diff --git a/src/cliproxy/auth/gitlab-pat-response.ts b/src/cliproxy/auth/gitlab-pat-response.ts new file mode 100644 index 00000000..50a73b8b --- /dev/null +++ b/src/cliproxy/auth/gitlab-pat-response.ts @@ -0,0 +1,90 @@ +const GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH = 500; +const GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; +const HTML_ERROR_RESPONSE_OMITTED = '[HTML error response omitted]'; + +function sanitizeGitLabPatErrorDetail( + detail: string | undefined, + submittedToken?: string +): string | undefined { + const trimmed = detail?.trim(); + if (!trimmed) { + return undefined; + } + + if (/^]+>/.test(trimmed)) { + return HTML_ERROR_RESPONSE_OMITTED; + } + + let sanitized = trimmed.replace( + /"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token|personal_access_token)"\s*:\s*"[^"]*"/gi, + '"$1":"[redacted]"' + ); + + if (submittedToken) { + sanitized = sanitized.split(submittedToken).join('[redacted]'); + } + + sanitized = sanitized + .replace(/glpat-[A-Za-z0-9._-]+/gi, '[redacted]') + .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]') + .replace(/\s+/g, ' '); + + if (sanitized.length > GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH) { + sanitized = `${sanitized.slice( + 0, + GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH - GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX.length + )}${GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX}`; + } + + return sanitized; +} + +export function parseGitLabPatAuthResponse( + responseOk: boolean, + responseStatus: number, + responseBody: string, + submittedToken?: string +): + | { ok: true; payload: Record } + | { ok: false; payload: Record; errorMessage: string } { + const trimmedBody = responseBody.trim(); + let payload: Record = {}; + + if (trimmedBody) { + try { + payload = JSON.parse(trimmedBody) as Record; + } catch { + payload = { + error: + sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) || + `GitLab PAT login failed with status ${responseStatus}`, + }; + } + } + + const payloadError = + typeof payload.error === 'string' + ? sanitizeGitLabPatErrorDetail(payload.error, submittedToken) + : undefined; + const fallbackError = + sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) || + `GitLab PAT login failed with status ${responseStatus}`; + + if (!responseOk) { + return { + ok: false, + payload, + errorMessage: payloadError || fallbackError, + }; + } + + if (payload.status !== 'ok') { + return { + ok: false, + payload, + errorMessage: payloadError || fallbackError, + }; + } + + return { ok: true, payload }; +} diff --git a/src/cliproxy/auth/kiro-import.ts b/src/cliproxy/auth/kiro-import.ts index cc507110..9e83facd 100644 --- a/src/cliproxy/auth/kiro-import.ts +++ b/src/cliproxy/auth/kiro-import.ts @@ -29,7 +29,7 @@ export async function tryKiroImport(tokenDir: string, verbose = false): Promise< try { log('Ensuring CLIProxy binary is available...'); - const binaryPath = await ensureCLIProxyBinary(verbose); + const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); const configPath = generateConfig('kiro'); log(`Binary: ${binaryPath}`); diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 2185598e..7b1482fa 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -56,6 +56,7 @@ import { } from './token-manager'; import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; +import { parseGitLabPatAuthResponse } from './gitlab-pat-response'; import { getProxyTarget, buildProxyUrl, @@ -69,6 +70,7 @@ import { warnPossible403Ban, } from '../account-safety'; import { ensureCliAntigravityResponsibility } from '../antigravity-responsibility'; +import { InteractivePrompt } from '../../utils/prompt'; interface PasteCallbackStartData { url?: string; @@ -83,9 +85,12 @@ const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000; export async function requestPasteCallbackStart( provider: CLIProxyProvider, target: ProxyTarget, - options?: { kiroMethod?: OAuthOptions['kiroMethod'] } + options?: { + kiroMethod?: OAuthOptions['kiroMethod']; + gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl']; + } ): Promise { - const startPath = getPasteCallbackStartPath(provider, { + let startPath = getPasteCallbackStartPath(provider, { kiroMethod: options?.kiroMethod, }); if (!startPath) { @@ -93,6 +98,11 @@ export async function requestPasteCallbackStart( `Paste-callback start is not available for ${provider} with the selected method` ); } + const normalizedGitLabBaseUrl = + provider === 'gitlab' ? normalizeGitLabBaseUrl(options?.gitlabBaseUrl) : undefined; + if (normalizedGitLabBaseUrl) { + startPath += `&base_url=${encodeURIComponent(normalizedGitLabBaseUrl)}`; + } const response = await fetch(buildProxyUrl(target, startPath), { headers: buildManagementHeaders(target), }); @@ -142,6 +152,49 @@ function parseAuthUrlState(url: string | null | undefined): string | null { } } +export function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined { + const normalized = baseUrl?.trim(); + if (!normalized) { + return undefined; + } + + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + throw new Error('GitLab URL must be a valid http:// or https:// URL'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('GitLab URL must use http:// or https://'); + } + + parsed.hash = ''; + parsed.search = ''; + parsed.username = ''; + parsed.password = ''; + + const normalizedPath = parsed.pathname.replace(/\/+$/, ''); + return normalizedPath ? `${parsed.origin}${normalizedPath}` : parsed.origin; +} + +export async function promptGitLabPersonalAccessToken(): Promise { + try { + const token = (await InteractivePrompt.password('GitLab Personal Access Token')).trim(); + return token.length > 0 ? token : null; + } catch (error) { + if ((error as Error).message.includes('TTY')) { + console.log( + fail( + 'GitLab Personal Access Token prompt requires an interactive TTY. Set the token explicitly or use Browser OAuth.' + ) + ); + return null; + } + throw error; + } +} + export function findNewTokenSnapshotForManualAuth( provider: CLIProxyProvider, tokenDir: string, @@ -375,7 +428,7 @@ async function prepareBinary( showStep(1, 4, 'progress', 'Preparing CLIProxy binary...'); try { - const binaryPath = await ensureCLIProxyBinary(verbose); + const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); process.stdout.write('\x1b[1A\x1b[2K'); showStep(1, 4, 'ok', 'CLIProxy binary ready'); @@ -458,7 +511,10 @@ async function handlePasteCallbackMode( tokenDir: string, nickname?: string, expectedAccountId?: string, - options?: { kiroMethod?: OAuthOptions['kiroMethod'] } + options?: { + kiroMethod?: OAuthOptions['kiroMethod']; + gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl']; + } ): Promise { // Resolve CLIProxyAPI target (local or remote based on config) const target = getProxyTarget(); @@ -647,6 +703,91 @@ async function handlePasteCallbackMode( } } +async function handleGitLabPatLogin( + provider: CLIProxyProvider, + oauthConfig: ProviderOAuthConfig, + verbose: boolean, + tokenDir: string, + nickname?: string, + expectedAccountId?: string, + options?: { + gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl']; + gitlabPersonalAccessToken?: OAuthOptions['gitlabPersonalAccessToken']; + } +): Promise { + const target = getProxyTarget(); + const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl); + const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir); + const suppliedToken = options?.gitlabPersonalAccessToken?.trim(); + const personalAccessToken = + suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined; + + let token = personalAccessToken; + if (!token) { + console.log(''); + console.log(info(`Starting ${oauthConfig.displayName} PAT login...`)); + console.log('Paste a Personal Access Token with api and read_user scopes.'); + token = (await promptGitLabPersonalAccessToken()) || undefined; + } + + if (!token) { + console.log(info('Cancelled')); + return null; + } + + const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildManagementHeaders(target), + }, + body: JSON.stringify({ + ...(baseUrl ? { base_url: baseUrl } : {}), + personal_access_token: token, + }), + }); + + const responseBody = await response.text(); + const parsedResponse = parseGitLabPatAuthResponse( + response.ok, + response.status, + responseBody, + token + ); + + if (!parsedResponse.ok) { + console.log(fail(parsedResponse.errorMessage)); + return null; + } + + const tokenSnapshot = findNewTokenSnapshotForAuthAttempt( + provider, + tokenDir, + knownTokenFiles, + expectedAccountId + ); + if (!tokenSnapshot) { + console.log(fail('GitLab PAT login completed, but CCS could not find the saved token file.')); + return null; + } + + const account = registerAccountFromToken( + provider, + tokenDir, + nickname, + verbose, + expectedAccountId || tokenSnapshot.file + ); + + if (!account) { + console.log(fail('Authenticated GitLab token could not be registered as a CCS account.')); + return null; + } + + console.log(ok('Authentication successful!')); + return account; +} + /** * Trigger OAuth flow for provider * Auto-detects headless environment and uses --no-browser flag accordingly @@ -666,6 +807,17 @@ export async function triggerOAuth( provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD; const resolvedKiroIDCFlow = provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW; + const resolvedGitLabAuthMode = + provider === 'gitlab' && options.gitlabAuthMode === 'pat' ? 'pat' : 'oauth'; + let resolvedGitLabBaseUrl: string | undefined; + if (provider === 'gitlab') { + try { + resolvedGitLabBaseUrl = normalizeGitLabBaseUrl(options.gitlabBaseUrl); + } catch (error) { + console.log(fail((error as Error).message)); + return null; + } + } if (provider === 'agy') { if (fromUI && !acceptAgyRisk) { @@ -744,6 +896,14 @@ export async function triggerOAuth( } } + if (provider === 'gitlab' && resolvedGitLabBaseUrl && !selectedPasteCallback) { + selectedPasteCallback = true; + console.log(''); + console.log( + info('GitLab custom base URL selected. Switching to paste-callback mode for OAuth.') + ); + } + const useSelectedKiroLocalPasteCallback = selectedPasteCallback && provider === 'kiro' && @@ -765,6 +925,22 @@ export async function triggerOAuth( } } + if (provider === 'gitlab' && resolvedGitLabAuthMode === 'pat') { + const tokenDir = getProviderTokenDir(provider); + return handleGitLabPatLogin( + provider, + oauthConfig, + verbose, + tokenDir, + nickname, + existingNameMatch?.id, + { + gitlabBaseUrl: resolvedGitLabBaseUrl, + gitlabPersonalAccessToken: options.gitlabPersonalAccessToken, + } + ); + } + if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) { const tokenDir = getProviderTokenDir(provider); return handlePasteCallbackMode( @@ -774,7 +950,10 @@ export async function triggerOAuth( tokenDir, nickname, existingNameMatch?.id, - { kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined } + { + kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, + gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined, + } ); } diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index 207ac0fa..d5cfea3b 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -4,8 +4,7 @@ * Exports refresh functions for each OAuth provider. * * Refresh responsibility: - * - CCS-managed: gemini (CCS refreshes tokens directly via Google OAuth) - * - CLIProxy-delegated: codex, agy, kiro, ghcp, qwen, iflow, kimi + * - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, qwen, iflow, kimi * (CLIProxyAPIPlus handles refresh automatically in background) * - Not implemented: claude */ @@ -16,7 +15,6 @@ import { getTokenRefreshOwnership, isRefreshDelegatedToCLIProxy, } from '../../provider-capabilities'; -import { refreshGeminiToken } from '../gemini-token-refresh'; /** Token refresh result */ export interface ProviderRefreshResult { @@ -66,19 +64,18 @@ export async function refreshToken( }; } - if (provider === 'gemini') { - return await refreshGeminiTokenWrapper(normalizedAccountId); - } - const ownership = getTokenRefreshOwnership(provider); switch (ownership) { case 'cliproxy': // CLIProxyAPIPlus handles refresh for these providers automatically. // No action needed from CCS — report success with delegated flag. return { success: true, delegated: true }; - case 'unsupported': case 'ccs': - // Non-gemini CCS-owned refresh paths are not implemented yet. + return { + success: false, + error: `Token refresh not yet implemented for ${provider}`, + }; + case 'unsupported': return { success: false, error: `Token refresh not yet implemented for ${provider}`, @@ -87,23 +84,3 @@ export async function refreshToken( return assertNever(ownership); } } - -/** - * Wrapper for Gemini token refresh - * Converts gemini-token-refresh.ts format to provider-refreshers format - */ -async function refreshGeminiTokenWrapper(accountId: string): Promise { - const result = await refreshGeminiToken(accountId); - - if (!result.success) { - return { - success: false, - error: result.error, - }; - } - - return { - success: true, - expiresAt: result.expiresAt, - }; -} diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 721012c1..a41ae62b 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -14,6 +14,7 @@ import { getProviderAuthDir } from '../config-generator'; import { getProviderAccounts, getDefaultAccount } from '../account-manager'; import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops'; import { buildEmailBackedAccountId } from '../accounts/email-account-identity'; +import { getTokenRefreshOwnership } from '../provider-capabilities'; import { AuthStatus, PROVIDER_AUTH_PREFIXES, @@ -507,14 +508,16 @@ export function displayAuthStatus(): void { */ export async function ensureTokenValid( provider: CLIProxyProvider, - verbose = false + _verbose = false ): Promise<{ valid: boolean; refreshed: boolean; error?: string }> { - if (provider === 'gemini') { - const { ensureGeminiTokenValid } = await import('./gemini-token-refresh'); - return ensureGeminiTokenValid(verbose); + if (getTokenRefreshOwnership(provider) === 'ccs') { + return { + valid: false, + refreshed: false, + error: `CCS-managed token validation is not available for ${provider}`, + }; } - // For CLIProxy-delegated providers, token refresh is handled by CLIProxyAPIPlus. - // CCS only verifies the token file exists (authentication state). + // Runtime-managed providers refresh upstream. CCS only verifies auth material exists locally. return { valid: isAuthenticated(provider), refreshed: false }; } diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 194faac7..452bd162 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -62,6 +62,8 @@ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): Binary maxRetries: 3, verbose: false, forceVersion: false, + skipAutoUpdate: false, + allowInstall: true, backend, // Pass backend for installer to use correct download URL }; } @@ -115,8 +117,16 @@ export class BinaryManager { } } +export interface EnsureCLIProxyBinaryOptions { + allowInstall?: boolean; + skipAutoUpdate?: boolean; +} + /** Convenience function respecting version pin */ -export async function ensureCLIProxyBinary(verbose = false): Promise { +export async function ensureCLIProxyBinary( + verbose = false, + options: EnsureCLIProxyBinaryOptions = {} +): Promise { const backend = getConfiguredBackend(); // Migrate old shared pin to backend-specific location (one-time migration) @@ -130,11 +140,20 @@ export async function ensureCLIProxyBinary(verbose = false): Promise { version: pinnedVersion, verbose, forceVersion: true, + skipAutoUpdate: options.skipAutoUpdate ?? false, + allowInstall: options.allowInstall ?? true, }, backend ).ensureBinary(); } - return new BinaryManager({ verbose }, backend).ensureBinary(); + return new BinaryManager( + { + verbose, + skipAutoUpdate: options.skipAutoUpdate ?? false, + allowInstall: options.allowInstall ?? true, + }, + backend + ).ensureBinary(); } /** Check if CLIProxyAPI binary is installed */ diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 48700ce5..24e64d5c 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -26,6 +26,10 @@ function log(message: string, verbose: boolean): void { if (verbose) console.error(`[cliproxy] ${message}`); } +function getBackendLabel(backend: CLIProxyBackend): string { + return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; +} + /** * Check if version is above max stable (known unstable) */ @@ -52,7 +56,7 @@ function clampToMaxStable(version: string | undefined, verbose: boolean): string /** Handle auto-update when binary exists */ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise { const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND; - const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + const backendLabel = getBackendLabel(backend); const updateResult = await checkForUpdates(config.binPath, config.version, verbose, backend); const currentVersion = updateResult.currentVersion; const latestVersion = updateResult.latestVersion; @@ -112,6 +116,11 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise return binaryPath; } + if (config.skipAutoUpdate) { + log('Runtime bootstrap mode: skipping auto-update check', verbose); + return binaryPath; + } + try { await handleAutoUpdate(config, verbose); } catch (error) { @@ -125,6 +134,13 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise // Binary missing - download log('Binary not found, downloading...', verbose); + if (!config.allowInstall) { + throw new Error( + `${getBackendLabel(backend)} binary is not installed locally. ` + + 'Run "ccs cliproxy install" when you have network access.' + ); + } + if (!config.forceVersion) { try { const latestVersion = await fetchLatestVersion(verbose, backend); diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 95506089..5276f970 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -1,5 +1,6 @@ import { getDefaultAccount } from './account-manager'; import { getProviderCatalog } from './model-catalog'; +import { normalizeModelIdForProvider } from './model-id-normalizer'; import { fetchCodexQuota } from './quota-fetcher-codex'; import { getCachedQuota, setCachedQuota } from './quota-response-cache'; import type { CodexQuotaResult } from './quota-types'; @@ -7,8 +8,8 @@ import { info, warn } from '../utils/ui'; export type CodexPlanType = CodexQuotaResult['planType']; -const FREE_SAFE_DEFAULT_MODEL = 'gpt-5-codex'; -const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini'; +const FREE_SAFE_DEFAULT_MODEL = 'gpt-5.4'; +const FREE_SAFE_FAST_MODEL = 'gpt-5.4-mini'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i; const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; @@ -19,7 +20,6 @@ const KNOWN_CODEX_MODELS = new Set( const FREE_PLAN_FALLBACKS = new Map([ ['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL], ['gpt-5.3-codex-spark', FREE_SAFE_FAST_MODEL], - ['gpt-5.4', FREE_SAFE_DEFAULT_MODEL], ]); export interface CodexRuntimeFallbackModelMap { @@ -52,13 +52,13 @@ function isKnownCodexModel(model: string): boolean { } export function normalizeCodexModelId(model: string): string { - return model + const stripped = model .trim() .replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '') .replace(CODEX_PAREN_SUFFIX_REGEX, '') .replace(CODEX_EFFORT_SUFFIX_REGEX, '') - .trim() - .toLowerCase(); + .trim(); + return normalizeModelIdForProvider(stripped, 'codex').trim().toLowerCase(); } export function getDefaultCodexModel(): string { diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index dcace13b..dafd0c95 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -19,7 +19,10 @@ import { normalizeProtocol, CLIPROXY_DEFAULT_PORT, } from './port-manager'; -import { getProviderSettingsPath } from './path-resolver'; +import { + getLegacyProviderSettingsPath, + migrateLegacyProviderSettingsIfNeeded, +} from './path-resolver'; import { canonicalizeModelIdForProvider, MODEL_ENV_VAR_KEYS, @@ -49,6 +52,15 @@ const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', ] as const; +const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', +]); + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} function stripCodexEffortSuffix(modelId: string): string { return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); @@ -285,6 +297,63 @@ export function getClaudeEnvVars( return normalizeModelEnvVarsForProvider(mergedEnv, provider); } +function buildCursorProviderSettingsFromLegacy( + legacySettings: Record +): Record { + const defaultEnv = getClaudeEnvVars('cursor'); + const legacyEnvSource = legacySettings.env; + const legacyEnv = isObjectRecord(legacyEnvSource) ? legacyEnvSource : {}; + const migratedEnv: NodeJS.ProcessEnv = { ...defaultEnv }; + + for (const [key, value] of Object.entries(legacyEnv)) { + if (typeof value !== 'string' || CURSOR_LEGACY_ENV_OVERRIDE_KEYS.has(key)) { + continue; + } + migratedEnv[key] = value; + } + + delete migratedEnv.ANTHROPIC_API_KEY; + + return { + ...legacySettings, + env: normalizeModelEnvVarsForProvider(migratedEnv, 'cursor'), + }; +} + +/** + * Resolve the provider settings path, migrating legacy Cursor provider settings into + * the dedicated cliproxy/providers namespace on first access. + */ +export function resolveProviderSettingsPath(provider: CLIProxyProvider): string { + const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + if (provider !== 'cursor' || fs.existsSync(settingsPath)) { + return settingsPath; + } + + const legacySettingsPath = getLegacyProviderSettingsPath(provider); + if (!fs.existsSync(legacySettingsPath)) { + return settingsPath; + } + + try { + const parsed = JSON.parse(fs.readFileSync(legacySettingsPath, 'utf-8')) as unknown; + if (!isObjectRecord(parsed)) { + return settingsPath; + } + + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify(buildCursorProviderSettingsFromLegacy(parsed), null, 2) + '\n', + { mode: 0o600 } + ); + } catch { + // Best-effort migration only. Callers will fall back to defaults if the legacy file is invalid. + } + + return settingsPath; +} + /** * Get global env vars to inject into all third-party profiles. * Returns empty object if disabled. @@ -465,7 +534,7 @@ export function getEffectiveEnvVars( } // Priority 2: Default provider settings file - const settingsPath = getProviderSettingsPath(provider); + const settingsPath = resolveProviderSettingsPath(provider); // Check for user override file if (fs.existsSync(settingsPath)) { @@ -505,7 +574,7 @@ export function getEffectiveEnvVars( * Called during installation/first run */ export function ensureProviderSettings(provider: CLIProxyProvider): void { - const settingsPath = getProviderSettingsPath(provider); + const settingsPath = resolveProviderSettingsPath(provider); const defaultEnv = getClaudeEnvVars(provider); const writeSettings = (settings: Record): void => { @@ -663,7 +732,7 @@ export function getRemoteEnvVars( // Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json) if (Object.keys(userEnvVars).length === 0) { - const settingsPath = getProviderSettingsPath(provider); + const settingsPath = resolveProviderSettingsPath(provider); if (fs.existsSync(settingsPath)) { try { const content = fs.readFileSync(settingsPath, 'utf-8'); diff --git a/src/cliproxy/config/path-resolver.ts b/src/cliproxy/config/path-resolver.ts index 21746fc7..413d6aee 100644 --- a/src/cliproxy/config/path-resolver.ts +++ b/src/cliproxy/config/path-resolver.ts @@ -16,6 +16,13 @@ export function getCliproxyDir(): string { return path.join(getCcsDir(), 'cliproxy'); } +/** + * Get CLIProxy provider settings directory. + */ +export function getCliproxyProvidersDir(): string { + return path.join(getCliproxyDir(), 'providers'); +} + /** * Get CLIProxy writable directory for logs and runtime files. * This directory is set as WRITABLE_PATH env var when spawning CLIProxy. @@ -75,5 +82,40 @@ export function getBinDir(): string { * Example: ~/.ccs/gemini.settings.json */ export function getProviderSettingsPath(provider: CLIProxyProvider): string { + if (provider === 'cursor') { + return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`); + } + + return getLegacyProviderSettingsPath(provider); +} + +/** + * Get CLIProxy provider settings path in the dedicated cliproxy/providers namespace. + * Used only for providers that must not collide with legacy top-level settings files. + */ +export function getDedicatedProviderSettingsPath(provider: CLIProxyProvider): string { + return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`); +} + +/** + * Get legacy provider settings file path in ~/.ccs root. + * This is kept for compatibility reads/migration of older provider settings. + */ +export function getLegacyProviderSettingsPath(provider: CLIProxyProvider): string { return path.join(getCcsDir(), `${provider}.settings.json`); } + +/** + * Resolve the effective provider settings path. + * + * Cursor uses a dedicated cliproxy/providers namespace so it does not collide + * with the deprecated Cursor IDE bridge raw settings file. + */ +export function migrateLegacyProviderSettingsIfNeeded(provider: CLIProxyProvider): string { + if (provider !== 'cursor') { + return getProviderSettingsPath(provider); + } + + const targetPath = getDedicatedProviderSettingsPath(provider); + return targetPath; +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index b7934395..f3d9f1a9 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -66,11 +66,15 @@ import { import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, + getEffectiveClaudeBrowserAttachConfig, resolveBrowserRuntimeEnv, - resolveConfiguredBrowserProfileDir, syncBrowserMcpToConfigDir, } from '../../utils/browser'; -import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader'; +import { + getBrowserConfig, + loadOrCreateUnifiedConfig, + getThinkingConfig, +} from '../../config/unified-config-loader'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { isKiroAuthMethod, @@ -152,6 +156,14 @@ export function readOptionValue( return { present: true, value: next.trim(), missingValue: false }; } +export function hasGitLabTokenLoginFlag(args: string[]): boolean { + return args.includes('--gitlab-token-login') || args.includes('--token-login'); +} + +function getGitLabTokenLoginFlagName(args: string[]): '--gitlab-token-login' | '--token-login' { + return args.includes('--gitlab-token-login') ? '--gitlab-token-login' : '--token-login'; +} + /** * Execute Claude CLI with CLIProxy (main entry point) * @@ -252,8 +264,8 @@ export async function execClaudeWithCLIProxy( // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); - const browserProfileDir = resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR); - if (browserProfileDir) { + const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); + if (browserAttachConfig.enabled) { ensureBrowserMcpOrThrow(); } displayWebSearchStatus(); @@ -321,7 +333,7 @@ export async function execClaudeWithCLIProxy( spinner.start(); try { - binaryPath = await ensureCLIProxyBinary(verbose); + binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); spinner.succeed('CLIProxy binary ready'); } catch (error) { spinner.fail('Failed to prepare CLIProxy'); @@ -353,6 +365,7 @@ export async function execClaudeWithCLIProxy( const addAccount = argsWithoutProxy.includes('--add'); const showAccounts = argsWithoutProxy.includes('--accounts'); const forceImport = argsWithoutProxy.includes('--import'); + const gitlabTokenLogin = hasGitLabTokenLoginFlag(argsWithoutProxy); const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy); const incognitoFlag = argsWithoutProxy.includes('--incognito'); @@ -444,6 +457,16 @@ export async function execClaudeWithCLIProxy( kiroIDCFlow = normalizeKiroIDCFlow(normalized); } + let gitlabBaseUrl: string | undefined; + const gitlabBaseUrlValue = readOptionValue(argsWithoutProxy, '--gitlab-url'); + if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) { + gitlabBaseUrl = gitlabBaseUrlValue.value.trim(); + } else if (gitlabBaseUrlValue.present) { + console.error(fail('--gitlab-url requires a value')); + process.exitCode = 1; + return; + } + if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { console.error(fail('--kiro-auth-method is only valid for ccs kiro')); process.exitCode = 1; @@ -491,6 +514,15 @@ export async function execClaudeWithCLIProxy( return; } + if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') { + const flagName = gitlabTokenLogin + ? getGitLabTokenLoginFlagName(argsWithoutProxy) + : '--gitlab-url'; + console.error(fail(`${flagName} is only valid for ccs gitlab`)); + process.exitCode = 1; + return; + } + // Parse --thinking / --effort flags (aliases; first occurrence wins) const thinkingParse = parseThinkingOverride(argsWithoutProxy); if (thinkingParse.error) { @@ -730,6 +762,8 @@ export async function execClaudeWithCLIProxy( ...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}), ...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}), ...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}), + ...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}), + ...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), ...(noIncognito ? { noIncognito: true } : {}), @@ -775,6 +809,8 @@ export async function execClaudeWithCLIProxy( ...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}), ...(kiroIDCRegion ? { kiroIDCRegion } : {}), ...(kiroIDCFlow ? { kiroIDCFlow } : {}), + ...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}), + ...(gitlabBaseUrl ? { gitlabBaseUrl } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), ...(noIncognito ? { noIncognito: true } : {}), @@ -1018,7 +1054,7 @@ export async function execClaudeWithCLIProxy( syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); if ( - browserProfileDir && + browserAttachConfig.enabled && inheritedClaudeConfigDir && !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) ) { @@ -1121,10 +1157,13 @@ export async function execClaudeWithCLIProxy( } // 11. Build final environment with all proxy chains - const browserRuntimeEnv = browserProfileDir + const browserRuntimeEnv = browserAttachConfig.enabled ? { ...(await resolveBrowserRuntimeEnv({ - profileDir: browserProfileDir, + profileDir: browserAttachConfig.userDataDir, + devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort + ? String(browserAttachConfig.devtoolsPort) + : undefined, })), } : undefined; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 8a4e4564..f4187873 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -186,56 +186,12 @@ export const MODEL_CATALOG: Partial> = codex: { provider: 'codex', displayName: 'Copilot Codex', - defaultModel: 'gpt-5-codex', + defaultModel: 'gpt-5.4', models: [ { - id: 'gpt-5-codex', - name: 'GPT-5 Codex', - description: 'Cross-plan safe Codex default', - thinking: { - type: 'levels', - levels: ['low', 'medium', 'high'], - maxLevel: 'high', - dynamicAllowed: false, - }, - }, - { - id: 'gpt-5-codex-mini', - name: 'GPT-5 Codex Mini', - description: 'Faster and cheaper Codex option', - thinking: { - type: 'levels', - levels: ['low', 'medium', 'high'], - maxLevel: 'high', - dynamicAllowed: false, - }, - }, - { - id: 'gpt-5-mini', - name: 'GPT-5 Mini', - description: 'Legacy mini model ID kept for backwards compatibility', - thinking: { - type: 'levels', - levels: ['low', 'medium', 'high'], - maxLevel: 'high', - dynamicAllowed: false, - }, - }, - { - id: 'gpt-5.1-codex-mini', - name: 'GPT-5.1 Codex Mini', - description: 'Legacy fast Codex mini model', - thinking: { - type: 'levels', - levels: ['low', 'medium', 'high'], - maxLevel: 'high', - dynamicAllowed: false, - }, - }, - { - id: 'gpt-5.1-codex-max', - name: 'GPT-5.1 Codex Max', - description: 'Higher-effort Codex model with xhigh support', + id: 'gpt-5.4', + name: 'GPT-5.4', + description: 'Recommended Codex default for most coding and agentic tasks', thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -244,13 +200,13 @@ export const MODEL_CATALOG: Partial> = }, }, { - id: 'gpt-5.2-codex', - name: 'GPT-5.2 Codex', - description: 'Cross-plan Codex model with xhigh support', + id: 'gpt-5.4-mini', + name: 'GPT-5.4 Mini', + description: 'Fast, lower-cost Codex option for lighter tasks and haiku-tier routing', thinking: { type: 'levels', - levels: ['low', 'medium', 'high', 'xhigh'], - maxLevel: 'xhigh', + levels: ['low', 'medium', 'high'], + maxLevel: 'high', dynamicAllowed: false, }, }, @@ -258,7 +214,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', tier: 'pro', - description: 'Paid Codex plans only', + description: 'Previous flagship coding model whose capabilities now power GPT-5.4', thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -270,7 +226,8 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.3-codex-spark', name: 'GPT-5.3 Codex Spark', tier: 'pro', - description: 'Paid Codex plans only, ultra-fast coding model', + description: + 'Research preview model for ChatGPT Pro subscribers, optimized for near-instant coding iteration', thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -279,10 +236,9 @@ export const MODEL_CATALOG: Partial> = }, }, { - id: 'gpt-5.4', - name: 'GPT-5.4', - tier: 'pro', - description: 'Paid Codex plans only, latest GPT-5 family model', + id: 'gpt-5.2', + name: 'GPT-5.2', + description: 'Previous general-purpose Codex model', thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 21c02ef6..8b99316d 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { InteractivePrompt } from '../utils/prompt'; import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog'; -import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator'; +import { getClaudeEnvVars, resolveProviderSettingsPath } from './config-generator'; import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; @@ -31,7 +31,7 @@ function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string) * Check if provider has user settings configured */ export function hasUserSettings(provider: CLIProxyProvider): boolean { - const settingsPath = getProviderSettingsPath(provider); + const settingsPath = resolveProviderSettingsPath(provider); return fs.existsSync(settingsPath); } @@ -46,7 +46,7 @@ export function getCurrentModel( ): string | undefined { const settingsPath = customSettingsPath ? customSettingsPath.replace(/^~/, os.homedir()) - : getProviderSettingsPath(provider); + : resolveProviderSettingsPath(provider); if (!fs.existsSync(settingsPath)) return undefined; try { @@ -116,7 +116,7 @@ export async function configureProviderModel( // Use custom settings path for CLIProxy variants, otherwise use default provider path const settingsPath = customSettingsPath ? customSettingsPath.replace(/^~/, os.homedir()) - : getProviderSettingsPath(provider); + : resolveProviderSettingsPath(provider); // Skip if already configured with a model (unless --config flag). // A settings file can exist without model env keys (e.g., hook-only writes). @@ -249,7 +249,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise> = Object.freeze({ + 'gpt-5-codex': 'gpt-5.4', + 'gpt-5-codex-mini': 'gpt-5.4-mini', + 'gpt-5-mini': 'gpt-5.4-mini', + 'gpt-5.1-codex': 'gpt-5.2', + 'gpt-5.1-codex-mini': 'gpt-5.4-mini', + 'gpt-5.1-codex-max': 'gpt-5.4', + 'gpt-5.2-codex': 'gpt-5.2', + 'gpt-5.2-codex-mini': 'gpt-5.4-mini', +}); const IFLOW_LEGACY_MODEL_ALIASES: Readonly> = Object.freeze({ 'iflow-default': 'qwen3-coder-plus', 'kimi-k2.5': 'kimi-k2', @@ -92,6 +102,17 @@ export function stripCodexEffortSuffix(model: string): string { return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); } +/** Normalize legacy Codex aliases to the current public Codex model IDs. */ +export function normalizeCodexLegacyModelAliases(model: string): string { + const trimmed = trimModelId(model); + const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); + const replacement = CODEX_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()]; + if (!replacement) { + return trimmed; + } + return `${replacement}${suffix}`; +} + /** * Normalize known legacy iFlow model aliases to current upstream model IDs. * Preserves suffixes such as (budget) and [1m]. @@ -186,6 +207,9 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik if (isIFlowProvider(provider)) { return normalizeIFlowLegacyModelAliases(trimmedModel); } + if (isCodexProvider(provider)) { + return normalizeCodexLegacyModelAliases(trimmedModel); + } if (!isAntigravityProvider(provider)) return trimmedModel; const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel); return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion); diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index ffd8e921..bafd436a 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -33,7 +33,7 @@ export const PROVIDER_CAPABILITIES: Record part.length > 0) + .map((part) => + /^\d+$/.test(part) ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}` + ) + .join(' '); } } @@ -185,8 +192,17 @@ function normalizeRestriction( /** * Parse raw policy limits response into normalized windows. - * Supports both array and object-map `restrictions` shapes. + * Supports both policy-limits `restrictions` payloads and OAuth usage payloads + * keyed by window name (`five_hour`, `seven_day`, `seven_day_sonnet`, ...). */ +function isClaudeOAuthUsageWindowCandidate(key: string, raw: Record): boolean { + if (key === 'extra_usage') return false; + if (asNumber(raw['utilization']) === null) return false; + + const resetAt = raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at'] ?? null; + return resetAt !== null && resetAt !== undefined; +} + export function buildClaudeQuotaWindows(payload: Record): ClaudeQuotaWindow[] { const rawRestrictions = payload['restrictions']; const windows: ClaudeQuotaWindow[] = []; @@ -206,9 +222,19 @@ export function buildClaudeQuotaWindows(payload: Record): Claud if (window) windows.push(window); } } else if (toObject(payload)) { + for (const [key, value] of Object.entries(payload)) { + const raw = toObject(value); + if (!raw) continue; + if (!isClaudeOAuthUsageWindowCandidate(key, raw)) continue; + const window = normalizeRestriction(raw, key); + if (window) windows.push(window); + } + // Some responses may contain a single restriction object directly. - const direct = normalizeRestriction(payload); - if (direct) windows.push(direct); + if (windows.length === 0) { + const direct = normalizeRestriction(payload); + if (direct) windows.push(direct); + } } const seen = new Set(); diff --git a/src/cliproxy/quota-fetcher-claude.ts b/src/cliproxy/quota-fetcher-claude.ts index 60f88d3b..62ce4b35 100644 --- a/src/cliproxy/quota-fetcher-claude.ts +++ b/src/cliproxy/quota-fetcher-claude.ts @@ -1,7 +1,7 @@ /** * Quota Fetcher for Claude (Anthropic) Accounts * - * Fetches policy limits from Claude API and normalizes 5h + weekly windows. + * Fetches OAuth usage windows from Claude API and normalizes 5h + weekly windows. */ import * as path from 'node:path'; @@ -17,11 +17,10 @@ import { export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary }; -export const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits'; +export const CLAUDE_OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; const CLAUDE_QUOTA_TIMEOUT_MS = 10000; const CLAUDE_QUOTA_MAX_ATTEMPTS = 2; -const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota'; -const CLAUDE_OAUTH_UNSUPPORTED_MESSAGE = 'oauth authentication is currently not supported'; +const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20'; interface ClaudeAuthData { accessToken: string; @@ -193,16 +192,6 @@ function buildEmptyResult( }; } -function buildPolicyUnavailableResult(accountId: string): ClaudeQuotaResult { - return { - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - lastUpdated: Date.now(), - accountId, - }; -} - /** * Fetch quota for a single Claude account. */ @@ -230,46 +219,43 @@ export async function fetchClaudeQuota( const timeoutId = setTimeout(() => controller.abort(), CLAUDE_QUOTA_TIMEOUT_MS); try { - const response = await fetch(CLAUDE_POLICY_LIMITS_URL, { + const response = await fetch(CLAUDE_OAUTH_USAGE_URL, { method: 'GET', signal: controller.signal, headers: { Authorization: `Bearer ${authData.accessToken}`, Accept: 'application/json', - 'User-Agent': CLAUDE_USER_AGENT, + 'Content-Type': 'application/json', + 'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER, }, }); clearTimeout(timeoutId); if (verbose) { - console.error(`[i] Claude policy limits status: ${response.status} (attempt ${attempt})`); + console.error(`[i] Claude OAuth usage status: ${response.status} (attempt ${attempt})`); } if (response.status === 401) { const errorMessage = await readResponseErrorMessage(response); - if (errorMessage && errorMessage.toLowerCase().includes(CLAUDE_OAUTH_UNSUPPORTED_MESSAGE)) { - if (verbose) { - console.error( - '[i] Claude policy limits endpoint does not support OAuth tokens; treating quota as unavailable' - ); - } - return buildPolicyUnavailableResult(accountId); - } - - return buildEmptyResult('Authentication required for policy limits', accountId, true); + return buildEmptyResult( + errorMessage || 'Authentication required for Claude OAuth usage', + accountId, + true + ); } if (response.status === 404) { - // Some accounts may not expose policy limits; treat as unavailable but successful. - return buildPolicyUnavailableResult(accountId); + return buildEmptyResult('Claude OAuth usage endpoint not found', accountId); } if (response.status === 403) { - return buildEmptyResult('Not authorized for policy limits', accountId); + return buildEmptyResult('Not authorized for Claude OAuth usage', accountId); } if (!response.ok) { - lastError = `Policy limits API error: ${response.status}`; + lastError = + (await readResponseErrorMessage(response)) || + `Claude OAuth usage API error: ${response.status}`; if ( attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && (response.status === 429 || response.status >= 500) @@ -283,11 +269,11 @@ export async function fetchClaudeQuota( try { payload = await response.json(); } catch { - return buildEmptyResult('Invalid policy limits format', accountId); + return buildEmptyResult('Invalid Claude OAuth usage format', accountId); } if (!toObject(payload)) { - return buildEmptyResult('Invalid policy limits format', accountId); + return buildEmptyResult('Invalid Claude OAuth usage format', accountId); } const windows = buildClaudeQuotaWindows(payload as Record); @@ -304,7 +290,7 @@ export async function fetchClaudeQuota( clearTimeout(timeoutId); lastError = error instanceof Error && error.name === 'AbortError' - ? 'Policy limits request timeout' + ? 'Claude OAuth usage request timeout' : error instanceof Error ? error.message : 'Unknown error'; @@ -313,7 +299,7 @@ export async function fetchClaudeQuota( const errorDetails = error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error); console.error( - `[!] Claude policy limits failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}` + `[!] Claude OAuth usage failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}` ); } diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 43fdd606..811be609 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -624,11 +624,12 @@ export async function fetchCodexQuota( // Extract plan type const planTypeRaw = data.plan_type || data.planType; - let planType: 'free' | 'plus' | 'team' | null = null; + let planType: 'free' | 'plus' | 'pro' | 'team' | null = null; if (planTypeRaw) { const normalized = planTypeRaw.toLowerCase(); if (normalized === 'free') planType = 'free'; else if (normalized === 'plus') planType = 'plus'; + else if (normalized === 'pro') planType = 'pro'; else if (normalized === 'team') planType = 'team'; } diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 27d80d6a..ad88b9c9 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -10,11 +10,12 @@ import * as path from 'node:path'; import { getAuthDir } from './config-generator'; import { getProviderAccounts, getPausedDir, setAccountTier } from './account-manager'; import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils'; -import { refreshGeminiToken } from './auth/gemini-token-refresh'; import { buildGeminiCliBucketsFromParsedBuckets, type GeminiCliParsedBucket, } from './gemini-cli-quota-normalizer'; +import { mapExternalProviderName } from './provider-capabilities'; +import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver'; import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types'; import { buildProviderEntitlementEvidence, @@ -32,6 +33,8 @@ const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERS const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320; const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI'; +const MANAGEMENT_API_TIMEOUT_MS = 5000; +const SECONDARY_REQUEST_TIMEOUT_MS = 2000; /** Auth data extracted from Gemini CLI auth file */ interface GeminiCliAuthData { @@ -93,6 +96,44 @@ interface GeminiCliSupplementaryInfo { normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown'; } +interface ManagementAuthFile { + auth_index?: string | number; + provider?: string; + type?: string; + email?: string; + name?: string; +} + +interface ManagementApiCallResponse { + status_code?: number; + body?: string; +} + +interface ManagedResponse { + status: number; + bodyText: string; + json: unknown; + viaManagement: boolean; +} + +interface ManagedGeminiAuthContext { + authIndexLookupPromise?: Promise; +} + +interface ManagedGeminiAuthLookupResult { + authIndex: string | number | null; + unavailable: boolean; +} + +interface ManagedGeminiRequestResult { + response: ManagedResponse | null; + unavailable: boolean; +} + +function getRemainingTimeoutMs(deadlineMs: number): number { + return Math.max(1, deadlineMs - Date.now()); +} + /** * Extract project ID from account field * Input: "user@example.com (cloudaicompanion-abc-123)" @@ -167,6 +208,244 @@ function isGeminiAuthFile(filename: string): boolean { return false; } +function safeParseJson(bodyText: string): unknown { + try { + return JSON.parse(bodyText); + } catch { + return null; + } +} + +async function readManagedResponse( + response: Response, + viaManagement: boolean +): Promise { + const bodyText = await response.text(); + return { + status: response.status, + bodyText, + json: safeParseJson(bodyText), + viaManagement, + }; +} + +function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean { + const rawProvider = normalizeStringValue(file.provider ?? file.type); + if (!rawProvider || mapExternalProviderName(rawProvider) !== 'gemini') { + return false; + } + + const email = normalizeStringValue(file.email); + const normalizedAccountId = accountId.trim().toLowerCase(); + if (email?.toLowerCase() === normalizedAccountId) { + return true; + } + + const normalizedName = normalizeStringValue(file.name); + if (!normalizedName) { + return false; + } + + const normalizedFileName = normalizedName.toLowerCase(); + const sanitizedAccount = sanitizeEmail(accountId).toLowerCase(); + return ( + normalizedFileName === `gemini-${sanitizedAccount}.json` || + normalizedFileName.startsWith(`${normalizedAccountId}-gen-lang-client-`) || + normalizedFileName.includes(sanitizedAccount) + ); +} + +async function findManagedGeminiAuthIndex( + accountId: string, + timeoutMs: number +): Promise { + const target = getProxyTarget(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), { + signal: controller.signal, + headers: buildManagementHeaders(target), + }); + clearTimeout(timeoutId); + + if (!response.ok) { + return { authIndex: null, unavailable: true }; + } + + const data = (await response.json()) as { files?: ManagementAuthFile[] }; + const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId)); + return { authIndex: match?.auth_index ?? null, unavailable: false }; + } catch { + clearTimeout(timeoutId); + return { authIndex: null, unavailable: true }; + } +} + +async function getManagedGeminiAuthIndex( + accountId: string, + timeoutMs: number, + context?: ManagedGeminiAuthContext +): Promise { + if (!context) { + return await findManagedGeminiAuthIndex(accountId, timeoutMs); + } + + context.authIndexLookupPromise ??= findManagedGeminiAuthIndex(accountId, timeoutMs); + return await context.authIndexLookupPromise; +} + +class GeminiManagedAuthUnavailableError extends Error { + constructor() { + super('CLIProxy managed Gemini auth is temporarily unavailable'); + this.name = 'GeminiManagedAuthUnavailableError'; + } +} + +async function performManagedGeminiRequest( + accountId: string, + url: string, + body: string, + timeoutMs: number, + authContext?: ManagedGeminiAuthContext +): Promise { + const deadlineMs = Date.now() + timeoutMs; + const lookupResult = await getManagedGeminiAuthIndex( + accountId, + getRemainingTimeoutMs(deadlineMs), + authContext + ); + if (lookupResult.unavailable) { + return { response: null, unavailable: true }; + } + + const authIndex = lookupResult.authIndex; + if (authIndex === null || authIndex === undefined) { + return { response: null, unavailable: false }; + } + + const target = getProxyTarget(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs)); + + try { + const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), { + method: 'POST', + signal: controller.signal, + headers: buildManagementHeaders(target, { + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + auth_index: authIndex, + method: 'POST', + url, + header: { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/json', + }, + data: body, + }), + }); + clearTimeout(timeoutId); + + if (!response.ok) { + return { response: null, unavailable: true }; + } + + const apiResponse = (await response.json()) as ManagementApiCallResponse; + const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : ''; + return { + response: { + status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500, + bodyText, + json: safeParseJson(bodyText), + viaManagement: true, + }, + unavailable: false, + }; + } catch { + clearTimeout(timeoutId); + return { response: null, unavailable: true }; + } +} + +async function performGeminiCliRequest( + accountId: string, + accessToken: string, + url: string, + body: string, + preferManagement = false, + authContext?: ManagedGeminiAuthContext +): Promise { + let managementAttempted = false; + let managementUnavailable = false; + + if (preferManagement) { + managementAttempted = true; + const managedResult = await performManagedGeminiRequest( + accountId, + url, + body, + MANAGEMENT_API_TIMEOUT_MS, + authContext + ); + managementUnavailable = managedResult.unavailable; + if (managedResult.response) { + return managedResult.response; + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + managementAttempted ? SECONDARY_REQUEST_TIMEOUT_MS : MANAGEMENT_API_TIMEOUT_MS + ); + + try { + const response = await fetch(url, { + method: 'POST', + signal: controller.signal, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body, + }); + clearTimeout(timeoutId); + + const directResult = await readManagedResponse(response, false); + if (directResult.status !== 401) { + return directResult; + } + + if (managementAttempted) { + if (managementUnavailable) { + throw new GeminiManagedAuthUnavailableError(); + } + return directResult; + } + + const managedResult = await performManagedGeminiRequest( + accountId, + url, + body, + SECONDARY_REQUEST_TIMEOUT_MS, + authContext + ); + if (managedResult.response) { + return managedResult.response; + } + if (managedResult.unavailable) { + throw new GeminiManagedAuthUnavailableError(); + } + return directResult; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } +} + /** * Read auth data from Gemini CLI auth file * Supports multiple file naming conventions and JSON structures @@ -308,42 +587,43 @@ function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | nu } async function fetchGeminiCliSupplementary( + accountId: string, accessToken: string, projectId: string, - verbose: boolean + verbose: boolean, + authContext?: ManagedGeminiAuthContext ): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + const requestBody = JSON.stringify({ + cloudaicompanionProject: projectId, + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + duetProject: projectId, + }, + }); try { - const response = await fetch(GEMINI_CLI_CODE_ASSIST_URL, { - method: 'POST', - signal: controller.signal, - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - cloudaicompanionProject: projectId, - metadata: { - ideType: 'IDE_UNSPECIFIED', - platform: 'PLATFORM_UNSPECIFIED', - pluginType: 'GEMINI', - duetProject: projectId, - }, - }), - }); + const response = await performGeminiCliRequest( + accountId, + accessToken, + GEMINI_CLI_CODE_ASSIST_URL, + requestBody, + false, + authContext + ); - clearTimeout(timeoutId); - - if (!response.ok) { + if (response.status !== 200) { if (verbose) { - console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`); + const source = response.viaManagement ? 'managed' : 'direct'; + console.error( + `[i] Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}` + ); } return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' }; } - const payload = (await response.json()) as GeminiCliCodeAssistResponse; + const payload = response.json as GeminiCliCodeAssistResponse | null; return { tierLabel: resolveGeminiCliTierLabel(payload), tierId: resolveGeminiCliTierId(payload), @@ -351,7 +631,6 @@ async function fetchGeminiCliSupplementary( normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)), }; } catch (error) { - clearTimeout(timeoutId); if (verbose) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`); @@ -680,41 +959,42 @@ async function fetchWithAuthData( }); } - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + const authContext: ManagedGeminiAuthContext = {}; const supplementaryPromise = fetchGeminiCliSupplementary( + accountId, authData.accessToken, authData.projectId, - verbose + verbose, + authContext ); + const requestBody = JSON.stringify({ project: authData.projectId }); try { - const response = await fetch(GEMINI_CLI_QUOTA_URL, { - method: 'POST', - signal: controller.signal, - headers: { - Authorization: `Bearer ${authData.accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ project: authData.projectId }), - }); + const response = await performGeminiCliRequest( + accountId, + authData.accessToken, + GEMINI_CLI_QUOTA_URL, + requestBody, + authData.isExpired, + authContext + ); - clearTimeout(timeoutId); + if (verbose) { + const source = response.viaManagement ? 'managed' : 'direct'; + console.error(`[i] Gemini CLI API status via ${source}: ${response.status}`); + } - if (verbose) console.error(`[i] Gemini CLI API status: ${response.status}`); - - if (!response.ok) { - const bodyText = await response.text(); + if (response.status !== 200) { return buildGeminiCliHttpFailureResult( accountId, authData.projectId, response.status, - bodyText + response.bodyText ); } - const data = (await response.json()) as GeminiCliQuotaResponse; - const rawBuckets = data.buckets || []; + const data = response.json as GeminiCliQuotaResponse | null; + const rawBuckets = data?.buckets || []; const buckets = buildGeminiCliBuckets(rawBuckets); const supplementary = await supplementaryPromise; @@ -744,7 +1024,16 @@ async function fetchWithAuthData( accountId, }; } catch (err) { - clearTimeout(timeoutId); + if (err instanceof GeminiManagedAuthUnavailableError) { + return buildGeminiCliFailureResult(accountId, authData.projectId, { + error: 'Gemini delegated auth refresh is temporarily unavailable', + errorCode: 'managed_auth_unavailable', + errorDetail: err.message, + actionHint: 'Retry later. CLIProxy management could not refresh this Gemini account.', + retryable: true, + }); + } + const errorMsg = err instanceof Error && err.name === 'AbortError' ? 'Request timeout' @@ -778,7 +1067,7 @@ export async function fetchGeminiCliQuota( ): Promise { if (verbose) console.error(`[i] Fetching Gemini CLI quota for ${accountId}...`); - let authData = readGeminiCliAuthData(accountId); + const authData = readGeminiCliAuthData(accountId); if (!authData) { const error = 'Auth file not found for Gemini account'; if (verbose) console.error(`[!] Error: ${error}`); @@ -790,62 +1079,15 @@ export async function fetchGeminiCliQuota( }); } - // Proactive refresh: refresh if expired OR expiring within 5 minutes - const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; - const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); - const shouldRefresh = - authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS; - let refreshedBeforeQuotaFetch = false; - - if (shouldRefresh) { - if (verbose) - console.error( - authData.isExpired - ? '[i] Token expired, refreshing...' - : '[i] Token expiring soon, proactive refresh...' - ); - const refreshResult = await refreshGeminiToken(accountId); - - if (refreshResult.success) { - refreshedBeforeQuotaFetch = true; - if (verbose) console.error('[i] Token refreshed successfully'); - // Re-read auth data after successful refresh - const refreshedAuthData = readGeminiCliAuthData(accountId); - if (refreshedAuthData) { - authData = refreshedAuthData; - } - } else if (authData.isExpired) { - // Only fail if token is actually expired (not just expiring soon) - const error = refreshResult.error || 'Token refresh failed'; - if (verbose) console.error(`[!] Refresh failed: ${error}`); - return buildGeminiCliFailureResult(accountId, authData.projectId, { - error, - errorCode: 'reauth_required', - errorDetail: error, - actionHint: 'Run ccs gemini --auth to reconnect this account.', - needsReauth: true, - retryable: false, - }); - } - // If proactive refresh fails but token isn't expired yet, continue with existing token + if (authData.isExpired && verbose) { + const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); + const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown'; + console.error( + `[i] Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.` + ); } - // First attempt with current token - const result = await fetchWithAuthData(authData, accountId, verbose); - - // Retry once with an account-scoped refresh when the quota endpoint rejects auth. - if (result.needsReauth && !refreshedBeforeQuotaFetch) { - if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); - const refreshResult = await refreshGeminiToken(accountId); - if (refreshResult.success) { - const refreshedAuthData = readGeminiCliAuthData(accountId); - if (refreshedAuthData) { - return await fetchWithAuthData(refreshedAuthData, accountId, verbose); - } - } - } - - return result; + return await fetchWithAuthData(authData, accountId, verbose); } /** diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index a1f0b313..e768ec9b 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -74,8 +74,8 @@ export interface CodexQuotaResult extends QuotaErrorMetadata { windows: CodexQuotaWindow[]; /** Explicit core usage windows (5h + weekly) for easier reset display */ coreUsage?: CodexCoreUsageSummary; - /** Plan type: free, plus, team, or null if unknown */ - planType: 'free' | 'plus' | 'team' | null; + /** Plan type: free, plus, pro, team, or null if unknown */ + planType: 'free' | 'plus' | 'pro' | 'team' | null; /** Timestamp of fetch */ lastUpdated: number; /** Error message if fetch failed */ diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index 0ef4d2e7..b97add9f 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -216,7 +216,10 @@ export async function ensureCliproxyService( // 1. Ensure binary exists let binaryPath: string; try { - binaryPath = await ensureCLIProxyBinary(verbose); + binaryPath = await ensureCLIProxyBinary(verbose, { + allowInstall: false, + skipAutoUpdate: true, + }); log(`Binary ready: ${binaryPath}`); } catch (error) { const err = error as Error; diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index aae1912f..ea723480 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -50,6 +50,10 @@ export interface BinaryManagerConfig { verbose: boolean; /** Force specific version (skip auto-upgrade to latest) */ forceVersion: boolean; + /** Skip background update checks on runtime bootstrap paths */ + skipAutoUpdate: boolean; + /** Allow downloading/installing the binary when it is missing */ + allowInstall: boolean; /** Backend variant (original vs plus) */ backend?: CLIProxyBackend; } @@ -122,6 +126,10 @@ export interface DownloadResult { * - ghcp: GitHub Copilot via Device Code (OAuth through CLIProxyAPIPlus) * - claude: Claude (Anthropic) via OAuth * - kimi: Kimi (Moonshot AI) via Device Code OAuth + * - cursor: Cursor via PKCE browser polling + * - gitlab: GitLab Duo via OAuth or PAT + * - codebuddy: Tencent CodeBuddy via browser polling + * - kilo: Kilo AI via device flow */ export type CLIProxyProvider = | 'gemini' @@ -132,12 +140,16 @@ export type CLIProxyProvider = | 'kiro' | 'ghcp' | 'claude' - | 'kimi'; + | 'kimi' + | 'cursor' + | 'gitlab' + | 'codebuddy' + | 'kilo'; /** * CLIProxy backend selection - * - original: CLIProxyAPI (no Kiro/ghcp support) - * - plus: CLIProxyAPIPlus (Kiro/ghcp support, default) + * - original: CLIProxyAPI (legacy provider subset) + * - plus: CLIProxyAPIPlus (expanded provider support, default) */ export type CLIProxyBackend = 'original' | 'plus'; @@ -149,7 +161,14 @@ export type CliproxyRoutingStrategy = 'round-robin' | 'fill-first'; /** * Providers that require CLIProxyAPIPlus backend */ -export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = ['kiro', 'ghcp']; +export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = [ + 'kiro', + 'ghcp', + 'cursor', + 'gitlab', + 'codebuddy', + 'kilo', +]; /** * CLIProxy config.yaml structure (minimal) diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts new file mode 100644 index 00000000..d9c4ccf6 --- /dev/null +++ b/src/commands/browser-command.ts @@ -0,0 +1,149 @@ +import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser'; +import { getNodePlatformKey } from '../utils/browser/platform'; +import { color, dim, header, initUI, subheader } from '../utils/ui'; + +type HelpWriter = (line: string) => void; + +function summarizeBrowserHealth(status: BrowserStatusPayload): { + label: 'ready' | 'partial' | 'action required'; + exitCode: 0 | 1; +} { + const claudeNeedsAttention = status.claude.enabled && status.claude.state !== 'ready'; + if (claudeNeedsAttention) { + return { label: 'action required', exitCode: 1 }; + } + + if (status.codex.enabled && status.codex.state !== 'enabled') { + return { label: 'partial', exitCode: 0 }; + } + + return { label: 'ready', exitCode: 0 }; +} + +function writeCommandTable(writeLine: HelpWriter): void { + writeLine(subheader('Commands')); + writeLine( + ` ${color('ccs browser status', 'command')} Show Claude attach and Codex browser readiness` + ); + writeLine( + ` ${color('ccs browser doctor', 'command')} Explain what is missing and how to fix it` + ); + writeLine(''); +} + +function writeIntro(writeLine: HelpWriter): void { + writeLine(' Claude Browser Attach reuses a local Chrome session for Claude-target launches.'); + writeLine( + ' Codex Browser Tools inject managed Playwright MCP overrides into Codex-target launches.' + ); + writeLine(''); +} + +function writeClaudeStatus( + status: BrowserStatusPayload['claude'], + writeLine: HelpWriter, + includeLaunchGuidance: boolean +): void { + writeLine(subheader('Claude Browser Attach')); + writeLine(` State: ${status.state}`); + writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`); + writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`); + writeLine(` User data dir: ${status.effectiveUserDataDir}`); + writeLine(` DevTools port: ${status.devtoolsPort}`); + writeLine(` Managed MCP: ${status.managedMcpServerName}`); + writeLine(` Managed path: ${status.managedMcpServerPath}`); + if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) { + writeLine(` DevTools endpoint: ${status.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL}`); + } + writeLine(` Detail: ${status.detail}`); + writeLine(` Next step: ${status.nextStep}`); + if (includeLaunchGuidance && status.enabled && status.state !== 'ready') { + const platform = getNodePlatformKey(); + writeLine(` Launch command (${platform}): ${status.launchCommands[platform]}`); + } + writeLine(''); +} + +function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: HelpWriter): void { + writeLine(subheader('Codex Browser Tools')); + writeLine(` State: ${status.state}`); + writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`); + writeLine(` Managed server: ${status.serverName}`); + writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`); + writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`); + if (status.version) { + writeLine(` Codex version: ${status.version}`); + } + writeLine(` Detail: ${status.detail}`); + writeLine(` Next step: ${status.nextStep}`); + writeLine(''); +} + +export async function showBrowserHelp(writeLine: HelpWriter = console.log): Promise { + await initUI(); + writeLine(header('CCS Browser Help')); + writeLine(''); + writeIntro(writeLine); + writeLine(subheader('Usage')); + writeLine(` ${color('ccs browser ', 'command')}`); + writeLine(` ${color('ccs help browser', 'command')}`); + writeLine(''); + writeCommandTable(writeLine); + writeLine(subheader('What Each Lane Does')); + writeLine(' Claude Browser Attach expects a Chrome user-data dir and remote debugging port.'); + writeLine(' Codex Browser Tools depend on a Codex build that supports --config overrides.'); + writeLine(''); + writeLine(subheader('Examples')); + writeLine(` ${color('ccs browser status', 'command')} ${dim('# Quick readiness snapshot')}`); + writeLine( + ` ${color('ccs browser doctor', 'command')} ${dim('# Detailed troubleshooting output')}` + ); + writeLine( + ` ${color('ccs config', 'command')} ${dim('# Open Settings > Browser in the dashboard')}` + ); + writeLine(''); +} + +export async function handleBrowserCommand( + args: string[], + writeLine: HelpWriter = console.log +): Promise { + const subcommand = args[0]; + if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') { + await showBrowserHelp(writeLine); + return; + } + + if (subcommand !== 'status' && subcommand !== 'doctor') { + await initUI(); + writeLine(color(`Unknown browser subcommand: ${subcommand}`, 'error')); + writeLine(''); + writeLine(` ${dim('Supported subcommands: status, doctor')}`); + writeLine(''); + process.exitCode = 1; + return; + } + + await initUI(); + const status = await getBrowserStatus(); + + writeLine(header(`ccs browser ${subcommand}`)); + writeLine(''); + writeIntro(writeLine); + + if (subcommand === 'doctor') { + const summary = summarizeBrowserHealth(status); + writeLine(subheader('Overall')); + writeLine(` Claude Browser Attach: ${status.claude.title}`); + writeLine(` Codex Browser Tools: ${status.codex.title}`); + writeLine(` Result: ${summary.label}`); + writeLine(''); + } + + writeClaudeStatus(status.claude, writeLine, subcommand === 'doctor'); + writeCodexStatus(status.codex, writeLine); + + if (subcommand === 'doctor') { + process.exitCode = summarizeBrowserHealth(status).exitCode; + } +} diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 3b14c3b7..5b37e223 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -1,8 +1,13 @@ import { COPILOT_SUBCOMMANDS } from '../copilot/constants'; -import { CURSOR_SUBCOMMANDS } from '../cursor/constants'; import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; -export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets'; +export type HelpTopicName = + | 'profiles' + | 'providers' + | 'kiro' + | 'browser' + | 'completion' + | 'targets'; export interface HelpTopicEntry { name: HelpTopicName; @@ -26,6 +31,7 @@ export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [ { name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' }, { name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' }, { name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' }, + { name: 'browser', summary: 'Claude Browser Attach and Codex Browser Tools guidance' }, { name: 'completion', summary: 'Shell completion install, refresh, and testing' }, { name: 'targets', summary: 'Claude, Droid, and Codex target routing' }, ] as const; @@ -105,7 +111,19 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [ }, { name: 'cursor', - summary: 'Run or manage the Cursor bridge', + summary: 'Run Cursor via CLIProxy or manage Cursor provider auth', + group: 'runtime', + visibility: 'public', + }, + { + name: 'proxy', + summary: 'Start or inspect the OpenAI-compatible local proxy', + group: 'runtime', + visibility: 'public', + }, + { + name: 'browser', + summary: 'Inspect Claude Browser Attach and Codex Browser Tools readiness', group: 'runtime', visibility: 'public', }, @@ -183,6 +201,10 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO ghcp: 'GitHub Copilot via CLIProxy OAuth', claude: 'Claude via CLIProxy OAuth', kimi: 'Kimi via CLIProxy OAuth', + cursor: 'Cursor via CLIProxy OAuth', + gitlab: 'GitLab Duo via CLIProxy OAuth', + codebuddy: 'CodeBuddy via CLIProxy OAuth', + kilo: 'Kilo AI via CLIProxy OAuth', }[name] || 'CLIProxy OAuth provider', }) ); @@ -252,6 +274,7 @@ export const CLIPROXY_SUBCOMMANDS = [ ] as const; export const CONFIG_SUBCOMMANDS = ['auth', 'channels', 'image-analysis', 'thinking'] as const; export const DOCKER_SUBCOMMANDS = ['up', 'down', 'status', 'update', 'logs', 'config'] as const; +export const PROXY_SUBCOMMANDS = ['start', 'stop', 'status', 'activate'] as const; export const TOKENS_FLAGS = [ '--show', '--api-key', @@ -297,6 +320,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly', + 'Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.', + `Supported auth path: ${CLIPROXY_CURSOR_COMMAND} --auth`, + 'Supported dashboard path: ccs config -> CLIProxy -> Cursor', '', - 'Subcommands:', - ' auth Import Cursor IDE authentication token', - ' status Show integration, authentication, and daemon status', + `Usage: ${LEGACY_CURSOR_COMMAND} `, + '', + 'Subcommands (deprecated compatibility for the local reverse-engineered bridge):', + ' auth Import Cursor IDE authentication token (deprecated)', + ' status Show legacy integration, authentication, and daemon status', ' probe Run a live authenticated runtime probe', ' models List available models', - ' start Start cursor daemon', - ' stop Stop cursor daemon', - ' enable Enable cursor integration in unified config', - ' disable Disable cursor integration in unified config', + ' start Start local cursor daemon', + ' stop Stop local cursor daemon', + ' enable Enable legacy cursor integration in unified config', + ' disable Disable legacy cursor integration in unified config', ' help Show this help message', '', - 'Runtime entry:', - ' ccs cursor [claude args] # Run Claude via the local Cursor proxy', + 'Supported CLIProxy path:', + ` ${CLIPROXY_CURSOR_COMMAND} --auth # Authenticate Cursor via CLIProxy`, + ` ${CLIPROXY_CURSOR_COMMAND} --accounts # Manage CLIProxy Cursor accounts`, + ` ${CLIPROXY_CURSOR_COMMAND} --config # Open CLIProxy Cursor settings`, '', - 'Auth options:', - ' ccs cursor auth # Auto-detect from Cursor SQLite', - ' ccs cursor auth --manual --token --machine-id ', + 'Legacy runtime entry (deprecated compatibility):', + ` ${LEGACY_CURSOR_COMMAND} [claude args] # Run Claude via the local Cursor bridge`, '', - 'Quick start:', - ' 1. ccs cursor enable # Enable integration', - ' 2. ccs cursor auth # Import Cursor IDE token', - ' 3. ccs cursor start # Start daemon', - ' 4. ccs cursor probe # Verify live runtime health', - ' 5. ccs cursor "task" # Run Claude through Cursor', - ' 6. ccs cursor status # Inspect auth/daemon wiring', + 'Legacy auth options:', + ` ${LEGACY_CURSOR_COMMAND} auth # Auto-detect from Cursor SQLite (deprecated)`, + ` ${LEGACY_CURSOR_COMMAND} auth --manual --token --machine-id `, '', - 'Or use the web UI: ccs config -> Cursor page', + 'Legacy bridge quick start:', + ` 1. ${LEGACY_CURSOR_COMMAND} enable # Deprecated compatibility: enable local bridge`, + ` 2. ${LEGACY_CURSOR_COMMAND} auth # Deprecated compatibility: import Cursor IDE token`, + ` 3. ${LEGACY_CURSOR_COMMAND} start # Start local daemon`, + ` 4. ${LEGACY_CURSOR_COMMAND} probe # Verify live runtime health`, + ` 5. ${LEGACY_CURSOR_COMMAND} "task" # Run Claude through the local bridge`, + ` 6. ${LEGACY_CURSOR_COMMAND} status # Inspect auth/daemon wiring`, + '', + 'Web UI: ccs config -> Deprecated -> Cursor IDE', '', ]); @@ -100,10 +112,13 @@ export function renderCursorStatus( console.log(''); console.log('Client setup:'); console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`); - console.log(' Runtime entry: ccs cursor [claude args]'); - console.log(' Live probe: ccs cursor probe'); - console.log(' Status command: ccs cursor status'); - console.log(' Help command: ccs cursor help'); + console.log( + ` Runtime entry: ${LEGACY_CURSOR_COMMAND} [claude args] (deprecated compatibility)` + ); + console.log(` Supported auth: ${CLIPROXY_CURSOR_COMMAND} --auth`); + console.log(` Live probe: ${LEGACY_CURSOR_COMMAND} probe`); + console.log(` Status command: ${LEGACY_CURSOR_COMMAND} status`); + console.log(` Help command: ${LEGACY_CURSOR_COMMAND} help`); if (isReady) { return; @@ -113,15 +128,16 @@ export function renderCursorStatus( console.log('Next steps:'); if (!cursorConfig.enabled) { - console.log(' - Enable: ccs cursor enable'); + console.log(` - Enable: ${LEGACY_CURSOR_COMMAND} enable`); } if (!authStatus.authenticated || authStatus.expired) { - console.log(' - Auth: ccs cursor auth'); + console.log(` - Supported: ${CLIPROXY_CURSOR_COMMAND} --auth`); + console.log(` - Legacy auth: ${LEGACY_CURSOR_COMMAND} auth`); } if (!daemonStatus.running) { - console.log(' - Start: ccs cursor start'); + console.log(` - Start: ${LEGACY_CURSOR_COMMAND} start`); } - console.log(' - Help: ccs cursor help'); + console.log(` - Help: ${LEGACY_CURSOR_COMMAND} help`); } export function renderCursorModels(models: CursorModel[], defaultModel: string): void { diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index b0f8f12d..80c2f260 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -26,6 +26,18 @@ import { } from './cursor-command-display'; import { ok, fail, info } from '../utils/ui'; +const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor'; +const CLIPROXY_CURSOR_COMMAND = 'ccs cursor'; + +function printLegacyCursorDeprecationNotice(): void { + console.log( + info( + `Deprecated compatibility path. \`${CLIPROXY_CURSOR_COMMAND}\` now belongs to the CLIProxy Cursor provider; use \`${LEGACY_CURSOR_COMMAND}\` for the old bridge.` + ) + ); + console.log(''); +} + /** * Handle cursor subcommand. */ @@ -114,6 +126,7 @@ function printAutoDetectFailure(result: { * Handle auth subcommand. */ async function handleAuth(args: string[]): Promise { + printLegacyCursorDeprecationNotice(); const manual = args.includes('--manual'); if (manual) { @@ -125,7 +138,7 @@ async function handleAuth(args: string[]): Promise { if (!accessToken || !machineId) { console.error( fail( - 'Manual auth requires both token and machine ID.\n\nExample:\n ccs cursor auth --manual --token --machine-id ' + `Manual auth requires both token and machine ID.\n\nExample:\n ${LEGACY_CURSOR_COMMAND} auth --manual --token --machine-id ` ) ); return 1; @@ -146,8 +159,9 @@ async function handleAuth(args: string[]): Promise { console.log(ok('Cursor credentials imported (manual mode)')); console.log(''); console.log('Next steps:'); - console.log(' 1. Enable integration: ccs cursor enable'); - console.log(' 2. Start daemon: ccs cursor start'); + console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`); + console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`); + console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`); return 0; } @@ -168,9 +182,10 @@ async function handleAuth(args: string[]): Promise { console.log(ok('Auto-detected Cursor credentials')); console.log(''); console.log('Next steps:'); - console.log(' 1. Enable integration: ccs cursor enable'); - console.log(' 2. Start daemon: ccs cursor start'); - console.log(' 3. Check status: ccs cursor status'); + console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`); + console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`); + console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`); + console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`); return 0; } @@ -178,13 +193,14 @@ async function handleAuth(args: string[]): Promise { printAutoDetectFailure(autoResult); console.log(''); console.log('Manual fallback:'); - console.log(' ccs cursor auth --manual --token --machine-id '); + console.log(` ${LEGACY_CURSOR_COMMAND} auth --manual --token --machine-id `); console.log(''); return 1; } async function handleStatus(): Promise { + printLegacyCursorDeprecationNotice(); const cursorConfig = getCursorConfig(); const authStatus = checkAuthStatus(); const daemonStatus = await getDaemonStatus(cursorConfig.port); @@ -193,6 +209,7 @@ async function handleStatus(): Promise { } async function handleProbe(): Promise { + printLegacyCursorDeprecationNotice(); const cursorConfig = getCursorConfig(); const result = await probeCursorRuntime(cursorConfig); renderCursorProbe(result); @@ -200,6 +217,7 @@ async function handleProbe(): Promise { } async function handleModels(): Promise { + printLegacyCursorDeprecationNotice(); const cursorConfig = getCursorConfig(); const models = await getAvailableModels(cursorConfig.port); const defaultModel = getDefaultModel(); @@ -211,20 +229,21 @@ async function handleModels(): Promise { * Handle start subcommand. */ async function handleStart(): Promise { + printLegacyCursorDeprecationNotice(); const cursorConfig = getCursorConfig(); if (!cursorConfig.enabled) { - console.error(fail('Cursor integration is disabled. Run: ccs cursor enable')); + console.error(fail(`Cursor integration is disabled. Run: ${LEGACY_CURSOR_COMMAND} enable`)); return 1; } const authStatus = checkAuthStatus(); if (!authStatus.authenticated) { - console.error(fail('Not authenticated. Run: ccs cursor auth')); + console.error(fail(`Not authenticated. Run: ${LEGACY_CURSOR_COMMAND} auth`)); return 1; } if (authStatus.expired) { - console.error(fail('Credentials expired. Run: ccs cursor auth')); + console.error(fail(`Credentials expired. Run: ${LEGACY_CURSOR_COMMAND} auth`)); return 1; } @@ -248,6 +267,7 @@ async function handleStart(): Promise { * Handle stop subcommand. */ async function handleStop(): Promise { + printLegacyCursorDeprecationNotice(); console.log(info('Stopping cursor daemon...')); const result = await stopDaemon(); @@ -265,6 +285,7 @@ async function handleStop(): Promise { * Handle enable subcommand. */ async function handleEnable(): Promise { + printLegacyCursorDeprecationNotice(); mutateUnifiedConfig((config) => { if (!config.cursor) { config.cursor = { ...DEFAULT_CURSOR_CONFIG }; @@ -276,9 +297,10 @@ async function handleEnable(): Promise { console.log(ok('Cursor integration enabled')); console.log(''); console.log('Next steps:'); - console.log(' 1. Authenticate: ccs cursor auth'); - console.log(' 2. Start daemon: ccs cursor start'); - console.log(' 3. Check status: ccs cursor status'); + console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`); + console.log(` 1. Authenticate: ${LEGACY_CURSOR_COMMAND} auth`); + console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`); + console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`); return 0; } @@ -287,6 +309,7 @@ async function handleEnable(): Promise { * Handle disable subcommand. */ async function handleDisable(): Promise { + printLegacyCursorDeprecationNotice(); mutateUnifiedConfig((config) => { if (config.cursor) { config.cursor.enabled = false; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index e3912a11..20fd265a 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -1,4 +1,5 @@ import packageJson from '../../package.json'; +import type { CLIProxyProvider } from '../cliproxy'; import { color, dim, header, initUI, subheader } from '../utils/ui'; import { BUILTIN_PROVIDER_SHORTCUTS, @@ -82,10 +83,80 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise { ], writeLine ); + writeCommandTable( + 'GitLab Duo Flags', + [ + { + name: 'ccs gitlab --auth --gitlab-token-login', + summary: 'Authenticate with a GitLab Personal Access Token', + }, + { + name: 'ccs gitlab --auth --token-login', + summary: 'Legacy alias for GitLab PAT login (still supported)', + }, + { + name: 'ccs gitlab --auth --gitlab-url ', + summary: 'Use a self-hosted GitLab base URL during OAuth or PAT auth', + }, + ], + writeLine + ); writeLine(` ${dim('Deep help: ccs cliproxy --help | ccs api --help')}`); writeLine(''); } +export async function showProviderShortcutHelp( + provider: CLIProxyProvider, + writeLine: HelpWriter = console.log +): Promise { + if (provider === 'kiro') { + await showKiroHelp(writeLine); + return; + } + + await initUI(); + + const providerEntry = BUILTIN_PROVIDER_SHORTCUTS.find((entry) => entry.name === provider); + writeLine(header(`CCS ${provider} Shortcut Help`)); + writeLine(''); + writeLine(` ${providerEntry?.summary || 'CLIProxy OAuth provider shortcut'}.`); + writeLine(''); + writeCommandTable( + 'Common Commands', + [ + { name: `ccs ${provider} --auth`, summary: 'Authenticate the provider account via CLIProxy' }, + { name: `ccs ${provider} --accounts`, summary: 'List or manage stored CLIProxy accounts' }, + { name: `ccs ${provider} --config`, summary: 'Open the provider config flow' }, + { name: `ccs ${provider} "task"`, summary: 'Run Claude through this provider shortcut' }, + ], + writeLine + ); + + if (provider === 'gitlab') { + writeCommandTable( + 'GitLab Duo Flags', + [ + { + name: '--gitlab-token-login', + summary: 'Use a GitLab Personal Access Token instead of browser OAuth', + }, + { + name: '--token-login', + summary: 'Legacy alias for `--gitlab-token-login`', + }, + { + name: '--gitlab-url ', + summary: 'Target a self-hosted GitLab base URL', + }, + ], + writeLine + ); + } + + writeLine(` ${dim('See also: ccs help providers | ccs cliproxy --help')}`); + writeLine(''); +} + async function showKiroHelp(writeLine: HelpWriter): Promise { await initUI(); writeLine(header('CCS Kiro Help')); @@ -178,7 +249,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr writeLine(header(`CCS CLI v${packageJson.version}`)); writeLine(''); - writeLine(' Claude profile switching, provider routing, and compatible runtime bridges.'); + writeLine(' Claude profile switching, provider routing, runtime bridges, and browser tooling.'); writeLine(''); writeLine(subheader('Usage')); @@ -208,6 +279,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr [ { name: 'ccs help profiles', summary: getTopicSummary('profiles') }, { name: 'ccs help providers', summary: getTopicSummary('providers') }, + { name: 'ccs help browser', summary: getTopicSummary('browser') }, { name: 'ccs help completion', summary: getTopicSummary('completion') }, { name: 'ccs help targets', summary: getTopicSummary('targets') }, { name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' }, @@ -215,6 +287,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr name: 'ccs cliproxy --help', summary: 'Deep help for variants, routing, quota, and lifecycle', }, + { name: 'ccs proxy --help', summary: 'Deep help for the OpenAI-compatible local proxy' }, { name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' }, { name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' }, { name: 'ccs copilot --help', summary: 'Deep help for GitHub Copilot commands' }, @@ -256,6 +329,10 @@ export async function handleHelpRoute( await showTargetsHelp(writeLine); return; } + if (topic === 'browser') { + await (await import('./browser-command')).showBrowserHelp(writeLine); + return; + } if (topic === 'completion') { const { showShellCompletionHelp } = await import('./shell-completion-command'); showShellCompletionHelp(writeLine); @@ -270,11 +347,13 @@ export async function handleHelpRoute( await new AuthCommands().showHelp(); }, cleanup: async () => (await import('./cleanup-command')).handleCleanupCommand(['--help']), + browser: async () => (await import('./browser-command')).showBrowserHelp(writeLine), cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(), copilot: async () => process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])), - cursor: async () => - process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])), + cursor: async () => await showProviderShortcutHelp('cursor', writeLine), + proxy: async () => + process.exit(await (await import('./proxy-command')).handleProxyCommand(['--help'])), docker: async () => (await import('./docker/help-subcommand')).showHelp(), migrate: async () => (await import('./migrate-command')).printMigrateHelp(), setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']), diff --git a/src/commands/index.ts b/src/commands/index.ts index 22d62035..ef9de709 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -13,6 +13,7 @@ export { handleDockerCommand } from './docker-command'; export { handleHelpCommand } from './help-command'; export { handleInstallCommand } from './install-command'; export { handleMigrateCommand } from './migrate-command'; +export { handleProxyCommand } from './proxy-command'; export { handleShellCompletionCommand } from './shell-completion-command'; export { handleSyncCommand } from './sync-command'; export { handleUpdateCommand } from './update-command'; diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts new file mode 100644 index 00000000..51c406f9 --- /dev/null +++ b/src/commands/proxy-command.ts @@ -0,0 +1,182 @@ +import { detectShell, formatExportLine } from './env-command'; +import { getSettingsPath, loadSettings } from '../utils/config-manager'; +import { expandPath } from '../utils/helpers'; +import { fail, info, ok } from '../utils/ui'; +import { + buildOpenAICompatProxyEnv, + getOpenAICompatProxyStatus, + resolveOpenAICompatProfileConfig, + startOpenAICompatProxy, + stopOpenAICompatProxy, +} from '../proxy'; + +function parseOptionValue(args: string[], key: string): string | undefined { + const exactIndex = args.findIndex((arg) => arg === key); + if (exactIndex !== -1 && args[exactIndex + 1]) { + return args[exactIndex + 1]; + } + + const prefix = `${key}=`; + const withEquals = args.find((arg) => arg.startsWith(prefix)); + return withEquals ? withEquals.slice(prefix.length) : undefined; +} + +function showHelp(): number { + console.log('OpenAI-Compatible Proxy'); + console.log(''); + console.log('Usage: ccs proxy [profile] [options]'); + console.log(''); + console.log('Commands:'); + console.log( + ' start Start the local proxy for an OpenAI-compatible settings profile' + ); + console.log(' stop Stop the running proxy'); + console.log(' status Show daemon status and active profile'); + console.log(' activate Print shell exports for the running proxy'); + console.log(''); + console.log('Options:'); + console.log(' --port Override the local proxy port (default: 3456)'); + console.log(' --host Bind the proxy server to a specific host (default: 127.0.0.1)'); + console.log(' --shell activate only: auto|bash|zsh|fish|powershell'); + console.log(' --fish activate only: shorthand for --shell fish'); + console.log(' --insecure Disable upstream TLS verification'); + console.log(''); + console.log('Examples:'); + console.log(' ccs proxy start hf'); + console.log(' eval "$(ccs proxy activate)"'); + console.log(' ccs proxy activate --fish'); + console.log(' ccs proxy status'); + console.log(' ccs proxy stop'); + console.log(''); + return 0; +} + +function resolveProfile(profileName: string) { + const settingsPath = expandPath(getSettingsPath(profileName)); + const settings = loadSettings(settingsPath); + const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, settings.env || {}); + if (!profile) { + throw new Error(`Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`); + } + return profile; +} + +async function handleStart(args: string[]): Promise { + const profileName = args.find((arg) => !arg.startsWith('-')); + if (!profileName) { + console.error( + fail('Usage: ccs proxy start [--port ] [--host ] [--insecure]') + ); + return 1; + } + + const portValue = parseOptionValue(args, '--port'); + const host = parseOptionValue(args, '--host'); + const port = portValue ? Number.parseInt(portValue, 10) || 3456 : undefined; + let profile; + try { + profile = resolveProfile(profileName); + } catch (error) { + console.error(fail((error as Error).message)); + return 1; + } + + const result = await startOpenAICompatProxy(profile, { + ...(port ? { port } : {}), + ...(host ? { host } : {}), + insecure: args.includes('--insecure'), + }); + + if (!result.success) { + console.error(fail(result.error || 'Failed to start proxy')); + return 1; + } + + console.log( + result.alreadyRunning + ? info(`Proxy already running on port ${result.port}`) + : ok(`Proxy started on port ${result.port}`) + ); + return 0; +} + +async function handleStatus(): Promise { + const status = await getOpenAICompatProxyStatus(); + if (!status.running) { + console.log(info('Proxy is not running')); + return 0; + } + + console.log(ok(`Proxy running on port ${status.port}`)); + if (status.host) { + console.log(` Host: ${status.host}`); + console.log(` Local URL: http://${status.host}:${status.port}`); + } + console.log(` Profile: ${status.profileName}`); + console.log(` Base URL: ${status.baseUrl}`); + if (status.model) { + console.log(` Model: ${status.model}`); + } + if (status.pid) { + console.log(` PID: ${status.pid}`); + } + return 0; +} + +async function handleActivate(args: string[]): Promise { + const status = await getOpenAICompatProxyStatus(); + if (!status.running || !status.profileName || !status.port || !status.authToken) { + console.error(fail('Proxy is not running. Start it with: ccs proxy start ')); + return 1; + } + + const shell = detectShell(args.includes('--fish') ? 'fish' : parseOptionValue(args, '--shell')); + let profile; + try { + profile = resolveProfile(status.profileName); + } catch (error) { + console.error(fail((error as Error).message)); + return 1; + } + + const env = buildOpenAICompatProxyEnv( + profile, + status.port, + status.authToken, + undefined, + status.host || '127.0.0.1' + ); + Object.entries(env).forEach(([key, value]) => { + console.log(formatExportLine(shell, key, value)); + }); + return 0; +} + +export async function handleProxyCommand(args: string[]): Promise { + const subcommand = args[0]; + switch (subcommand) { + case undefined: + case 'help': + case '--help': + case '-h': + return showHelp(); + case 'start': + return handleStart(args.slice(1)); + case 'stop': { + const result = await stopOpenAICompatProxy(); + if (!result.success) { + console.error(fail(result.error || 'Failed to stop proxy')); + return 1; + } + console.log(ok('Proxy stopped')); + return 0; + } + case 'status': + return handleStatus(); + case 'activate': + return handleActivate(args.slice(1)); + default: + console.error(fail(`Unknown proxy subcommand: ${subcommand}`)); + return 1; + } +} diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts index be45acc7..6d09848f 100644 --- a/src/commands/root-command-router.ts +++ b/src/commands/root-command-router.ts @@ -105,6 +105,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ await handleSyncCommand(); }, }, + { + name: 'browser', + handle: async (args) => { + const { handleBrowserCommand } = await import('./browser-command'); + await handleBrowserCommand(args); + }, + }, { name: 'cleanup', aliases: ['--cleanup'], @@ -136,6 +143,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ await handleCliproxyCommand(args); }, }, + { + name: 'proxy', + handle: async (args) => { + const { handleProxyCommand } = await import('./proxy-command'); + process.exit(await handleProxyCommand(args)); + }, + }, { name: 'docker', handle: async (args) => { diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index 8705d63a..ed80ecd5 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -9,14 +9,24 @@ export const RESERVED_PROFILE_NAMES = [ 'agy', 'qwen', 'iflow', + 'kiro', + 'ghcp', + 'claude', + 'kimi', + 'gitlab', + 'codebuddy', + 'kilo', // Copilot API (GitHub Copilot proxy) 'copilot', // Cursor IDE (Cursor proxy daemon) 'cursor', + 'legacy-cursor', + 'legacy', // CLI commands and special names 'default', 'config', 'cliproxy', + 'proxy', ] as const; export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number]; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 5865ba5d..50835435 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -23,6 +23,7 @@ import { DEFAULT_THINKING_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, + DEFAULT_BROWSER_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, DEFAULT_LOGGING_CONFIG, } from './unified-config-types'; @@ -34,6 +35,7 @@ import type { OfficialChannelsConfig, OfficialChannelId, DashboardAuthConfig, + BrowserConfig, ImageAnalysisConfig, LoggingConfig, CursorConfig, @@ -46,6 +48,7 @@ import { normalizeOfficialChannelIds, resolveLegacyDiscordSelection, } from '../channels/official-channels-runtime'; +import { getRecommendedBrowserUserDataDir } from '../utils/browser/browser-settings'; import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver'; import { normalizeSearxngBaseUrl } from '../utils/websearch/types'; @@ -54,6 +57,32 @@ const CONFIG_JSON = 'config.json'; const CONFIG_LOCK = 'config.yaml.lock'; const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds +function normalizeBrowserDevtoolsPort(value: number | undefined): number { + if (!Number.isFinite(value)) { + return DEFAULT_BROWSER_CONFIG.claude.devtools_port; + } + + const port = Math.floor(value as number); + if (port < 1 || port > 65535) { + return DEFAULT_BROWSER_CONFIG.claude.devtools_port; + } + + return port; +} + +function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig { + return { + claude: { + enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled, + user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(), + devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port), + }, + codex: { + enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled, + }, + }; +} + /** * Get path to unified config.yaml */ @@ -384,6 +413,18 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { : defaults.cliproxy.routing?.strategy, }, }, + proxy: { + routing: { + default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default, + background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background, + think: partial.proxy?.routing?.think ?? defaults.proxy?.routing?.think, + longContext: partial.proxy?.routing?.longContext ?? defaults.proxy?.routing?.longContext, + webSearch: partial.proxy?.routing?.webSearch ?? defaults.proxy?.routing?.webSearch, + longContextThreshold: + partial.proxy?.routing?.longContextThreshold ?? + defaults.proxy?.routing?.longContextThreshold, + }, + }, logging: { enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled, level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level, @@ -580,6 +621,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { partial.dashboard_auth?.session_timeout_hours ?? DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours, }, + browser: canonicalizeBrowserConfig(partial.browser), // Image analysis config - enabled by default for CLIProxy providers image_analysis: canonicalizeImageAnalysisConfig({ enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, @@ -674,6 +716,17 @@ function generateYamlWithComments(config: UnifiedConfig): string { ); lines.push(''); + if (config.proxy?.routing) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Proxy Routing: OpenAI-compatible local proxy model selection rules'); + lines.push('# Use profile:model selectors to force a target profile and upstream model.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ proxy: config.proxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + if (config.logging) { lines.push('# ----------------------------------------------------------------------------'); lines.push('# Logging: CCS-owned structured runtime logs'); @@ -893,6 +946,23 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Browser automation section + if (config.browser) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Browser Automation: Claude browser attach and Codex browser tooling'); + lines.push('# Claude attach reuses a running Chrome/Chromium session with remote debugging.'); + lines.push('# Codex tooling controls whether CCS injects Playwright MCP overrides.'); + lines.push('#'); + lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the'); + lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.'); + lines.push('# Configure via: Settings > Browser or `ccs browser ...`.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ browser: config.browser }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + // Image analysis section if (config.image_analysis) { lines.push('# ----------------------------------------------------------------------------'); @@ -1311,6 +1381,15 @@ export function getDashboardAuthConfig(): DashboardAuthConfig { }; } +/** + * Get browser automation configuration. + * Returns canonicalized defaults if not configured. + */ +export function getBrowserConfig(): BrowserConfig { + const config = loadOrCreateUnifiedConfig(); + return canonicalizeBrowserConfig(config.browser); +} + /** * Get image_analysis configuration. * Returns defaults if not configured. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 3ab0f545..1b8b67f4 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -498,6 +498,19 @@ export interface ProxyLocalConfig { auto_start: boolean; } +export interface OpenAICompatProxyRoutingConfig { + default?: string; + background?: string; + think?: string; + longContext?: string; + webSearch?: string; + longContextThreshold?: number; +} + +export interface OpenAICompatProxyConfig { + routing?: OpenAICompatProxyRoutingConfig; +} + /** * CLIProxy server configuration section. * Controls whether CCS uses local or remote CLIProxyAPI instance. @@ -799,6 +812,40 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { session_timeout_hours: 24, }; +/** + * Browser automation configuration. + * Controls Claude browser attach and Codex browser tooling. + */ +export interface BrowserClaudeConfig { + /** Enable Claude browser attach (default: false) */ + enabled: boolean; + /** Chrome user-data directory used for attach mode */ + user_data_dir: string; + /** DevTools port used for attach mode (default: 9222) */ + devtools_port: number; +} + +export interface BrowserCodexConfig { + /** Enable Codex browser tooling injection (default: true) */ + enabled: boolean; +} + +export interface BrowserConfig { + claude: BrowserClaudeConfig; + codex: BrowserCodexConfig; +} + +export const DEFAULT_BROWSER_CONFIG: BrowserConfig = { + claude: { + enabled: false, + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, +}; + /** * Image analysis configuration. * Routes image/PDF files through CLIProxy for vision analysis. @@ -856,6 +903,8 @@ export interface UnifiedConfig { profiles: Record; /** CLIProxy configuration */ cliproxy: CLIProxyConfig; + /** OpenAI-compatible local proxy configuration */ + proxy?: OpenAICompatProxyConfig; /** CCS-owned structured logging configuration */ logging?: LoggingConfig; /** User preferences */ @@ -880,6 +929,8 @@ export interface UnifiedConfig { channels?: OfficialChannelsConfig; /** Dashboard authentication configuration (optional) */ dashboard_auth?: DashboardAuthConfig; + /** Browser automation configuration */ + browser?: BrowserConfig; /** Image analysis configuration (vision via CLIProxy) */ image_analysis?: ImageAnalysisConfig; } @@ -934,6 +985,12 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = { }, }; +export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = { + routing: { + longContextThreshold: 60_000, + }, +}; + /** * Create an empty unified config with defaults. */ @@ -958,6 +1015,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { strategy: 'round-robin', }, }, + proxy: { + routing: { + ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.routing, + }, + }, logging: { ...DEFAULT_LOGGING_CONFIG }, preferences: { theme: 'system', @@ -1015,6 +1077,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { thinking: { ...DEFAULT_THINKING_CONFIG }, channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG }, dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG }, + browser: { + claude: { ...DEFAULT_BROWSER_CONFIG.claude }, + codex: { ...DEFAULT_BROWSER_CONFIG.codex }, + }, image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }, }; } diff --git a/src/cursor/constants.ts b/src/cursor/constants.ts index b9c0d18d..b8e88d36 100644 --- a/src/cursor/constants.ts +++ b/src/cursor/constants.ts @@ -1,3 +1,5 @@ +export const LEGACY_CURSOR_PROFILE_NAME = 'legacy-cursor'; + export const CURSOR_SUBCOMMANDS = [ 'auth', 'status', @@ -12,8 +14,35 @@ export const CURSOR_SUBCOMMANDS = [ '-h', ] as const; +export const CURSOR_CLIPROXY_SHORTCUT_FLAGS = new Set([ + '--auth', + '--logout', + '--config', + '--accounts', +]); + export function isCursorSubcommandToken(token?: string): boolean { return ( Boolean(token) && CURSOR_SUBCOMMANDS.includes(token as (typeof CURSOR_SUBCOMMANDS)[number]) ); } + +export function shouldUseCursorCliproxyShortcut(args: string[]): boolean { + if (args[0] !== 'cursor') { + return false; + } + + for (const token of args.slice(1)) { + if (token === '--') { + break; + } + if (CURSOR_CLIPROXY_SHORTCUT_FLAGS.has(token)) { + return true; + } + if (!token.startsWith('-')) { + return false; + } + } + + return false; +} diff --git a/src/cursor/cursor-anthropic-response.ts b/src/cursor/cursor-anthropic-response.ts index 6575fd14..c5c5a0e2 100644 --- a/src/cursor/cursor-anthropic-response.ts +++ b/src/cursor/cursor-anthropic-response.ts @@ -1,249 +1,4 @@ -import { DeltaAccumulator } from '../glmt/delta-accumulator'; -import { GlmtTransformer } from '../glmt/glmt-transformer'; -import { SSEParser } from '../glmt/sse-parser'; -import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline'; - -const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response'; -const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response'; -type ResponseHeaders = Headers | Record | Array<[string, string]>; - -interface AnthropicErrorPayload { - type: 'error'; - error: { - type: string; - message: string; - }; -} - -function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload { - return { - type: 'error', - error: { - type, - message, - }, - }; -} - -function formatErrorForLog(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - try { - return JSON.stringify(error); - } catch { - return String(error); - } -} - -function logTranslationError(context: string, error: unknown): void { - console.error(`[cursor-anthropic-response] ${context}: ${formatErrorForLog(error)}`); -} - -export function createAnthropicErrorResponse( - status: number, - type: string, - message: string, - headers?: ResponseHeaders -): Response { - const responseHeaders = new Headers(headers); - responseHeaders.set('Content-Type', 'application/json'); - responseHeaders.delete('Content-Length'); - - return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), { - status, - headers: responseHeaders, - }); -} - -function formatSseEvent(event: string, data: unknown): string { - return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; -} - -function hasTranslatableChoices(value: unknown): value is OpenAIResponse { - if (typeof value !== 'object' || value === null) { - return false; - } - - const { choices } = value as OpenAIResponse; - if (!Array.isArray(choices) || choices.length === 0) { - return false; - } - - const firstChoice = choices[0]; - if (typeof firstChoice !== 'object' || firstChoice === null) { - return false; - } - - const message = (firstChoice as { message?: unknown }).message; - return typeof message === 'object' && message !== null; -} - -function isSyntheticTransformationFallback(value: unknown): boolean { - return ( - typeof value === 'object' && - value !== null && - typeof (value as { id?: unknown }).id === 'string' && - (value as { id: string }).id.startsWith('msg_error_') - ); -} - -async function createAnthropicErrorProxyResponse(response: Response): Promise { - const headers = new Headers(response.headers); - headers.delete('Content-Type'); - headers.delete('Content-Length'); - - let type = - response.status === 401 - ? 'authentication_error' - : response.status === 429 - ? 'rate_limit_error' - : response.status >= 400 && response.status < 500 - ? 'invalid_request_error' - : 'api_error'; - let message = `Cursor request failed with status ${response.status}`; - - try { - const contentType = (response.headers.get('content-type') || '').toLowerCase(); - if (contentType.includes('application/json')) { - const payload = (await response.json()) as { - error?: { type?: string; message?: string }; - message?: string; - }; - - if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) { - type = payload.error.type; - } - - if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) { - message = payload.error.message; - } else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) { - message = payload.message; - } - } else { - const text = (await response.text()).trim(); - if (text.length > 0) { - message = text; - } - } - } catch (error) { - logTranslationError('Failed to parse Cursor error response', error); - } - - return createAnthropicErrorResponse(response.status, type, message, headers); -} - -async function createAnthropicJsonResponse(response: Response): Promise { - try { - const openAiResponse = await response.json(); - if (!hasTranslatableChoices(openAiResponse)) { - return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); - } - - const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse); - if (isSyntheticTransformationFallback(anthropicResponse)) { - logTranslationError( - 'Cursor JSON translation produced synthetic fallback response', - anthropicResponse - ); - return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); - } - - return new Response(JSON.stringify(anthropicResponse), { - status: response.status, - headers: { 'Content-Type': 'application/json' }, - }); - } catch (error) { - logTranslationError('Cursor JSON translation failed', error); - return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); - } -} - -function createAnthropicStreamingResponse(response: Response): Response { - const body = response.body; - if (!body) { - return createAnthropicErrorResponse( - 502, - 'api_error', - 'Cursor stream ended before a response body was available' - ); - } - - const parser = new SSEParser({ throwOnMalformedJson: true }); - const transformer = new GlmtTransformer(); - const accumulator = new DeltaAccumulator({}); - const encoder = new TextEncoder(); - - const readable = new ReadableStream({ - async start(controller) { - const reader = body.getReader(); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (!value) { - continue; - } - - const events = parser.parse(Buffer.from(value)); - events.forEach((event) => { - const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator); - anthropicEvents.forEach((anthropicEvent) => { - controller.enqueue( - encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) - ); - }); - }); - } - - if (!accumulator.isFinalized() && accumulator.isMessageStarted()) { - transformer.finalizeDelta(accumulator).forEach((anthropicEvent) => { - controller.enqueue( - encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) - ); - }); - } - } catch (error) { - logTranslationError('Cursor SSE translation failed', error); - controller.enqueue( - encoder.encode( - formatSseEvent( - 'error', - createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE) - ) - ) - ); - } finally { - reader.releaseLock(); - controller.close(); - } - }, - }); - - return new Response(readable, { - status: response.status, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }); -} - -export async function createAnthropicProxyResponse(response: Response): Promise { - if (!response.ok) { - return createAnthropicErrorProxyResponse(response); - } - - const contentType = (response.headers.get('content-type') || '').toLowerCase(); - const isEventStream = - contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;'); - - return isEventStream - ? createAnthropicStreamingResponse(response) - : createAnthropicJsonResponse(response); -} +export { + createAnthropicErrorResponse, + createAnthropicProxyResponse, +} from '../proxy/transformers/sse-stream-transformer'; diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index bfcea61c..868121da 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -248,7 +248,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser const authStatus = checkAuthStatus(); if (!authStatus.authenticated || !authStatus.credentials) { - const message = 'Cursor credentials not found. Run `ccs cursor auth` first.'; + const message = 'Cursor credentials not found. Run `ccs legacy cursor auth` first.'; if (isAnthropicRoute) { await pipeWebResponseToNode( createAnthropicErrorResponse(401, 'authentication_error', message), @@ -266,7 +266,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser } if (authStatus.expired) { - const message = 'Cursor credentials expired. Run `ccs cursor auth` again.'; + const message = 'Cursor credentials expired. Run `ccs legacy cursor auth` again.'; if (isAnthropicRoute) { await pipeWebResponseToNode( createAnthropicErrorResponse(401, 'authentication_error', message), diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts index 74900445..1a1b239f 100644 --- a/src/cursor/cursor-profile-executor.ts +++ b/src/cursor/cursor-profile-executor.ts @@ -97,7 +97,7 @@ export async function executeCursorProfile( if (!config.enabled) { console.error(fail('Cursor integration is not enabled.')); console.error(''); - console.error('Enable it first: ccs cursor enable'); + console.error('Enable it first: ccs legacy cursor enable'); return 1; } @@ -105,13 +105,13 @@ export async function executeCursorProfile( if (!authStatus.authenticated) { console.error(fail('Cursor credentials not found.')); console.error(''); - console.error('Authenticate first: ccs cursor auth'); + console.error('Authenticate first: ccs legacy cursor auth'); return 1; } if (authStatus.expired) { console.error(fail('Cursor credentials have expired.')); console.error(''); - console.error('Refresh them with: ccs cursor auth'); + console.error('Refresh them with: ccs legacy cursor auth'); return 1; } @@ -133,7 +133,7 @@ export async function executeCursorProfile( console.error(fail('Cursor daemon is not running.')); console.error(''); console.error('Start the daemon:'); - console.error(' ccs cursor start'); + console.error(' ccs legacy cursor start'); console.error('Or enable auto_start in the Cursor config section.'); return 1; } diff --git a/src/cursor/cursor-runtime-probe.ts b/src/cursor/cursor-runtime-probe.ts index 3dadfa36..302bbb75 100644 --- a/src/cursor/cursor-runtime-probe.ts +++ b/src/cursor/cursor-runtime-probe.ts @@ -120,7 +120,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise this.maxBufferSize) { throw new Error(`SSE buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`); } - const lines = this.buffer.split('\n'); - - // Keep incomplete line in buffer - this.buffer = lines.pop() || ''; - const events: SSEEvent[] = []; - let currentEvent: SSEEvent = { event: 'message', data: '' }; + const segments = this.buffer.split('\n\n'); + this.buffer = segments.pop() || ''; - for (const line of lines) { - if (line.startsWith('event: ')) { - currentEvent.event = line.substring(7).trim(); - } else if (line.startsWith('data: ')) { - const data = line.substring(6); + for (const segment of segments) { + const lines = segment.split('\n'); + const currentEvent: SSEEvent = { event: 'message', data: '' }; + const dataLines: string[] = []; - if (data === '[DONE]') { - this.eventCount++; - events.push({ - event: 'done', - data: null, - index: this.eventCount, - }); - currentEvent = { event: 'message', data: '' }; - } else { - try { - currentEvent.data = JSON.parse(data); - this.eventCount++; - currentEvent.index = this.eventCount; - events.push({ ...currentEvent }); - currentEvent = { event: 'message', data: '' }; - } catch (e) { - // H-01 Fix: Log parse errors for debugging - if (typeof console !== 'undefined' && console.error) { - console.error( - '[SSEParser] Malformed JSON event:', - (e as Error).message, - 'Data:', - data.substring(0, 100) - ); - } - if (this.throwOnMalformedJson) { - throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`); - } - } + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + if (!line || line.startsWith(':')) { + continue; + } + if (line.startsWith('event: ')) { + currentEvent.event = line.substring(7).trim(); + continue; + } + if (line.startsWith('data:')) { + dataLines.push(line.substring(5).trimStart()); + continue; + } + if (line.startsWith('id: ')) { + currentEvent.id = line.substring(4).trim(); + continue; + } + if (line.startsWith('retry: ')) { + currentEvent.retry = parseInt(line.substring(7), 10); + } + } + + const data = dataLines.join('\n'); + if (!data) { + continue; + } + + if (data === '[DONE]') { + this.eventCount++; + events.push({ event: 'done', data: null, index: this.eventCount }); + continue; + } + + try { + currentEvent.data = JSON.parse(data); + this.eventCount++; + currentEvent.index = this.eventCount; + events.push({ ...currentEvent }); + } catch (e) { + if (typeof console !== 'undefined' && console.error) { + console.error( + '[SSEParser] Malformed JSON event:', + (e as Error).message, + 'Data:', + data.substring(0, 100) + ); + } + if (this.throwOnMalformedJson) { + throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`); } - } else if (line.startsWith('id: ')) { - currentEvent.id = line.substring(4).trim(); - } else if (line.startsWith('retry: ')) { - currentEvent.retry = parseInt(line.substring(7), 10); } - // Empty lines separate events (already handled by JSON parsing) } return events; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index e423d77d..730f1ded 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -936,6 +936,10 @@ class SharedManager { } for (const [name, value] of Object.entries(parsed as Record)) { + if (!this.isMarketplaceRegistryEntry(value)) { + continue; + } + merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized; } } catch (err) { @@ -947,22 +951,20 @@ class SharedManager { const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir); - for (const [name, value] of Object.entries(discoveredEntries)) { - const existing = merged[name]; - if (existing && typeof existing === 'object' && !Array.isArray(existing)) { - merged[name] = { - ...(existing as Record), - installLocation: value.installLocation, - }; - continue; - } - - merged[name] = value; - } - + // Keep only registry entries that have a physical directory, and update their + // installLocation. Entries only on disk (no registry record) are excluded — + // they lack required schema fields that Claude Code enforces. for (const name of Object.keys(merged)) { + const entry = merged[name]; if (!(name in discoveredEntries)) { delete merged[name]; + } else if (this.isMarketplaceRegistryEntry(entry)) { + merged[name] = { + ...entry, + installLocation: discoveredEntries[name].installLocation, + }; + } else { + delete merged[name]; } } @@ -984,6 +986,11 @@ class SharedManager { continue; } + // Skip hidden dirs and Claude Code rename-dance leftovers (.staging/.bak). + if (this.isTransientMarketplaceDirectory(entry.name)) { + continue; + } + discovered[entry.name] = { installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name), }; @@ -992,6 +999,14 @@ class SharedManager { return discovered; } + private isTransientMarketplaceDirectory(name: string): boolean { + return name.startsWith('.') || name.endsWith('.staging') || name.endsWith('.bak'); + } + + private isMarketplaceRegistryEntry(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + private writePluginMetadataFile( registryPath: string, content: string, diff --git a/src/proxy/index.ts b/src/proxy/index.ts new file mode 100644 index 00000000..24a0a516 --- /dev/null +++ b/src/proxy/index.ts @@ -0,0 +1,7 @@ +export * from './profile-router'; +export * from './proxy-daemon'; +export * from './proxy-daemon-paths'; +export * from './proxy-env'; +export * from './upstream-url'; +export * from './transformers/request-transformer'; +export * from './transformers/sse-stream-transformer'; diff --git a/src/proxy/profile-router.ts b/src/proxy/profile-router.ts new file mode 100644 index 00000000..9fe6b092 --- /dev/null +++ b/src/proxy/profile-router.ts @@ -0,0 +1,77 @@ +import { + inferDroidProviderFromBaseUrl, + resolveDroidProvider, + type DroidProvider, +} from '../targets/droid-provider'; + +export interface OpenAICompatProfileConfig { + profileName: string; + settingsPath: string; + baseUrl: string; + apiKey: string; + provider: DroidProvider; + insecure?: boolean; + model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; +} + +export interface OpenAICompatProfileEnv { + ANTHROPIC_BASE_URL?: string; + ANTHROPIC_AUTH_TOKEN?: string; + ANTHROPIC_API_KEY?: string; + ANTHROPIC_MODEL?: string; + ANTHROPIC_DEFAULT_OPUS_MODEL?: string; + ANTHROPIC_DEFAULT_SONNET_MODEL?: string; + ANTHROPIC_DEFAULT_HAIKU_MODEL?: string; + ANTHROPIC_SMALL_FAST_MODEL?: string; + CCS_DROID_PROVIDER?: string; + CCS_OPENAI_PROXY_INSECURE?: string; +} + +export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider { + return provider === 'openai' || provider === 'generic-chat-completion-api'; +} + +export function resolveOpenAICompatProfileConfig( + profileName: string, + settingsPath: string, + env: OpenAICompatProfileEnv +): OpenAICompatProfileConfig | null { + const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || ''; + const apiKey = env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim() || ''; + if (!baseUrl || !apiKey) { + return null; + } + + const providerFromUrl = inferDroidProviderFromBaseUrl(baseUrl); + const provider = isOpenAICompatProvider(providerFromUrl) + ? providerFromUrl + : resolveDroidProvider({ + provider: env.CCS_DROID_PROVIDER, + baseUrl, + model: env.ANTHROPIC_MODEL, + }); + if (!isOpenAICompatProvider(provider)) { + return null; + } + + return { + profileName, + settingsPath, + baseUrl, + apiKey, + provider, + insecure: + env.CCS_OPENAI_PROXY_INSECURE === '1' || + env.CCS_OPENAI_PROXY_INSECURE?.toLowerCase() === 'true', + model: env.ANTHROPIC_MODEL?.trim() || undefined, + opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined, + sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined, + haikuModel: + env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() || + env.ANTHROPIC_SMALL_FAST_MODEL?.trim() || + undefined, + }; +} diff --git a/src/proxy/proxy-daemon-entry.ts b/src/proxy/proxy-daemon-entry.ts new file mode 100644 index 00000000..0013ef7e --- /dev/null +++ b/src/proxy/proxy-daemon-entry.ts @@ -0,0 +1,87 @@ +import { loadSettings } from '../utils/config-manager'; +import { resolveOpenAICompatProfileConfig } from './profile-router'; +import { startOpenAICompatProxyServer } from './server/proxy-server'; + +interface RuntimeOptions { + port: number; + host: string; + profileName: string; + settingsPath: string; + authToken: string; + insecure: boolean; +} + +function parseArgs(argv: string[]): RuntimeOptions { + let port = 3456; + let host = '127.0.0.1'; + let profileName = ''; + let settingsPath = ''; + let authToken = ''; + let insecure = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--port' && argv[i + 1]) { + port = Number.parseInt(argv[++i] || '', 10) || port; + continue; + } + if (arg === '--host' && argv[i + 1]) { + host = argv[++i] || host; + continue; + } + if (arg === '--profile' && argv[i + 1]) { + profileName = argv[++i] || ''; + continue; + } + if (arg === '--settings-path' && argv[i + 1]) { + settingsPath = argv[++i] || ''; + continue; + } + if (arg === '--auth-token' && argv[i + 1]) { + authToken = argv[++i] || ''; + continue; + } + if (arg === '--insecure') { + insecure = true; + } + } + + return { port, host, profileName, settingsPath, authToken, insecure }; +} + +function startRuntime(options: RuntimeOptions): void { + if (!options.authToken.trim()) { + throw new Error('Missing local proxy auth token'); + } + + const settings = loadSettings(options.settingsPath); + const profile = resolveOpenAICompatProfileConfig( + options.profileName, + options.settingsPath, + settings.env || {} + ); + if (!profile) { + throw new Error( + `Profile "${options.profileName}" is not an OpenAI-compatible settings profile` + ); + } + + const server = startOpenAICompatProxyServer({ + profile, + host: options.host, + port: options.port, + authToken: options.authToken, + insecure: options.insecure, + }); + server.once('error', (error) => { + console.error((error as Error).message); + process.exit(1); + }); + const shutdown = () => server.close(); + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +} + +if (require.main === module) { + startRuntime(parseArgs(process.argv.slice(2))); +} diff --git a/src/proxy/proxy-daemon-paths.ts b/src/proxy/proxy-daemon-paths.ts new file mode 100644 index 00000000..92b6b95b --- /dev/null +++ b/src/proxy/proxy-daemon-paths.ts @@ -0,0 +1,17 @@ +import * as path from 'path'; +import { getCcsDir } from '../utils/config-manager'; + +export const OPENAI_COMPAT_PROXY_DEFAULT_PORT = 3456; +export const OPENAI_COMPAT_PROXY_SERVICE_NAME = 'ccs-openai-compat-proxy'; + +export function getOpenAICompatProxyDir(): string { + return path.join(getCcsDir(), 'proxy'); +} + +export function getOpenAICompatProxyPidPath(): string { + return path.join(getOpenAICompatProxyDir(), 'daemon.pid'); +} + +export function getOpenAICompatProxySessionPath(): string { + return path.join(getOpenAICompatProxyDir(), 'session.json'); +} diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts new file mode 100644 index 00000000..525c5a7e --- /dev/null +++ b/src/proxy/proxy-daemon-state.ts @@ -0,0 +1,83 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { + getOpenAICompatProxyDir, + getOpenAICompatProxyPidPath, + getOpenAICompatProxySessionPath, +} from './proxy-daemon-paths'; + +export interface OpenAICompatProxySession { + profileName: string; + settingsPath: string; + host: string; + port: number; + baseUrl: string; + authToken: string; + model?: string; + insecure?: boolean; +} + +function ensureProxyDir(): void { + fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true }); +} + +export function getOpenAICompatProxyPid(): number | null { + try { + const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim(); + const pid = Number.parseInt(raw, 10); + return Number.isInteger(pid) ? pid : null; + } catch { + return null; + } +} + +export function writeOpenAICompatProxyPid(pid: number): void { + ensureProxyDir(); + fs.writeFileSync(getOpenAICompatProxyPidPath(), String(pid), 'utf8'); +} + +export function removeOpenAICompatProxyPid(): void { + try { + fs.unlinkSync(getOpenAICompatProxyPidPath()); + } catch { + // Best-effort cleanup. + } +} + +export function readOpenAICompatProxySession(): OpenAICompatProxySession | null { + try { + return JSON.parse( + fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8') + ) as OpenAICompatProxySession; + } catch { + return null; + } +} + +export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void { + ensureProxyDir(); + fs.writeFileSync( + getOpenAICompatProxySessionPath(), + JSON.stringify(session, null, 2) + '\n', + 'utf8' + ); +} + +export function removeOpenAICompatProxySession(): void { + try { + fs.unlinkSync(getOpenAICompatProxySessionPath()); + } catch { + // Best-effort cleanup. + } +} + +export function resolveOpenAICompatProxyEntrypointCandidates(): string[] { + const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js'); + const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts'); + const isBunRuntime = process.execPath.toLowerCase().includes('bun'); + const runningFromDist = __filename.endsWith('.js'); + if (runningFromDist) { + return [jsEntry]; + } + return isBunRuntime ? [tsEntry, jsEntry] : [jsEntry]; +} diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts new file mode 100644 index 00000000..8a28382e --- /dev/null +++ b/src/proxy/proxy-daemon.ts @@ -0,0 +1,389 @@ +import { spawn, type ChildProcess } from 'child_process'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as net from 'net'; +import * as lockfile from 'proper-lockfile'; +import { verifyProcessOwnership } from '../cursor/daemon-process-ownership'; +import type { OpenAICompatProfileConfig } from './profile-router'; +import { + OPENAI_COMPAT_PROXY_DEFAULT_PORT, + OPENAI_COMPAT_PROXY_SERVICE_NAME, + getOpenAICompatProxyDir, +} from './proxy-daemon-paths'; +import { + getOpenAICompatProxyPid, + readOpenAICompatProxySession, + removeOpenAICompatProxyPid, + removeOpenAICompatProxySession, + resolveOpenAICompatProxyEntrypointCandidates, + type OpenAICompatProxySession, + writeOpenAICompatProxyPid, + writeOpenAICompatProxySession, +} from './proxy-daemon-state'; + +export interface OpenAICompatProxyStatus extends Partial { + running: boolean; + pid?: number; +} + +export interface StartOpenAICompatProxyResult { + success: boolean; + alreadyRunning?: boolean; + authToken?: string; + pid?: number; + port: number; + error?: string; +} + +function generateProxyAuthToken(): string { + return crypto.randomBytes(24).toString('hex'); +} + +async function withOpenAICompatProxyLock(operation: () => Promise): Promise { + const proxyDir = getOpenAICompatProxyDir(); + await fs.promises.mkdir(proxyDir, { recursive: true }); + + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(proxyDir, { + stale: 10000, + retries: { retries: 20, minTimeout: 50, maxTimeout: 250 }, + realpath: false, + }); + } catch (error) { + throw new Error( + `Failed to lock OpenAI-compatible proxy directory (${proxyDir}): ${(error as Error).message}` + ); + } + + try { + return await operation(); + } finally { + if (release) { + try { + await release(); + } catch { + // Best-effort release. + } + } + } +} + +async function isPortOccupied(port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + const finish = (occupied: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(occupied); + }; + + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + socket.setTimeout(500, () => { + finish(false); + }); + }); +} + +async function findOpenAICompatProxyPort(): Promise { + for ( + let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT; + candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10; + candidate += 1 + ) { + if (!(await isPortOccupied(candidate))) { + return candidate; + } + } + + return 0; +} + +async function resolveDaemonEntrypoint(): Promise { + for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) { + try { + await fs.promises.access(candidate, fs.constants.R_OK); + return candidate; + } catch { + // Try next candidate. + } + } + return null; +} + +export async function isOpenAICompatProxyRunning(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + if (res.statusCode !== 200) { + resolve(false); + return; + } + try { + const payload = JSON.parse(body) as { service?: string }; + resolve(payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME); + } catch { + resolve(false); + } + }); + } + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); +} + +export async function getOpenAICompatProxyStatus(): Promise { + const session = readOpenAICompatProxySession(); + const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; + const running = await isOpenAICompatProxyRunning(port); + return { + running, + pid: running ? getOpenAICompatProxyPid() || undefined : undefined, + ...session, + }; +} + +async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> { + const pid = getOpenAICompatProxyPid(); + if (!pid) { + removeOpenAICompatProxySession(); + return { success: true }; + } + + const ownership = verifyProcessOwnership( + pid, + (commandLine) => + commandLine.includes('--ccs-openai-proxy-daemon') && + commandLine.includes('proxy-daemon-entry') + ); + + if (ownership === 'not-owned') { + removeOpenAICompatProxyPid(); + return { success: true }; + } + + if (ownership === 'unknown') { + return { + success: false, + error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`, + }; + } + + if (ownership === 'not-running') { + removeOpenAICompatProxyPid(); + removeOpenAICompatProxySession(); + return { success: true }; + } + + try { + process.kill(pid, 'SIGTERM'); + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + process.kill(pid, 0); + attempts += 1; + } catch { + break; + } + } + + if (attempts >= 10) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already exited. + } + } + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ESRCH') { + return { success: false, error: `Failed to stop daemon: ${err.message}` }; + } + } + + removeOpenAICompatProxyPid(); + removeOpenAICompatProxySession(); + return { success: true }; +} + +export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error?: string }> { + return withOpenAICompatProxyLock(() => stopOpenAICompatProxyUnlocked()); +} + +export async function startOpenAICompatProxy( + profile: OpenAICompatProfileConfig, + options: { port?: number; host?: string; insecure?: boolean } = {} +): Promise { + return withOpenAICompatProxyLock(async () => { + const status = await getOpenAICompatProxyStatus(); + const host = options.host?.trim() || status.host || '127.0.0.1'; + const port = + typeof options.port === 'number' + ? options.port + : status.running && status.profileName === profile.profileName && status.port + ? status.port + : await findOpenAICompatProxyPort(); + if (port === 0) { + return { + success: false, + port: OPENAI_COMPAT_PROXY_DEFAULT_PORT, + error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, + }; + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return { success: false, port, error: `Invalid port: ${port}` }; + } + if ( + status.running && + status.profileName === profile.profileName && + status.port === port && + (status.host || '127.0.0.1') === host + ) { + return { + success: true, + alreadyRunning: true, + pid: status.pid, + port, + authToken: status.authToken, + }; + } + if (status.running) { + if (status.profileName !== profile.profileName) { + return { + success: false, + port, + error: `Proxy already running for profile "${status.profileName}" on port ${status.port}. Stop it before starting a different profile.`, + }; + } + + const stopped = await stopOpenAICompatProxyUnlocked(); + if (!stopped.success) { + return { + success: false, + port, + error: stopped.error || 'Failed to restart the running proxy', + }; + } + } + + const daemonEntry = await resolveDaemonEntrypoint(); + if (!daemonEntry) { + return { + success: false, + port, + error: 'OpenAI proxy daemon entrypoint not found. Run `bun run build` and retry.', + }; + } + + return new Promise((resolve) => { + let resolved = false; + let timeout: NodeJS.Timeout | null = null; + const authToken = generateProxyAuthToken(); + + const finish = (result: StartOpenAICompatProxyResult) => { + if (resolved) return; + resolved = true; + if (timeout) clearTimeout(timeout); + if (!result.success) { + removeOpenAICompatProxyPid(); + removeOpenAICompatProxySession(); + } + resolve(result); + }; + + const proc: ChildProcess = spawn( + process.execPath, + [ + daemonEntry, + '--port', + String(port), + '--host', + host, + '--profile', + profile.profileName, + '--settings-path', + profile.settingsPath, + '--auth-token', + authToken, + ...(options.insecure ? ['--insecure'] : []), + '--ccs-openai-proxy-daemon', + ], + { stdio: 'ignore', detached: true } + ); + + proc.unref(); + if (proc.pid) { + writeOpenAICompatProxyPid(proc.pid); + } + writeOpenAICompatProxySession({ + profileName: profile.profileName, + settingsPath: profile.settingsPath, + host, + port, + baseUrl: profile.baseUrl, + authToken, + model: profile.model, + insecure: options.insecure, + }); + + let attempts = 0; + const poll = async () => { + attempts += 1; + if (await isOpenAICompatProxyRunning(port)) { + finish({ success: true, pid: proc.pid, port, authToken }); + return; + } + if (attempts >= 30) { + finish({ + success: false, + port, + error: `Proxy daemon did not start within 30 seconds on port ${port}`, + }); + return; + } + timeout = setTimeout(poll, 1000); + }; + + timeout = setTimeout(poll, 1000); + proc.on('error', (error) => { + finish({ success: false, port, error: error.message }); + }); + proc.on('exit', (code, signal) => { + if (code === 0) { + finish({ + success: false, + port, + error: 'Proxy daemon exited before becoming healthy', + }); + return; + } + if (code !== null) { + finish({ + success: false, + port, + error: `Proxy daemon exited with code ${code}`, + }); + return; + } + finish({ + success: false, + port, + error: `Proxy daemon was killed by signal ${signal}`, + }); + }); + }); + }); +} diff --git a/src/proxy/proxy-env.ts b/src/proxy/proxy-env.ts new file mode 100644 index 00000000..ed99a87c --- /dev/null +++ b/src/proxy/proxy-env.ts @@ -0,0 +1,29 @@ +import type { OpenAICompatProfileConfig } from './profile-router'; + +export function buildOpenAICompatProxyEnv( + profile: OpenAICompatProfileConfig, + port: number, + authToken: string, + claudeConfigDir?: string, + host = '127.0.0.1' +): Record { + const localBaseUrl = `http://${host}:${port}`; + return { + ANTHROPIC_BASE_URL: localBaseUrl, + ANTHROPIC_AUTH_TOKEN: authToken, + DISABLE_TELEMETRY: '1', + DISABLE_COST_WARNINGS: '1', + API_TIMEOUT_MS: '600000', + NO_PROXY: host === '127.0.0.1' ? '127.0.0.1,localhost' : `${host},127.0.0.1,localhost`, + ...(profile.model ? { ANTHROPIC_MODEL: profile.model } : {}), + ...(profile.opusModel ? { ANTHROPIC_DEFAULT_OPUS_MODEL: profile.opusModel } : {}), + ...(profile.sonnetModel ? { ANTHROPIC_DEFAULT_SONNET_MODEL: profile.sonnetModel } : {}), + ...(profile.haikuModel + ? { + ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.haikuModel, + ANTHROPIC_SMALL_FAST_MODEL: profile.haikuModel, + } + : {}), + ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), + }; +} diff --git a/src/proxy/request-router.ts b/src/proxy/request-router.ts new file mode 100644 index 00000000..aa758280 --- /dev/null +++ b/src/proxy/request-router.ts @@ -0,0 +1,222 @@ +import { loadConfigSafe, loadSettings } from '../utils/config-manager'; +import { expandPath } from '../utils/helpers'; +import type { ProxyOpenAIRequest } from './transformers/request-transformer'; +import { + loadOpenAICompatProxyRoutingConfig, + type OpenAICompatProxyRoutingConfig, +} from './routing-config'; +import { resolveOpenAICompatProfileConfig, type OpenAICompatProfileConfig } from './profile-router'; + +export type ProxyRoutingScenario = 'default' | 'background' | 'think' | 'longContext' | 'webSearch'; + +export interface ProxyRequestRoute { + profile: OpenAICompatProfileConfig; + model?: string; + scenario?: ProxyRoutingScenario; + estimatedTokens: number; + source: + | 'explicit-profile' + | 'scenario' + | 'profile-model-match' + | 'profile-name' + | 'request-model' + | 'active-default'; +} + +function loadOpenAICompatProfiles( + activeProfile: OpenAICompatProfileConfig +): OpenAICompatProfileConfig[] { + const config = loadConfigSafe(); + const profiles = [activeProfile]; + + for (const [profileName, settingsPath] of Object.entries(config.profiles)) { + if (profileName === activeProfile.profileName) { + continue; + } + + try { + const expandedPath = expandPath(settingsPath); + const settings = loadSettings(expandedPath); + const profile = resolveOpenAICompatProfileConfig( + profileName, + expandedPath, + settings.env || {} + ); + if (profile) { + profiles.push(profile); + } + } catch { + // Ignore invalid profiles while routing a live request. + } + } + + return profiles; +} + +function resolveSelectorTarget( + selector: string, + activeProfile: OpenAICompatProfileConfig, + profiles: OpenAICompatProfileConfig[] +): { profile: OpenAICompatProfileConfig; model?: string; explicitProfile: boolean } | null { + const trimmed = selector.trim(); + if (!trimmed) { + return null; + } + + const colonIndex = trimmed.indexOf(':'); + if (colonIndex > 0) { + const profileName = trimmed.slice(0, colonIndex).trim(); + const profile = profiles.find((candidate) => candidate.profileName === profileName); + if (profile) { + const model = trimmed.slice(colonIndex + 1).trim() || profile.model; + return { profile, model, explicitProfile: true }; + } + } + + const namedProfile = profiles.find((candidate) => candidate.profileName === trimmed); + if (namedProfile) { + return { profile: namedProfile, model: namedProfile.model, explicitProfile: false }; + } + + return { + profile: activeProfile, + model: trimmed, + explicitProfile: false, + }; +} + +function profileSupportsModel(profile: OpenAICompatProfileConfig, model: string): boolean { + return [profile.model, profile.opusModel, profile.sonnetModel, profile.haikuModel].some( + (candidate) => typeof candidate === 'string' && candidate === model + ); +} + +function estimateTokens(request: ProxyOpenAIRequest): number { + let characters = 0; + for (const message of request.messages) { + if (typeof message.content === 'string') { + characters += message.content.length; + continue; + } + if (Array.isArray(message.content)) { + for (const part of message.content) { + characters += part.type === 'text' ? part.text.length : part.image_url.url.length; + } + } + if (Array.isArray(message.tool_calls)) { + for (const toolCall of message.tool_calls) { + characters += toolCall.function.name.length + toolCall.function.arguments.length; + } + } + } + + if (Array.isArray(request.tools)) { + characters += JSON.stringify(request.tools).length; + } + + return Math.max(1, Math.ceil(characters / 4)); +} + +function detectScenario( + request: ProxyOpenAIRequest, + requestedModel: string | undefined, + routing: OpenAICompatProxyRoutingConfig +): { scenario?: ProxyRoutingScenario; selector?: string; estimatedTokens: number } { + const estimatedTokens = estimateTokens(request); + const hasWebSearchTool = + request.tools?.some((tool) => tool.function.name === 'web_search') === true; + const thinkingEnabled = + request.reasoning?.enabled === true || typeof request.reasoning_effort === 'string'; + const modelId = requestedModel || ''; + const longContextThreshold = routing.longContextThreshold ?? 60_000; + + if (hasWebSearchTool && routing.webSearch) { + return { scenario: 'webSearch', selector: routing.webSearch, estimatedTokens }; + } + if (thinkingEnabled && routing.think) { + return { scenario: 'think', selector: routing.think, estimatedTokens }; + } + if (estimatedTokens > longContextThreshold && routing.longContext) { + return { scenario: 'longContext', selector: routing.longContext, estimatedTokens }; + } + if (modelId.toLowerCase().includes('haiku') && routing.background) { + return { scenario: 'background', selector: routing.background, estimatedTokens }; + } + if (!requestedModel && routing.default) { + return { scenario: 'default', selector: routing.default, estimatedTokens }; + } + + return { estimatedTokens }; +} + +export function resolveProxyRequestRoute( + activeProfile: OpenAICompatProfileConfig, + request: ProxyOpenAIRequest +): ProxyRequestRoute { + const profiles = loadOpenAICompatProfiles(activeProfile); + const requestedModel = request.model?.trim() || undefined; + const explicitTarget = requestedModel + ? resolveSelectorTarget(requestedModel, activeProfile, profiles) + : null; + const routing = loadOpenAICompatProxyRoutingConfig(); + + if (explicitTarget?.explicitProfile) { + return { + profile: explicitTarget.profile, + model: explicitTarget.model, + estimatedTokens: estimateTokens(request), + source: 'explicit-profile', + }; + } + + const scenario = detectScenario(request, requestedModel, routing); + if (scenario.selector) { + const scenarioTarget = resolveSelectorTarget(scenario.selector, activeProfile, profiles); + if (scenarioTarget) { + return { + profile: scenarioTarget.profile, + model: scenarioTarget.model, + scenario: scenario.scenario, + estimatedTokens: scenario.estimatedTokens, + source: 'scenario', + }; + } + } + + if (explicitTarget && explicitTarget.profile.profileName !== activeProfile.profileName) { + return { + profile: explicitTarget.profile, + model: explicitTarget.model, + estimatedTokens: scenario.estimatedTokens, + source: 'profile-name', + }; + } + + if (requestedModel) { + const matchedProfile = profiles.find((profile) => + profileSupportsModel(profile, requestedModel) + ); + if (matchedProfile) { + return { + profile: matchedProfile, + model: requestedModel, + estimatedTokens: scenario.estimatedTokens, + source: 'profile-model-match', + }; + } + + return { + profile: activeProfile, + model: requestedModel, + estimatedTokens: scenario.estimatedTokens, + source: 'request-model', + }; + } + + return { + profile: activeProfile, + model: activeProfile.model, + estimatedTokens: scenario.estimatedTokens, + source: 'active-default', + }; +} diff --git a/src/proxy/routing-config.ts b/src/proxy/routing-config.ts new file mode 100644 index 00000000..f069edd6 --- /dev/null +++ b/src/proxy/routing-config.ts @@ -0,0 +1,74 @@ +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { getActiveConfigPath, getConfigPath } from '../utils/config-manager'; + +export interface OpenAICompatProxyRoutingConfig { + default?: string; + background?: string; + think?: string; + longContext?: string; + webSearch?: string; + longContextThreshold?: number; +} + +function readRawConfigObject(configPath: string): Record | null { + if (!fs.existsSync(configPath)) { + return null; + } + + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = + configPath.endsWith('.yaml') || configPath.endsWith('.yml') ? yaml.load(raw) : JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null ? (parsed as Record) : null; +} + +function normalizeRoutingConfig(value: unknown): OpenAICompatProxyRoutingConfig { + if (typeof value !== 'object' || value === null) { + return {}; + } + + const config = value as Record; + return { + default: + typeof config.default === 'string' && config.default.trim() + ? config.default.trim() + : undefined, + background: + typeof config.background === 'string' && config.background.trim() + ? config.background.trim() + : undefined, + think: + typeof config.think === 'string' && config.think.trim() ? config.think.trim() : undefined, + longContext: + typeof config.longContext === 'string' && config.longContext.trim() + ? config.longContext.trim() + : undefined, + webSearch: + typeof config.webSearch === 'string' && config.webSearch.trim() + ? config.webSearch.trim() + : undefined, + longContextThreshold: + typeof config.longContextThreshold === 'number' && + Number.isFinite(config.longContextThreshold) + ? config.longContextThreshold + : undefined, + }; +} + +export function loadOpenAICompatProxyRoutingConfig(): OpenAICompatProxyRoutingConfig { + const activePath = getActiveConfigPath(); + const rawConfig = readRawConfigObject(activePath); + if (rawConfig?.proxy && typeof rawConfig.proxy === 'object') { + return normalizeRoutingConfig((rawConfig.proxy as Record).routing); + } + + const legacyPath = getConfigPath(); + if (legacyPath !== activePath) { + const legacyConfig = readRawConfigObject(legacyPath); + if (legacyConfig?.proxy && typeof legacyConfig.proxy === 'object') { + return normalizeRoutingConfig((legacyConfig.proxy as Record).routing); + } + } + + return {}; +} diff --git a/src/proxy/server/http-helpers.ts b/src/proxy/server/http-helpers.ts new file mode 100644 index 00000000..77a4adbc --- /dev/null +++ b/src/proxy/server/http-helpers.ts @@ -0,0 +1,81 @@ +import * as http from 'http'; +import { Readable } from 'stream'; + +const MAX_BODY_SIZE = 10 * 1024 * 1024; + +export function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +export function readJsonBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + let settled = false; + + const resolveOnce = (payload: unknown) => { + if (!settled) { + settled = true; + resolve(payload); + } + }; + + const rejectOnce = (error: Error) => { + if (!settled) { + settled = true; + reject(error); + } + }; + + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > MAX_BODY_SIZE) { + req.pause(); + rejectOnce(new Error('Request body too large (max 10MB)')); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) { + resolveOnce({}); + return; + } + + try { + resolveOnce(JSON.parse(raw)); + } catch { + rejectOnce(new Error('Invalid JSON in request body')); + } + }); + + req.on('error', (error) => { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + }); + }); +} + +export async function pipeWebResponseToNode( + response: Response, + res: http.ServerResponse +): Promise { + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + if (!response.body) { + res.end(); + return; + } + + const nodeStream = Readable.fromWeb(response.body as unknown as ReadableStream); + await new Promise((resolve, reject) => { + nodeStream.on('error', reject); + nodeStream.on('end', resolve); + nodeStream.pipe(res); + }); +} diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts new file mode 100644 index 00000000..95388e00 --- /dev/null +++ b/src/proxy/server/messages-route.ts @@ -0,0 +1,280 @@ +import * as http from 'http'; +import type { Dispatcher } from 'undici'; +import type { OpenAICompatProfileConfig } from '../profile-router'; +import { resolveProxyRequestRoute } from '../request-router'; +import { ProxyRequestTransformer } from '../transformers/request-transformer'; +import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer'; +import { resolveOpenAIChatCompletionsUrl } from '../upstream-url'; +import { createLogger } from '../../services/logging'; +import { pipeWebResponseToNode, readJsonBody, writeJson } from './http-helpers'; + +const REQUEST_TIMEOUT_MS = 600_000; +const logger = createLogger('proxy:openai-compat:messages'); + +class ProxyInputError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProxyInputError'; + } +} + +function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${profile.apiKey}`, + 'User-Agent': 'CCS-OpenAI-Compat-Proxy/1.0', + }; +} + +function buildUpstreamRequest( + profile: OpenAICompatProfileConfig, + rawBody: unknown +): { body: string; route: ReturnType } { + let transformed; + try { + const transformer = new ProxyRequestTransformer(); + transformed = transformer.transform(rawBody); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid Anthropic request'; + throw new ProxyInputError(message); + } + const route = resolveProxyRequestRoute(profile, transformed); + const body = { + ...transformed, + model: route.model || route.profile.model, + stream: transformed.stream === true, + }; + return { body: JSON.stringify(body), route }; +} + +export function extractIncomingProxyToken(headers: http.IncomingHttpHeaders): string | null { + const xApiKey = headers['x-api-key']; + if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) { + return xApiKey.trim(); + } + + const anthropicApiKey = headers['anthropic-api-key']; + if (typeof anthropicApiKey === 'string' && anthropicApiKey.trim().length > 0) { + return anthropicApiKey.trim(); + } + + const authHeader = headers.authorization; + if (typeof authHeader === 'string' && authHeader.trim().length > 0) { + const trimmed = authHeader.trim(); + const bearerPrefix = 'Bearer '; + return trimmed.startsWith(bearerPrefix) ? trimmed.slice(bearerPrefix.length).trim() : trimmed; + } + + return null; +} + +export function validateIncomingProxyAuth( + headers: http.IncomingHttpHeaders, + expectedToken: string +): boolean { + return extractIncomingProxyToken(headers) === expectedToken; +} + +function buildFetchInit( + profile: OpenAICompatProfileConfig, + body: string, + signal: AbortSignal, + insecureDispatcher?: Dispatcher +): RequestInit { + const init: RequestInit = { + method: 'POST', + headers: buildUpstreamHeaders(profile), + body, + signal, + }; + + if (insecureDispatcher) { + (init as Record).dispatcher = insecureDispatcher; + } + + return init; +} + +function getRequestTimeoutMs(): number { + const rawValue = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS; + if (!rawValue) { + return REQUEST_TIMEOUT_MS; + } + + const parsed = Number.parseInt(rawValue, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : REQUEST_TIMEOUT_MS; +} + +function formatTimeoutDuration(timeoutMs: number): string { + return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000} seconds` : `${timeoutMs}ms`; +} + +function registerOnceListener( + emitter: NodeJS.EventEmitter | null | undefined, + event: string, + handler: () => void +): () => void { + if (!emitter) { + return () => {}; + } + + emitter.once(event, handler); + return () => { + emitter.removeListener(event, handler); + }; +} + +export function attachDisconnectAbortHandlers( + req: http.IncomingMessage, + res: http.ServerResponse, + controller: AbortController, + onDisconnect: (source: string) => void +): () => void { + const abortOnDisconnect = (source: string) => { + if (!controller.signal.aborted && !res.writableEnded) { + onDisconnect(source); + controller.abort(); + } + }; + + const cleanupFns = [ + registerOnceListener(req, 'aborted', () => abortOnDisconnect('req.aborted')), + registerOnceListener(req, 'close', () => abortOnDisconnect('req.close')), + registerOnceListener(req.socket, 'close', () => abortOnDisconnect('req.socket.close')), + registerOnceListener(res, 'close', () => abortOnDisconnect('res.close')), + registerOnceListener(res.socket, 'close', () => abortOnDisconnect('res.socket.close')), + ]; + + const disconnectPoll = setInterval(() => { + if ( + req.destroyed || + res.destroyed || + req.socket?.destroyed === true || + res.socket?.destroyed === true + ) { + abortOnDisconnect('poll.destroyed'); + } + }, 50); + + return () => { + clearInterval(disconnectPoll); + for (const cleanup of cleanupFns) { + cleanup(); + } + }; +} + +export async function handleProxyMessagesRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + profile: OpenAICompatProfileConfig, + expectedAuthToken: string, + insecureDispatcher?: Dispatcher +): Promise { + const transformer = new ProxySseStreamTransformer(); + + if (!validateIncomingProxyAuth(req.headers, expectedAuthToken)) { + logger.warn('auth.invalid', 'Rejected proxy message request with invalid auth token', { + remoteAddress: req.socket.remoteAddress || null, + }); + await pipeWebResponseToNode( + transformer.error(401, 'authentication_error', 'Missing or invalid local proxy token'), + res + ); + return; + } + + let timeoutMs = REQUEST_TIMEOUT_MS; + try { + const rawBody = await readJsonBody(req); + const upstream = buildUpstreamRequest(profile, rawBody); + logger.info('request.forward', 'Forwarding Anthropic request to OpenAI-compatible upstream', { + profileName: upstream.route.profile.profileName, + provider: upstream.route.profile.provider, + baseUrl: upstream.route.profile.baseUrl, + model: upstream.route.model || upstream.route.profile.model || null, + routeSource: upstream.route.source, + scenario: upstream.route.scenario || null, + estimatedTokens: upstream.route.estimatedTokens, + }); + const controller = new AbortController(); + timeoutMs = getRequestTimeoutMs(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const cleanupDisconnectHandlers = attachDisconnectAbortHandlers( + req, + res, + controller, + (source) => { + logger.info( + 'request.disconnect', + 'Aborting upstream request after local client disconnect', + { + profileName: profile.profileName, + source, + } + ); + } + ); + + try { + const upstreamResponse = await fetch( + resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl), + buildFetchInit(upstream.route.profile, upstream.body, controller.signal, insecureDispatcher) + ); + logger.info('response.received', 'Received upstream response', { + profileName: profile.profileName, + routedProfileName: upstream.route.profile.profileName, + status: upstreamResponse.status, + }); + const response = await transformer.transform(upstreamResponse); + await pipeWebResponseToNode(response, res); + } finally { + clearTimeout(timeout); + cleanupDisconnectHandlers(); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown proxy error'; + logger.error('request.failed', 'Proxy message request failed', { + profileName: profile.profileName, + error: message, + abort: error instanceof Error && error.name === 'AbortError', + }); + const status = + error instanceof Error && error.name === 'AbortError' + ? 502 + : error instanceof ProxyInputError + ? 400 + : message.includes('Request body too large') + ? 413 + : message.includes('Invalid JSON') + ? 400 + : 502; + const type = status >= 500 ? 'api_error' : 'invalid_request_error'; + await pipeWebResponseToNode( + transformer.error( + status, + type, + error instanceof Error && error.name === 'AbortError' + ? `The upstream provider did not respond within ${formatTimeoutDuration(timeoutMs)}` + : message + ), + res + ); + } +} + +export function handleProxyModelsRequest( + res: http.ServerResponse, + profile: OpenAICompatProfileConfig +): void { + const data = [profile.model, profile.opusModel, profile.sonnetModel, profile.haikuModel] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .map((id) => ({ + id, + object: 'model', + created: 0, + owned_by: profile.provider, + })); + + writeJson(res, 200, { object: 'list', data }); +} diff --git a/src/proxy/server/proxy-server.ts b/src/proxy/server/proxy-server.ts new file mode 100644 index 00000000..4b68f2a1 --- /dev/null +++ b/src/proxy/server/proxy-server.ts @@ -0,0 +1,110 @@ +import * as http from 'http'; +import { Agent } from 'undici'; +import type { OpenAICompatProfileConfig } from '../profile-router'; +import { OPENAI_COMPAT_PROXY_SERVICE_NAME } from '../proxy-daemon-paths'; +import { createLogger } from '../../services/logging'; +import { + handleProxyMessagesRequest, + handleProxyModelsRequest, + validateIncomingProxyAuth, +} from './messages-route'; +import { writeJson } from './http-helpers'; + +export interface OpenAICompatProxyServerOptions { + profile: OpenAICompatProfileConfig; + host?: string; + port: number; + authToken: string; + insecure?: boolean; +} + +export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOptions): http.Server { + const host = options.host?.trim() || '127.0.0.1'; + const logger = createLogger('proxy:openai-compat', { + profileName: options.profile.profileName, + host, + port: options.port, + }); + const insecureDispatcher = options.insecure + ? new Agent({ connect: { rejectUnauthorized: false } }) + : undefined; + const server = http.createServer(async (req, res) => { + const method = req.method || 'GET'; + const requestUrl = req.url || '/'; + const parsedUrl = new URL(requestUrl, 'http://127.0.0.1'); + const pathname = + parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname; + + if (method === 'GET' && pathname === '/health') { + writeJson(res, 200, { + ok: true, + service: OPENAI_COMPAT_PROXY_SERVICE_NAME, + host, + profile: options.profile.profileName, + port: options.port, + }); + return; + } + + if (method === 'GET' && pathname === '/') { + writeJson(res, 200, { + ok: true, + service: OPENAI_COMPAT_PROXY_SERVICE_NAME, + bind: { + host, + port: options.port, + }, + profile: { + name: options.profile.profileName, + provider: options.profile.provider, + model: options.profile.model || null, + }, + endpoints: ['/health', '/v1/messages', '/v1/models'], + }); + return; + } + + if (method === 'GET' && pathname === '/v1/models') { + if (!validateIncomingProxyAuth(req.headers, options.authToken)) { + writeJson(res, 401, { + type: 'error', + error: { + type: 'authentication_error', + message: 'Missing or invalid local proxy token', + }, + }); + return; + } + handleProxyModelsRequest(res, options.profile); + return; + } + + if (method === 'POST' && pathname === '/v1/messages') { + await handleProxyMessagesRequest( + req, + res, + options.profile, + options.authToken, + insecureDispatcher + ); + return; + } + + logger.warn('http.not_found', 'Rejected unknown proxy route', { + method, + pathname, + }); + writeJson(res, 404, { error: 'Not found' }); + }); + + logger.info('server.start', 'OpenAI-compatible proxy server listening', { + baseUrl: `http://${host}:${options.port}`, + }); + server.on('close', () => { + logger.info('server.stop', 'OpenAI-compatible proxy server stopped'); + void insecureDispatcher?.close(); + }); + + server.listen(options.port, host); + return server; +} diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts new file mode 100644 index 00000000..6b294dac --- /dev/null +++ b/src/proxy/transformers/request-transformer.ts @@ -0,0 +1,427 @@ +interface AnthropicThinking { + type?: 'enabled' | 'disabled' | string; + budget_tokens?: number; +} + +interface AnthropicTextBlock { + type: 'text'; + text?: string; +} + +interface AnthropicImageBlock { + type: 'image'; + source?: { + type?: string; + media_type?: string; + data?: string; + }; +} + +interface AnthropicToolUseBlock { + type: 'tool_use'; + id?: string; + name?: string; + input?: Record; +} + +interface AnthropicToolResultBlock { + type: 'tool_result'; + tool_use_id?: string; + content?: unknown; +} + +type AnthropicContentBlock = + | AnthropicTextBlock + | AnthropicImageBlock + | AnthropicToolUseBlock + | AnthropicToolResultBlock + | { type: string; [key: string]: unknown }; + +interface AnthropicMessage { + role?: 'user' | 'assistant' | string; + content?: string | AnthropicContentBlock[]; +} + +interface AnthropicProxyRequestShape { + model?: unknown; + system?: unknown; + messages?: unknown; + max_tokens?: unknown; + temperature?: unknown; + top_p?: unknown; + stop_sequences?: unknown; + metadata?: unknown; + tools?: unknown; + stream?: unknown; + thinking?: AnthropicThinking; +} + +interface OpenAITextPart { + type: 'text'; + text: string; +} + +interface OpenAIImagePart { + type: 'image_url'; + image_url: { + url: string; + }; +} + +type OpenAIContentPart = OpenAITextPart | OpenAIImagePart; + +interface OpenAIMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string | OpenAIContentPart[] | null; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; + }>; +} + +export interface ProxyOpenAIRequest { + model?: string; + stream: boolean; + reasoning_effort?: string; + reasoning?: { + enabled: boolean; + effort: string; + }; + tools?: Array<{ + type: 'function'; + function: { + name: string; + description?: string; + parameters: Record; + }; + }>; + messages: OpenAIMessage[]; + max_tokens?: number; + temperature?: number; + top_p?: number; + stop?: string[]; + metadata?: Record; +} + +const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]'; +const TOOL_USE_ARGUMENTS_FALLBACK = '{}'; + +function assertObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null) { + throw new Error(`${label} must be an object`); + } + + return value as Record; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const result = value.filter( + (entry): entry is string => typeof entry === 'string' && entry.length > 0 + ); + return result.length > 0 ? result : undefined; +} + +function asMetadata(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function safeJsonStringify(value: unknown, fallback: string): string { + try { + const serialized = JSON.stringify(value); + return typeof serialized === 'string' ? serialized : fallback; + } catch { + return fallback; + } +} + +function flattenTextContent(content: unknown, label: string): string { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + throw new Error(`${label} must be a string or content block array`); + } + + return content + .map((block, index) => { + const parsed = assertObject(block, `${label}[${index}]`); + if (parsed.type !== 'text') { + throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`); + } + return typeof parsed.text === 'string' ? parsed.text : ''; + }) + .join('\n'); +} + +function toToolResultContent(content: unknown, label: string): string { + if (content === undefined) { + return ''; + } + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return flattenTextContent(content, label); + } + return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK); +} + +function createFallbackToolId(messageIndex: number, blockIndex: number): string { + return `toolu_proxy_fallback_${messageIndex}_${blockIndex}`; +} + +function toImagePart(block: AnthropicImageBlock, label: string): OpenAIImagePart { + const source = block.source; + if (!source || source.type !== 'base64' || !source.media_type || !source.data) { + throw new Error(`${label}.source must be a base64 image payload`); + } + + return { + type: 'image_url', + image_url: { + url: `data:${source.media_type};base64,${source.data}`, + }, + }; +} + +function isImageBlock(block: AnthropicContentBlock): block is AnthropicImageBlock { + return block.type === 'image'; +} + +function isToolUseBlock(block: AnthropicContentBlock): block is AnthropicToolUseBlock { + return block.type === 'tool_use'; +} + +function isToolResultBlock(block: AnthropicContentBlock): block is AnthropicToolResultBlock { + return block.type === 'tool_result'; +} + +function flushUserContent(messages: OpenAIMessage[], parts: OpenAIContentPart[]): void { + if (parts.length === 0) { + return; + } + + const onlyText = parts.every((part) => part.type === 'text'); + messages.push({ + role: 'user', + content: onlyText ? parts.map((part) => (part as OpenAITextPart).text).join('\n') : [...parts], + }); + parts.length = 0; +} + +function transformTools(value: unknown): ProxyOpenAIRequest['tools'] { + if (!Array.isArray(value)) { + return undefined; + } + + const tools = value + .filter( + (entry): entry is { name?: unknown; description?: unknown; input_schema?: unknown } => + typeof entry === 'object' && entry !== null + ) + .map((entry) => ({ + type: 'function' as const, + function: { + name: typeof entry.name === 'string' ? entry.name : 'tool', + ...(typeof entry.description === 'string' ? { description: entry.description } : {}), + parameters: + typeof entry.input_schema === 'object' && entry.input_schema !== null + ? (entry.input_schema as Record) + : { type: 'object', properties: {} }, + }, + })); + + return tools.length > 0 ? tools : undefined; +} + +function mapThinkingToReasoning( + thinking: AnthropicThinking | undefined +): Pick { + if (!thinking || thinking.type === 'disabled') { + return {}; + } + + if (thinking.type !== 'enabled') { + throw new Error('thinking.type must be "enabled" or "disabled"'); + } + + const effort = + typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192 + ? 'high' + : 'medium'; + + return { + reasoning_effort: effort, + reasoning: { + enabled: true, + effort, + }, + }; +} + +function transformMessages(messagesValue: unknown): OpenAIMessage[] { + if (!Array.isArray(messagesValue)) { + throw new Error('messages must be an array'); + } + + const translatedMessages: OpenAIMessage[] = []; + + messagesValue.forEach((message, messageIndex) => { + const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage; + const role = parsedMessage.role; + if (role !== 'user' && role !== 'assistant') { + throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`); + } + + const content = parsedMessage.content; + if (typeof content === 'string') { + translatedMessages.push({ role, content }); + return; + } + + if (!Array.isArray(content)) { + throw new Error(`messages[${messageIndex}].content must be a string or array`); + } + + const userParts: OpenAIContentPart[] = []; + const assistantTextParts: string[] = []; + const toolCalls: NonNullable = []; + let sawToolResult = false; + + content.forEach((block, blockIndex) => { + const parsed = assertObject( + block, + `messages[${messageIndex}].content[${blockIndex}]` + ) as AnthropicContentBlock; + + if (parsed.type === 'text') { + const text = typeof parsed.text === 'string' ? parsed.text : ''; + if (role === 'user') { + userParts.push({ type: 'text', text }); + } else { + assistantTextParts.push(text); + } + return; + } + + if (isImageBlock(parsed)) { + if (role !== 'user') { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] image requires user role` + ); + } + userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`)); + return; + } + + if (isToolUseBlock(parsed)) { + if (role !== 'assistant') { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role` + ); + } + toolCalls.push({ + id: + typeof parsed.id === 'string' && parsed.id.length > 0 + ? parsed.id + : createFallbackToolId(messageIndex, blockIndex), + type: 'function', + function: { + name: typeof parsed.name === 'string' ? parsed.name : 'tool', + arguments: safeJsonStringify(parsed.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK), + }, + }); + return; + } + + if (isToolResultBlock(parsed)) { + if (role !== 'user') { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` + ); + } + if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string` + ); + } + sawToolResult = true; + flushUserContent(translatedMessages, userParts); + translatedMessages.push({ + role: 'tool', + tool_call_id: parsed.tool_use_id, + content: toToolResultContent( + parsed.content, + `messages[${messageIndex}].content[${blockIndex}].content` + ), + }); + return; + } + + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].type "${String(parsed.type)}" is not supported` + ); + }); + + if (role === 'assistant') { + translatedMessages.push({ + role: 'assistant', + content: assistantTextParts.join('\n'), + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); + return; + } + + if (userParts.length > 0 || !sawToolResult) { + flushUserContent(translatedMessages, userParts); + } + }); + + return translatedMessages; +} + +export class ProxyRequestTransformer { + transform(raw: unknown): ProxyOpenAIRequest { + const source = assertObject(raw || {}, 'request') as AnthropicProxyRequestShape; + const messages = transformMessages(source.messages); + const system = source.system; + const allMessages = + system !== undefined + ? [ + { role: 'system', content: flattenTextContent(system, 'system') } as OpenAIMessage, + ...messages, + ] + : messages; + + return { + model: + typeof source.model === 'string' && source.model.trim().length > 0 + ? source.model.trim() + : undefined, + stream: source.stream === true, + messages: allMessages, + max_tokens: asNumber(source.max_tokens), + temperature: asNumber(source.temperature), + top_p: asNumber(source.top_p), + stop: asStringArray(source.stop_sequences), + metadata: asMetadata(source.metadata), + tools: transformTools(source.tools), + ...mapThinkingToReasoning(source.thinking), + }; + } +} diff --git a/src/proxy/transformers/sse-stream-transformer.ts b/src/proxy/transformers/sse-stream-transformer.ts new file mode 100644 index 00000000..dff2b7ce --- /dev/null +++ b/src/proxy/transformers/sse-stream-transformer.ts @@ -0,0 +1,260 @@ +import { DeltaAccumulator } from '../../glmt/delta-accumulator'; +import { GlmtTransformer } from '../../glmt/glmt-transformer'; +import { SSEParser } from '../../glmt/sse-parser'; +import type { OpenAIResponse, SSEEvent } from '../../glmt/pipeline'; + +const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible JSON response'; +const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible SSE response'; + +type ResponseHeaders = Headers | Record | Array<[string, string]>; + +interface AnthropicErrorPayload { + type: 'error'; + error: { + type: string; + message: string; + }; +} + +function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload { + return { + type: 'error', + error: { + type, + message, + }, + }; +} + +function formatErrorForLog(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function logTranslationError(context: string, error: unknown): void { + console.error(`[proxy-sse-transformer] ${context}: ${formatErrorForLog(error)}`); +} + +export function createAnthropicErrorResponse( + status: number, + type: string, + message: string, + headers?: ResponseHeaders +): Response { + const responseHeaders = new Headers(headers); + responseHeaders.set('Content-Type', 'application/json'); + responseHeaders.delete('Content-Length'); + + return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), { + status, + headers: responseHeaders, + }); +} + +function formatSseEvent(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function hasTranslatableChoices(value: unknown): value is OpenAIResponse { + if (typeof value !== 'object' || value === null) { + return false; + } + + const { choices } = value as OpenAIResponse; + if (!Array.isArray(choices) || choices.length === 0) { + return false; + } + + const firstChoice = choices[0]; + if (typeof firstChoice !== 'object' || firstChoice === null) { + return false; + } + + const message = (firstChoice as { message?: unknown }).message; + return typeof message === 'object' && message !== null; +} + +function isSyntheticTransformationFallback(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.startsWith('msg_error_') + ); +} + +async function createAnthropicErrorProxyResponse(response: Response): Promise { + const headers = new Headers(response.headers); + headers.delete('Content-Type'); + headers.delete('Content-Length'); + + let type = + response.status === 401 + ? 'authentication_error' + : response.status === 429 + ? 'rate_limit_error' + : response.status >= 400 && response.status < 500 + ? 'invalid_request_error' + : 'api_error'; + let message = `Upstream request failed with status ${response.status}`; + + try { + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json')) { + const payload = (await response.json()) as { + error?: { type?: string; message?: string }; + message?: string; + }; + + if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) { + type = payload.error.type; + } + + if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) { + message = payload.error.message; + } else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) { + message = payload.message; + } + } else { + const text = (await response.text()).trim(); + if (text.length > 0) { + message = text; + } + } + } catch (error) { + logTranslationError('Failed to parse upstream error response', error); + } + + return createAnthropicErrorResponse(response.status, type, message, headers); +} + +async function createAnthropicJsonResponse(response: Response): Promise { + try { + const openAIResponse = await response.json(); + if (!hasTranslatableChoices(openAIResponse)) { + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } + + const anthropicResponse = new GlmtTransformer().transformResponse(openAIResponse); + if (isSyntheticTransformationFallback(anthropicResponse)) { + logTranslationError( + 'OpenAI-compatible JSON translation produced synthetic fallback response', + anthropicResponse + ); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } + + return new Response(JSON.stringify(anthropicResponse), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + logTranslationError('OpenAI-compatible JSON translation failed', error); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } +} + +function createAnthropicStreamingResponse(response: Response): Response { + const body = response.body; + if (!body) { + return createAnthropicErrorResponse( + 502, + 'api_error', + 'Upstream stream ended before a response body was available' + ); + } + + const parser = new SSEParser({ throwOnMalformedJson: true }); + const transformer = new GlmtTransformer(); + const accumulator = new DeltaAccumulator({}); + const encoder = new TextEncoder(); + + const readable = new ReadableStream({ + async start(controller) { + const reader = body.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + + const events = parser.parse(Buffer.from(value)); + for (const event of events) { + const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator); + for (const anthropicEvent of anthropicEvents) { + controller.enqueue( + encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) + ); + } + } + } + + if (!accumulator.isFinalized() && accumulator.isMessageStarted()) { + for (const anthropicEvent of transformer.finalizeDelta(accumulator)) { + controller.enqueue( + encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) + ); + } + } + } catch (error) { + logTranslationError('OpenAI-compatible SSE translation failed', error); + controller.enqueue( + encoder.encode( + formatSseEvent( + 'error', + createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE) + ) + ) + ); + } finally { + reader.releaseLock(); + controller.close(); + } + }, + }); + + return new Response(readable, { + status: response.status, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} + +export async function createAnthropicProxyResponse(response: Response): Promise { + if (!response.ok) { + return createAnthropicErrorProxyResponse(response); + } + + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + const isEventStream = + contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;'); + + return isEventStream + ? createAnthropicStreamingResponse(response) + : createAnthropicJsonResponse(response); +} + +export class ProxySseStreamTransformer { + async transform(response: Response): Promise { + return createAnthropicProxyResponse(response); + } + + error(status: number, type: string, message: string): Response { + return createAnthropicErrorResponse(status, type, message); + } +} diff --git a/src/proxy/upstream-url.ts b/src/proxy/upstream-url.ts new file mode 100644 index 00000000..d41d89b6 --- /dev/null +++ b/src/proxy/upstream-url.ts @@ -0,0 +1,36 @@ +function normalizePathname(pathname: string): string { + const trimmed = pathname.replace(/\/+$/, ''); + return trimmed || ''; +} + +function ensureSupportedProtocol(parsed: URL): void { + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Unsupported upstream protocol: ${parsed.protocol}`); + } +} + +function buildResolvedUrl(baseUrl: string, suffix: string): string { + const parsed = new URL(baseUrl); + ensureSupportedProtocol(parsed); + + const pathname = normalizePathname(parsed.pathname); + if (pathname.endsWith(suffix)) { + return parsed.toString(); + } + + if (pathname.endsWith('/v1') || pathname.endsWith('/api')) { + parsed.pathname = `${pathname}${suffix.startsWith('/') ? suffix : `/${suffix}`}`; + return parsed.toString(); + } + + parsed.pathname = pathname ? `${pathname}/v1${suffix}` : `/v1${suffix}`; + return parsed.toString(); +} + +export function resolveOpenAIChatCompletionsUrl(baseUrl: string): string { + return buildResolvedUrl(baseUrl, '/chat/completions'); +} + +export function resolveOpenAIModelsUrl(baseUrl: string): string { + return buildResolvedUrl(baseUrl, '/models'); +} diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index cf04b0fc..22c6304f 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -121,7 +121,7 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] { 'default', ...all.accounts, ...all.settings, - ...all.cliproxy, + ...all.cliproxy.filter((profileName) => profileName !== 'cursor'), ...all.cliproxyVariants, ]; const deduped = [...new Set(orderedNames)]; diff --git a/src/targets/codex-detector.ts b/src/targets/codex-detector.ts index 97cfac1a..e7070617 100644 --- a/src/targets/codex-detector.ts +++ b/src/targets/codex-detector.ts @@ -10,17 +10,26 @@ const CODEX_CONFIG_OVERRIDE_PROBE_ARGS = ['-c', 'model="gpt-5"', '--version']; function buildWindowsCodexCandidates(matches: string[]): string[] { const shellCandidates = matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry)); const bareCandidates = matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry)); - const prioritized: string[] = []; - - for (const entry of shellCandidates) { - if (/\.(cmd|bat)$/i.test(entry)) { - prioritized.push(entry.replace(/\.(cmd|bat)$/i, '.ps1')); + const prioritized = shellCandidates.map((entry) => { + if (!/\.ps1$/i.test(entry)) { + return entry; } - prioritized.push(entry); - } - prioritized.push(...bareCandidates); - return [...new Set(prioritized)]; + for (const preferredExtension of ['.cmd', '.bat', '.exe']) { + const siblingCandidate = entry.replace(/\.ps1$/i, preferredExtension); + try { + if (fs.statSync(siblingCandidate).isFile()) { + return siblingCandidate; + } + } catch { + // Ignore missing sibling wrappers and keep the original PowerShell path. + } + } + + return entry; + }); + + return [...new Set([...prioritized, ...bareCandidates])]; } function runCodexProbe(codexPath: string, args: string[]): string | undefined { diff --git a/src/targets/droid-provider.ts b/src/targets/droid-provider.ts index 22a8d8c2..1c5f9850 100644 --- a/src/targets/droid-provider.ts +++ b/src/targets/droid-provider.ts @@ -86,8 +86,10 @@ export function inferDroidProviderFromBaseUrl( host.includes('api.deepinfra.com') || host.includes('api.fireworks.ai') || host.includes('inference.baseten.co') || + host.includes('dashscope') || host.includes('huggingface.co') || host.includes('ollama.com') || + pathname.includes('/compatible-mode') || pathname.includes('/openai') || pathname.includes('/chat/completions') ) { diff --git a/src/types/config.ts b/src/types/config.ts index b84658e1..47de4f0d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -40,6 +40,19 @@ export interface CLIProxyVariantsConfig { [profileName: string]: CLIProxyVariantConfig; } +export interface OpenAICompatProxyRoutingConfig { + default?: string; + background?: string; + think?: string; + longContext?: string; + webSearch?: string; + longContextThreshold?: number; +} + +export interface OpenAICompatProxyConfig { + routing?: OpenAICompatProxyRoutingConfig; +} + /** * Main CCS configuration * Located at: ~/.ccs/config.json @@ -51,6 +64,8 @@ export interface Config { profile_targets?: Record; /** User-defined CLIProxy profile variants (optional) */ cliproxy?: CLIProxyVariantsConfig; + /** OpenAI-compatible local proxy configuration (optional) */ + proxy?: OpenAICompatProxyConfig; /** Legacy continuity inheritance mapping (profile -> source account) */ continuity_inherit_from_account?: Record; } diff --git a/src/types/index.ts b/src/types/index.ts index f88f447d..934f18ae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,8 @@ export type { ProfilesRegistry, CLIProxyVariantConfig, CLIProxyVariantsConfig, + OpenAICompatProxyConfig, + OpenAICompatProxyRoutingConfig, } from './config'; export { isConfig, isSettings } from './config'; diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts new file mode 100644 index 00000000..e1a9f6fe --- /dev/null +++ b/src/utils/browser/browser-settings.ts @@ -0,0 +1,103 @@ +import * as path from 'path'; +import type { BrowserConfig } from '../../config/unified-config-types'; +import { getCcsDir } from '../config-manager'; +import { expandPath } from '../helpers'; + +export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; + +export interface EffectiveClaudeBrowserAttachConfig { + enabled: boolean; + source: 'config' | BrowserOverrideSource; + overrideActive: boolean; + userDataDir: string; + devtoolsPort: number; + hasExplicitDevtoolsPort: boolean; +} + +export function getRecommendedBrowserUserDataDir(): string { + return path.join(getCcsDir(), 'browser', 'chrome-user-data'); +} + +export function resolveBrowserUserDataDir(value?: string): string | undefined { + return value?.trim() ? expandPath(value) : undefined; +} + +export function getBrowserAttachOverride(env: NodeJS.ProcessEnv = process.env): { + userDataDir?: string; + devtoolsPort?: number; + source?: BrowserOverrideSource; +} { + const explicitUserDataDir = resolveBrowserUserDataDir(env.CCS_BROWSER_USER_DATA_DIR); + if (explicitUserDataDir) { + return { + userDataDir: explicitUserDataDir, + devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT), + source: 'CCS_BROWSER_USER_DATA_DIR', + }; + } + + const legacyProfileDir = resolveBrowserUserDataDir(env.CCS_BROWSER_PROFILE_DIR); + if (legacyProfileDir) { + return { + userDataDir: legacyProfileDir, + devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT), + source: 'CCS_BROWSER_PROFILE_DIR', + }; + } + + return {}; +} + +export function getEffectiveClaudeBrowserAttachConfig( + config: BrowserConfig, + env: NodeJS.ProcessEnv = process.env +): EffectiveClaudeBrowserAttachConfig { + const override = getBrowserAttachOverride(env); + const configUserDataDir = + resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir(); + const configPort = normalizeDevtoolsPort(config.claude.devtools_port); + + if (override.userDataDir) { + return { + enabled: true, + source: override.source as BrowserOverrideSource, + overrideActive: true, + userDataDir: override.userDataDir, + devtoolsPort: override.devtoolsPort ?? configPort, + hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, + }; + } + + return { + enabled: config.claude.enabled, + source: 'config', + overrideActive: false, + userDataDir: configUserDataDir, + devtoolsPort: configPort, + // Config-backed browser attach always keeps an explicit port so launches + // stay aligned with Settings > Browser, even when the effective value is + // the default 9222. + hasExplicitDevtoolsPort: true, + }; +} + +function parseDevtoolsPort(value?: string): number | undefined { + if (!value?.trim() || !/^\d+$/.test(value.trim())) { + return undefined; + } + + return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10)); +} + +function normalizeDevtoolsPort(value: number | undefined): number { + if (!Number.isFinite(value)) { + return 9222; + } + + const port = Math.floor(value as number); + if (port < 1 || port > 65535) { + return 9222; + } + + return port; +} diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts new file mode 100644 index 00000000..0ea469d2 --- /dev/null +++ b/src/utils/browser/browser-status.ts @@ -0,0 +1,188 @@ +import { getBrowserConfig } from '../../config/unified-config-loader'; +import { getCodexBinaryInfo } from '../../targets/codex-detector'; +import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; +import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer'; +import { getNodePlatformKey } from './platform'; +import { + getEffectiveClaudeBrowserAttachConfig, + getRecommendedBrowserUserDataDir, +} from './browser-settings'; + +export interface BrowserLaunchCommands { + darwin: string; + linux: string; + win32: string; +} + +export interface ClaudeBrowserStatus { + enabled: boolean; + source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; + overrideActive: boolean; + state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready'; + title: string; + detail: string; + nextStep: string; + effectiveUserDataDir: string; + recommendedUserDataDir: string; + devtoolsPort: number; + managedMcpServerName: string; + managedMcpServerPath: string; + launchCommands: BrowserLaunchCommands; + runtimeEnv?: BrowserRuntimeEnv; +} + +export interface CodexBrowserStatus { + enabled: boolean; + state: 'disabled' | 'enabled' | 'unsupported_build'; + title: string; + detail: string; + nextStep: string; + serverName: string; + supportsConfigOverrides: boolean; + binaryPath: string | null; + version?: string; +} + +export interface BrowserStatusPayload { + claude: ClaudeBrowserStatus; + codex: CodexBrowserStatus; +} + +export async function getBrowserStatus(): Promise { + const browserConfig = getBrowserConfig(); + return { + claude: await buildClaudeBrowserStatus(browserConfig), + codex: buildCodexBrowserStatus(browserConfig), + }; +} + +async function buildClaudeBrowserStatus( + browserConfig = getBrowserConfig() +): Promise { + const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig); + const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort); + const base: Omit = { + enabled: effective.enabled, + source: effective.source, + overrideActive: effective.overrideActive, + effectiveUserDataDir: effective.userDataDir, + recommendedUserDataDir: getRecommendedBrowserUserDataDir(), + devtoolsPort: effective.devtoolsPort, + managedMcpServerName: getBrowserMcpServerName(), + managedMcpServerPath: getBrowserMcpServerPath(), + launchCommands, + }; + + if (!effective.enabled) { + return { + ...base, + state: 'disabled', + title: 'Claude Browser Attach is disabled.', + detail: + 'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.', + nextStep: + 'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.', + }; + } + + try { + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir: effective.userDataDir, + devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined, + }); + + return { + ...base, + state: 'ready', + title: 'Claude Browser Attach is ready.', + detail: + 'CCS can reach the configured Chrome DevTools endpoint for the current attach session.', + nextStep: 'Launch a Claude-target CCS session to use the managed browser MCP runtime.', + runtimeEnv, + }; + } catch (error) { + const message = (error as Error).message; + if (message.includes('Chrome profile directory is invalid')) { + return { + ...base, + state: 'path_missing', + title: 'Claude Browser Attach path is missing.', + detail: message, + nextStep: `Create or choose a Chrome user-data directory, then launch Chrome with attach mode enabled. Example: ${launchCommands[getNodePlatformKey()]}`, + }; + } + + if (message.includes('Chrome reuse metadata')) { + return { + ...base, + state: 'browser_not_running', + title: 'Claude Browser Attach could not find a running browser session.', + detail: message, + nextStep: `Start Chrome with remote debugging and the configured user-data dir. Example: ${launchCommands[getNodePlatformKey()]}`, + }; + } + + return { + ...base, + state: 'endpoint_unreachable', + title: 'Claude Browser Attach could not reach the DevTools endpoint.', + detail: message, + nextStep: `Restart the attach browser session or confirm the configured port. Example: ${launchCommands[getNodePlatformKey()]}`, + }; + } +} + +function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrowserStatus { + if (!browserConfig.codex.enabled) { + return { + enabled: false, + state: 'disabled', + title: 'Codex Browser Tools are disabled.', + detail: 'CCS will not inject Playwright MCP browser tooling into Codex-target launches.', + nextStep: + 'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.', + serverName: 'ccs_browser', + supportsConfigOverrides: false, + binaryPath: null, + }; + } + + const binaryInfo = getCodexBinaryInfo({ includeVersion: true, includeFeatures: true }); + const supportsConfigOverrides = Boolean(binaryInfo?.features?.includes('config-overrides')); + if (!binaryInfo || !supportsConfigOverrides) { + return { + enabled: true, + state: 'unsupported_build', + title: 'Codex Browser Tools need a Codex build with --config override support.', + detail: binaryInfo + ? `Detected Codex at ${binaryInfo.path}, but it does not advertise --config overrides.` + : 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', + nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', + serverName: 'ccs_browser', + supportsConfigOverrides, + binaryPath: binaryInfo?.path ?? null, + version: binaryInfo?.version, + }; + } + + return { + enabled: true, + state: 'enabled', + title: 'Codex Browser Tools are enabled.', + detail: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.', + nextStep: 'Use a Codex-target CCS launch to access browser tools.', + serverName: 'ccs_browser', + supportsConfigOverrides, + binaryPath: binaryInfo.path, + version: binaryInfo.version, + }; +} + +function buildLaunchCommands(userDataDir: string, devtoolsPort: number): BrowserLaunchCommands { + const quotedPath = JSON.stringify(userDataDir); + return { + darwin: `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`, + linux: `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`, + win32: `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`, + }; +} diff --git a/src/utils/browser/index.ts b/src/utils/browser/index.ts index be30da8e..22f92251 100644 --- a/src/utils/browser/index.ts +++ b/src/utils/browser/index.ts @@ -17,9 +17,22 @@ export { export { appendBrowserToolArgs } from './claude-tool-args'; +export { + getRecommendedBrowserUserDataDir, + getBrowserAttachOverride, + getEffectiveClaudeBrowserAttachConfig, +} from './browser-settings'; + export { resolveBrowserRuntimeEnv, resolveDefaultChromeUserDataDir, resolveConfiguredBrowserProfileDir, } from './chrome-reuse'; export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse'; + +export { getBrowserStatus } from './browser-status'; +export type { + BrowserStatusPayload, + ClaudeBrowserStatus, + CodexBrowserStatus, +} from './browser-status'; diff --git a/src/utils/browser/platform.ts b/src/utils/browser/platform.ts new file mode 100644 index 00000000..9f747258 --- /dev/null +++ b/src/utils/browser/platform.ts @@ -0,0 +1,9 @@ +export type BrowserPlatformKey = 'darwin' | 'linux' | 'win32'; + +export function getNodePlatformKey( + platform: NodeJS.Platform = process.platform +): BrowserPlatformKey { + if (platform === 'darwin') return 'darwin'; + if (platform === 'win32') return 'win32'; + return 'linux'; +} diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index df5ce92e..5f04ed4a 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -396,7 +396,7 @@ function resolveBackend( }; } - if (profileType === 'cursor' || profileName === 'cursor') { + if (profileType === 'cursor') { return { backendId: null, backendDisplayName: null, diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts new file mode 100644 index 00000000..14d8bdc3 --- /dev/null +++ b/src/web-server/routes/browser-routes.ts @@ -0,0 +1,138 @@ +import { Router, type Request, type Response } from 'express'; +import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { getBrowserStatus } from '../../utils/browser'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; + +const router = Router(); +const BROWSER_LOCAL_ACCESS_ERROR = + 'Browser endpoints require localhost access when dashboard auth is disabled.'; + +interface BrowserRouteBody { + claude?: { + enabled?: boolean; + userDataDir?: string; + devtoolsPort?: number; + }; + codex?: { + enabled?: boolean; + }; +} + +function isValidDevtoolsPort(value: number): boolean { + return Number.isInteger(value) && value >= 1 && value <= 65535; +} + +router.use((req: Request, res: Response, next) => { + if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) { + next(); + } +}); + +router.get('/', async (_req: Request, res: Response): Promise => { + try { + const config = getBrowserConfig(); + const status = await getBrowserStatus(); + res.json({ + config: toBrowserRouteConfig(config), + status, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.get('/status', async (_req: Request, res: Response): Promise => { + try { + res.json(await getBrowserStatus()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.put('/', async (req: Request, res: Response): Promise => { + if ( + req.body === null || + req.body === undefined || + typeof req.body !== 'object' || + Array.isArray(req.body) + ) { + res.status(400).json({ error: 'Invalid request body. Must be an object.' }); + return; + } + + const { claude, codex } = req.body as BrowserRouteBody; + if (claude && (typeof claude !== 'object' || Array.isArray(claude))) { + res.status(400).json({ error: 'Invalid value for claude. Must be an object.' }); + return; + } + if (codex && (typeof codex !== 'object' || Array.isArray(codex))) { + res.status(400).json({ error: 'Invalid value for codex. Must be an object.' }); + return; + } + if (claude?.enabled !== undefined && typeof claude.enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for claude.enabled. Must be a boolean.' }); + return; + } + if (claude?.userDataDir !== undefined && typeof claude.userDataDir !== 'string') { + res.status(400).json({ error: 'Invalid value for claude.userDataDir. Must be a string.' }); + return; + } + if ( + claude?.devtoolsPort !== undefined && + (typeof claude.devtoolsPort !== 'number' || !isValidDevtoolsPort(claude.devtoolsPort)) + ) { + res.status(400).json({ + error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.', + }); + return; + } + if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' }); + return; + } + + try { + const current = getBrowserConfig(); + const nextClaudeUserDataDir = + claude?.userDataDir === undefined ? current.claude.user_data_dir : claude.userDataDir.trim(); + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: claude?.enabled ?? current.claude.enabled, + user_data_dir: nextClaudeUserDataDir, + devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port, + }, + codex: { + enabled: codex?.enabled ?? current.codex.enabled, + }, + }; + }); + + const config = getBrowserConfig(); + const status = await getBrowserStatus(); + res.json({ + success: true, + browser: { + config: toBrowserRouteConfig(config), + status, + }, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +function toBrowserRouteConfig(config: ReturnType) { + return { + claude: { + enabled: config.claude.enabled, + userDataDir: config.claude.user_data_dir, + devtoolsPort: config.claude.devtools_port, + }, + codex: { + enabled: config.codex.enabled, + }, + }; +} + +export default router; diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 0b2bdf01..6e12f5fa 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -33,6 +33,7 @@ import { } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; +import { invalidateQuotaCache } from '../../cliproxy/quota-response-cache'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; import { @@ -42,6 +43,7 @@ import { listProviderTokenSnapshots, registerAccountFromToken, } from '../../cliproxy/auth/token-manager'; +import { parseGitLabPatAuthResponse } from '../../cliproxy/auth/gitlab-pat-response'; import { CLIPROXY_CALLBACK_PROVIDER_MAP, CLIPROXY_AUTH_URL_PROVIDER_MAP, @@ -190,6 +192,13 @@ function shouldKeepWaitingForLocalToken( ); } +function invalidateQuotaForRegisteredAccount(account: { + provider: CLIProxyProvider; + id: string; +}): void { + invalidateQuotaCache(account.provider, account.id); +} + function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { if (raw === undefined || raw === null) { return { method: normalizeKiroAuthMethod(), invalid: false }; @@ -221,6 +230,20 @@ function parseKiroIDCFlow(raw: unknown): { flow: KiroIDCFlow; invalid: boolean } return { flow: normalizeKiroIDCFlow(normalized), invalid: false }; } +function parseGitLabAuthMode(raw: unknown): { mode: 'oauth' | 'pat'; invalid: boolean } { + if (raw === undefined || raw === null || raw === '') { + return { mode: 'oauth', invalid: false }; + } + if (typeof raw !== 'string') { + return { mode: 'oauth', invalid: true }; + } + const normalized = raw.trim().toLowerCase(); + if (normalized === 'oauth' || normalized === 'pat') { + return { mode: normalized, invalid: false }; + } + return { mode: 'oauth', invalid: true }; +} + export function getKiroStartIDCValidationError(options: { kiroMethod: KiroAuthMethod; kiroIDCStartUrl?: string; @@ -595,6 +618,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise) : {}; const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; const kiroMethodRaw = requestBody.kiroMethod; + const gitlabAuthModeRaw = requestBody.gitlabAuthMode; + const gitlabBaseUrl = + typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined; const riskAcknowledgement = requestBody.riskAcknowledgement; const nickname = nicknameRaw?.trim(); const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); + const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } = + parseGitLabAuthMode(gitlabAuthModeRaw); // Check remote mode const target = getProxyTarget(); @@ -854,6 +987,22 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + if (provider === 'gitlab' && invalidGitLabAuthMode) { + res.status(400).json({ + error: 'Invalid gitlabAuthMode. Supported: oauth, pat', + code: 'INVALID_GITLAB_AUTH_MODE', + }); + return; + } + + if (provider === 'gitlab' && gitlabAuthMode === 'pat') { + res.status(400).json({ + error: 'GitLab PAT login must use /api/cliproxy/auth/gitlab/start', + code: 'GITLAB_PAT_REQUIRES_START', + }); + return; + } + if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) { const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement); if (!validation.valid) { @@ -892,11 +1041,18 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise provider === 'kiro' && kiroManagementMethod ? `&method=${encodeURIComponent(kiroManagementMethod)}` : ''; + const gitlabQuery = + provider === 'gitlab' && gitlabBaseUrl + ? `&base_url=${encodeURIComponent(gitlabBaseUrl)}` + : ''; // Call CLIProxyAPI to start OAuth and get auth URL // CLIProxyAPI management routes are under /v0/management prefix const response = await fetch( - buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}`), + buildProxyUrl( + target, + `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}` + ), { headers: buildManagementHeaders(target) } ); @@ -1022,6 +1178,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise { currentConfig.cliproxy = mergeCliproxyConfig(currentConfig, config); } + if (config.proxy !== undefined) { + currentConfig.proxy = config.proxy; + } + if (config.preferences !== undefined) { currentConfig.preferences = config.preferences; } diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 5a1d7f76..64b99ad0 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -19,6 +19,7 @@ import settingsRoutes from './settings-routes'; import channelsRoutes from './channels-routes'; import websearchRoutes from './websearch-routes'; import imageAnalysisRoutes from './image-analysis-routes'; +import browserRoutes from './browser-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxyRoutingRoutes from './cliproxy-routing-routes'; @@ -97,6 +98,7 @@ apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ==================== apiRoutes.use('/websearch', websearchRoutes); +apiRoutes.use('/browser', browserRoutes); apiRoutes.use('/image-analysis', imageAnalysisRoutes); // ==================== Copilot ==================== @@ -104,6 +106,7 @@ apiRoutes.use('/copilot', copilotRoutes); // ==================== Cursor ==================== apiRoutes.use('/cursor', cursorRoutes); +apiRoutes.use('/legacy/cursor', cursorRoutes); // ==================== Droid ==================== apiRoutes.use('/droid', droidRoutes); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 28df3ec2..6ffbed54 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -30,6 +30,7 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar import type { Settings } from '../../types/config'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { resolveProviderSettingsPath } from '../../cliproxy/config/env-builder'; import { expandPath } from '../../utils/helpers'; import { canonicalizeModelIdForProvider, @@ -101,6 +102,17 @@ function resolveSettingsPath(profileOrVariant: string): string { const ccsDir = getCcsDir(); const resolvedCcsDir = path.resolve(ccsDir); + const directProvider = mapExternalProviderName(profileOrVariant); + if (directProvider) { + if (profileOrVariant !== directProvider) { + return resolvePathWithin( + resolvedCcsDir, + path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`) + ); + } + return path.resolve(resolveProviderSettingsPath(directProvider)); + } + // Check if this is a variant const variants = listVariants(); const variant = variants[profileOrVariant]; diff --git a/src/web-server/services/compatible-cli-docs-registry.ts b/src/web-server/services/compatible-cli-docs-registry.ts index 5cba7137..cfa467e6 100644 --- a/src/web-server/services/compatible-cli-docs-registry.ts +++ b/src/web-server/services/compatible-cli-docs-registry.ts @@ -114,6 +114,7 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record] overlay on top of base config', 'CCS-backed Codex launches may apply transient -c overrides and CCS_CODEX_API_KEY', 'Official docs treat model_providers, mcp_servers, features, and project trust as schema-backed config surfaces', + 'CCS-managed browser tooling for Codex should be configured from Settings > Browser, not by editing the ccs_browser MCP entry directly', ], links: [ { diff --git a/tests/e2e/openai-provider-routing.e2e.test.ts b/tests/e2e/openai-provider-routing.e2e.test.ts new file mode 100644 index 00000000..cc2d823e --- /dev/null +++ b/tests/e2e/openai-provider-routing.e2e.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeAll, 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, spawnSync } from 'child_process'; +import getPort from 'get-port'; + +const DIST_ENTRY = path.join(process.cwd(), 'dist', 'ccs.js'); + +let originalCcsHome: string | undefined; +let tempDir: string; +let upstreamServer: http.Server; +let upstreamBody: unknown; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-e2e-')); + upstreamBody = undefined; +}); + +afterEach(() => { + try { + upstreamServer?.close(); + } catch { + // Best-effort cleanup. + } + spawnSync(process.execPath, [DIST_ENTRY, 'proxy', 'stop'], { + env: { ...process.env, CCS_HOME: tempDir }, + }); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +function startMockUpstream(port: number): Promise { + return new Promise((resolve) => { + upstreamServer = http.createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) { + body += chunk.toString(); + } + upstreamBody = JSON.parse(body); + + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n' + ); + res.end('data: [DONE]\n\n'); + }); + upstreamServer.listen(port, '127.0.0.1', () => resolve()); + }); +} + +function runCli(args: string[], env: Record): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, [DIST_ENTRY, ...args], { + env: { + ...process.env, + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('close', (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} + +beforeAll(() => { + const result = spawnSync(process.execPath, ['run', 'build'], { + encoding: 'utf8', + env: process.env, + }); + expect(result.status).toBe(0); +}); + +describe('openai provider routing e2e', () => { + it('routes a settings profile through the local proxy into an OpenAI-compatible upstream', async () => { + const upstreamPort = await getPort(); + await startMockUpstream(upstreamPort); + + const ccsDir = path.join(tempDir, '.ccs'); + const binDir = path.join(tempDir, 'bin'); + const outputPath = path.join(tempDir, 'claude-output.json'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + + const settingsPath = path.join(ccsDir, 'hf.settings.json'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { hf: settingsPath } }, null, 2), + 'utf8' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`, + ANTHROPIC_AUTH_TOKEN: 'hf_token', + ANTHROPIC_MODEL: 'hf-model', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + fs.writeFileSync( + path.join(binDir, 'claude'), + `#!/usr/bin/env node +const fs = require('fs'); +(async () => { + const response = await fetch(\`\${process.env.ANTHROPIC_BASE_URL}/v1/messages\`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': process.env.ANTHROPIC_AUTH_TOKEN, + }, + body: JSON.stringify({ + model: process.env.ANTHROPIC_MODEL, + stream: true, + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + messages: [{ role: 'user', content: 'Find docs' }], + }), + }); + const text = await response.text(); + fs.writeFileSync(process.env.CCS_E2E_OUTPUT, JSON.stringify({ + status: response.status, + baseUrl: process.env.ANTHROPIC_BASE_URL, + authToken: process.env.ANTHROPIC_AUTH_TOKEN, + text + }, null, 2)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`, + { mode: 0o755 } + ); + + const result = await runCli(['hf'], { + ...process.env, + CCS_HOME: tempDir, + CCS_E2E_OUTPUT: outputPath, + PATH: `${binDir}:${process.env.PATH || ''}`, + }); + + expect(result.code).toBe(0); + const payload = JSON.parse(fs.readFileSync(outputPath, 'utf8')) as { + status: number; + baseUrl: string; + authToken: string; + text: string; + }; + + expect(payload.status).toBe(200); + expect(payload.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(payload.authToken).toMatch(/^[a-f0-9]{48}$/); + expect(payload.text).toContain('event: message_start'); + expect(payload.text).toContain('tool_use'); + expect(payload.text).toContain('message_stop'); + + const parsedUpstream = upstreamBody as { + messages?: Array<{ role: string; content: string }>; + tools?: Array<{ type: string }>; + }; + expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' }); + expect(parsedUpstream.tools?.[0]?.type).toBe('function'); + }, 35000); +}); diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts new file mode 100644 index 00000000..021a0919 --- /dev/null +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import getPort from 'get-port'; + +const DIST_ENTRY = path.join(process.cwd(), 'dist', 'ccs.js'); + +let originalCcsHome: string | undefined; +let tempDir: string; + +function runCli(args: string[], extraEnv: Record = {}) { + return spawnSync(process.execPath, [DIST_ENTRY, ...args], { + encoding: 'utf8', + env: { + ...process.env, + CCS_HOME: tempDir, + ...extraEnv, + }, + }); +} + +beforeAll(() => { + const result = spawnSync(process.execPath, ['run', 'build'], { + encoding: 'utf8', + env: process.env, + }); + expect(result.status).toBe(0); +}); + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-e2e-')); +}); + +afterEach(() => { + runCli(['proxy', 'stop']); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('proxy command e2e', () => { + it('starts, reports status, activates, and stops via the built CLI', async () => { + const port = await getPort(); + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const settingsPath = path.join(ccsDir, 'hf.settings.json'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { hf: settingsPath } }, null, 2), + 'utf8' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + 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', + }, + }), + 'utf8' + ); + + const started = runCli(['proxy', 'start', 'hf', '--port', String(port), '--host', '127.0.0.1']); + expect(started.status).toBe(0); + + const status = runCli(['proxy', 'status']); + expect(status.stdout).toContain(`Proxy running on port ${port}`); + expect(status.stdout).toContain('Host: 127.0.0.1'); + expect(status.stdout).toContain('Profile: hf'); + + const activate = runCli(['proxy', 'activate', '--shell', 'bash']); + expect(activate.stdout).toContain(`export ANTHROPIC_BASE_URL='http://127.0.0.1:${port}'`); + expect(activate.stdout).toMatch(/export ANTHROPIC_AUTH_TOKEN='[a-f0-9]{48}'/); + expect(activate.stdout).toContain("export DISABLE_TELEMETRY='1'"); + expect(activate.stdout).toContain("export DISABLE_COST_WARNINGS='1'"); + expect(activate.stdout).toContain("export API_TIMEOUT_MS='600000'"); + expect(activate.stdout).toContain("export NO_PROXY='127.0.0.1,localhost'"); + + const activateFish = runCli(['proxy', 'activate', '--fish']); + expect(activateFish.stdout).toContain(`set -gx ANTHROPIC_BASE_URL 'http://127.0.0.1:${port}'`); + + const health = await fetch(`http://127.0.0.1:${port}/health`); + expect(health.status).toBe(200); + + const info = await fetch(`http://127.0.0.1:${port}/`); + expect(info.status).toBe(200); + await expect(info.json()).resolves.toMatchObject({ + ok: true, + service: 'ccs-openai-compat-proxy', + bind: { + host: '127.0.0.1', + port, + }, + profile: { + name: 'hf', + }, + }); + + const stopped = runCli(['proxy', 'stop']); + expect(stopped.status).toBe(0); + }, 35000); +}); diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts index 7a4e28ed..09acca70 100644 --- a/tests/integration/cursor-daemon-lifecycle.test.ts +++ b/tests/integration/cursor-daemon-lifecycle.test.ts @@ -74,7 +74,7 @@ describe('cursor daemon lifecycle smoke', () => { }; expect(anthropicBody.type).toBe('error'); expect(anthropicBody.error?.type).toBe('authentication_error'); - expect(anthropicBody.error?.message).toContain('Run `ccs cursor auth` first'); + expect(anthropicBody.error?.message).toContain('Run `ccs legacy cursor auth` first'); const stopResult = await stopDaemon(); expect(stopResult.success).toBe(true); diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts new file mode 100644 index 00000000..5a5aa86d --- /dev/null +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -0,0 +1,127 @@ +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 getPort from 'get-port'; +import { + getOpenAICompatProxyStatus, + startOpenAICompatProxy, + stopOpenAICompatProxy, +} from '../../../src/proxy/proxy-daemon'; +import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-proxy-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(async () => { + await stopOpenAICompatProxy(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('openai proxy daemon lifecycle', () => { + it('starts, reports status, serves health/models, and stops', async () => { + const port = await getPort(); + const settingsPath = path.join(tempDir, 'hf.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + 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', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('hf', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected an OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile, { port }); + expect(started.success).toBe(true); + expect(started.authToken).toBeTruthy(); + + const status = await getOpenAICompatProxyStatus(); + expect(status.running).toBe(true); + expect(status.profileName).toBe('hf'); + expect(status.authToken).toBe(started.authToken); + + const health = await fetch(`http://127.0.0.1:${port}/health`); + expect(health.status).toBe(200); + + const models = (await ( + await fetch(`http://127.0.0.1:${port}/v1/models`, { + headers: { 'x-api-key': started.authToken! }, + }) + ).json()) as { data?: Array<{ id: string }> }; + expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']); + + const stopped = await stopOpenAICompatProxy(); + expect(stopped.success).toBe(true); + expect((await getOpenAICompatProxyStatus()).running).toBe(false); + }, 35000); + + it('refuses to replace a running proxy for a different profile', async () => { + const firstPort = await getPort(); + const firstSettingsPath = path.join(tempDir, 'hf.settings.json'); + fs.writeFileSync( + firstSettingsPath, + JSON.stringify({ + 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', + }, + }), + 'utf8' + ); + const firstProfile = resolveOpenAICompatProfileConfig('hf', firstSettingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!firstProfile) { + throw new Error('Expected first OpenAI-compatible profile'); + } + + const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort }); + expect(firstStart.success).toBe(true); + + const secondProfile = resolveOpenAICompatProfileConfig('openai', path.join(tempDir, 'openai.settings.json'), { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-openai', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!secondProfile) { + throw new Error('Expected second OpenAI-compatible profile'); + } + + const secondStart = await startOpenAICompatProxy(secondProfile, { port: await getPort() }); + expect(secondStart.success).toBe(false); + expect(secondStart.error).toContain('Proxy already running for profile "hf"'); + + const health = await fetch(`http://127.0.0.1:${firstPort}/health`); + expect(health.status).toBe(200); + }); +}); diff --git a/tests/integration/proxy/messages-edge-cases.test.ts b/tests/integration/proxy/messages-edge-cases.test.ts new file mode 100644 index 00000000..db85114e --- /dev/null +++ b/tests/integration/proxy/messages-edge-cases.test.ts @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import getPort from 'get-port'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server'; +import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +let proxyServer: http.Server; +let upstreamServer: http.Server; +let upstreamSockets = new Set(); +let upstreamPort: number; +let proxyPort: number; +let tempDir: string; +let originalTimeoutEnv: string | undefined; +let originalCcsHome: string | undefined; + +async function startUpstream( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise | void +): Promise { + upstreamServer = http.createServer((req, res) => { + void Promise.resolve(handler(req, res)); + }); + upstreamServer.on('connection', (socket) => { + upstreamSockets.add(socket); + socket.on('close', () => { + upstreamSockets.delete(socket); + }); + }); + await new Promise((resolve) => + upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve()) + ); +} + +async function requestProxy(payload: unknown, signal?: AbortSignal): Promise { + return fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test-proxy-token', + }, + body: JSON.stringify(payload), + signal, + }); +} + +beforeEach(async () => { + upstreamPort = await getPort(); + proxyPort = await getPort(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-edge-')); + originalTimeoutEnv = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS; + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempDir; +}); + +afterEach(async () => { + if (originalTimeoutEnv !== undefined) { + process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS = originalTimeoutEnv; + } else { + delete process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS; + } + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (proxyServer) { + await new Promise((resolve) => proxyServer.close(() => resolve())); + } + if (upstreamServer) { + for (const socket of upstreamSockets) { + socket.destroy(); + } + upstreamSockets = new Set(); + await new Promise((resolve) => upstreamServer.close(() => resolve())); + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('openai proxy message edge cases', () => { + async function startProxyWithHandler( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise | void + ) { + await startUpstream(handler); + const profile: OpenAICompatProfileConfig = { + profileName: 'hf', + settingsPath: '/tmp/hf.settings.json', + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'hf_token', + provider: 'generic-chat-completion-api', + model: 'hf-model', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: proxyPort, + authToken: 'test-proxy-token', + }); + } + + it('preserves rate-limit errors from the upstream provider', async () => { + await startProxyWithHandler((_req, res) => { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '9', + }); + res.end(JSON.stringify({ error: { message: 'rate limited' } })); + }); + + const response = await requestProxy({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }); + + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('9'); + await expect(response.json()).resolves.toMatchObject({ + type: 'error', + error: { + type: 'rate_limit_error', + message: 'rate limited', + }, + }); + }); + + it('returns api_error when the upstream JSON response has no usable choices', async () => { + await startProxyWithHandler((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: 'chatcmpl_empty', choices: [] })); + }); + + const response = await requestProxy({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + type: 'error', + error: { + type: 'api_error', + message: 'Failed to translate OpenAI-compatible JSON response', + }, + }); + }); + + it('streams thinking deltas and chunked tool-call arguments back as Anthropic SSE', async () => { + await startProxyWithHandler((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant"}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"reasoning_content":"Need to search first."}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\""}}]}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"docs\\"}"}}]}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":8,"completion_tokens":6}}\n\n' + ); + res.end('data: [DONE]\n\n'); + }); + + const response = await requestProxy({ + model: 'hf-model', + stream: true, + messages: [{ role: 'user', content: 'search docs' }], + }); + + const body = await response.text(); + expect(response.status).toBe(200); + expect(body).toContain('"type":"thinking_delta"'); + expect(body).toContain('"type":"tool_use"'); + expect(body).toContain('"partial_json":"{\\"q\\":\\""'); + expect(body).toContain('"partial_json":"docs\\"}"'); + expect(body).toContain('event: message_stop'); + }); + + it('returns a timeout error when the upstream does not respond in time', async () => { + process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS = '50'; + await startProxyWithHandler(async (req, _res) => { + await new Promise((resolve) => req.on('close', () => resolve())); + }); + + const response = await requestProxy({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + type: 'error', + error: { + type: 'api_error', + message: 'The upstream provider did not respond within 50ms', + }, + }); + }); + + it('emits an SSE error if the upstream stalls after response headers are sent', async () => { + process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS = '50'; + await startProxyWithHandler((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"partial"}}]}\n\n' + ); + }); + + const response = await requestProxy({ + model: 'hf-model', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }); + + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain('event: message_start'); + expect(body).toContain('event: error'); + expect(body).toContain('"message":"Failed to translate OpenAI-compatible SSE response"'); + }); + + it('aborts the upstream request when the client disconnects mid-flight', async () => { + await startProxyWithHandler(() => {}); + + await new Promise((resolve) => { + const request = http.request( + { + hostname: '127.0.0.1', + port: proxyPort, + path: '/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test-proxy-token', + }, + }, + () => resolve() + ); + + request.write( + JSON.stringify({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }) + ); + request.end(); + + setTimeout(() => { + request.destroy(new Error('client aborted')); + resolve(); + }, 50); + }); + + const logPath = path.join(tempDir, '.ccs', 'logs', 'current.jsonl'); + await Promise.race([ + new Promise((resolve, reject) => { + const startedAt = Date.now(); + const timer = setInterval(() => { + if (fs.existsSync(logPath)) { + const content = fs.readFileSync(logPath, 'utf8'); + if (content.includes('"event":"request.disconnect"')) { + clearInterval(timer); + resolve(); + return; + } + } + + if (Date.now() - startedAt > 1500) { + clearInterval(timer); + reject(new Error('proxy did not log disconnect cleanup')); + } + }, 50); + }), + ]); + }); +}); diff --git a/tests/integration/proxy/messages-endpoint.test.ts b/tests/integration/proxy/messages-endpoint.test.ts new file mode 100644 index 00000000..d6ce7469 --- /dev/null +++ b/tests/integration/proxy/messages-endpoint.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import getPort from 'get-port'; +import * as http from 'http'; +import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server'; +import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +let upstreamServer: http.Server; +let proxyServer: http.Server; +let upstreamBody: unknown; +let upstreamPort: number; +let proxyPort: number; + +function startMockUpstream(): Promise { + return new Promise((resolve) => { + upstreamServer = http.createServer(async (req, res) => { + if (req.method !== 'POST' || req.url !== '/v1/chat/completions') { + res.writeHead(404).end(); + return; + } + + let body = ''; + for await (const chunk of req) { + body += chunk.toString(); + } + upstreamBody = JSON.parse(body); + const parsed = upstreamBody as { stream?: boolean }; + + if (parsed.stream) { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n' + ); + res.end('data: [DONE]\n\n'); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'chatcmpl_1', + model: 'hf-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Plain answer' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 3 }, + }) + ); + }); + + upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve()); + }); +} + +async function requestProxy(payload: unknown): Promise { + return fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': 'test-proxy-token', + }, + body: JSON.stringify(payload), + }); +} + +beforeEach(async () => { + upstreamPort = await getPort(); + proxyPort = await getPort(); + upstreamBody = undefined; + await startMockUpstream(); + const profile: OpenAICompatProfileConfig = { + profileName: 'hf', + settingsPath: '/tmp/hf.settings.json', + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'hf_token', + provider: 'generic-chat-completion-api', + model: 'hf-model', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: proxyPort, + authToken: 'test-proxy-token', + }); +}); + +afterEach(async () => { + await new Promise((resolve) => proxyServer.close(() => resolve())); + await new Promise((resolve) => upstreamServer.close(() => resolve())); +}); + +describe('openai proxy messages endpoint', () => { + it('translates Anthropic requests to OpenAI upstream and streams Anthropic SSE back', async () => { + const response = await requestProxy({ + model: 'hf-model', + stream: true, + messages: [{ role: 'user', content: [{ type: 'text', text: 'Find docs' }] }], + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + }); + + const body = await response.text(); + expect(response.status).toBe(200); + expect(body).toContain('event: message_start'); + expect(body).toContain('content_block_delta'); + expect(body).toContain('tool_use'); + expect(body).toContain('message_stop'); + + const parsedUpstream = upstreamBody as { + messages?: Array<{ role: string; content: string }>; + tools?: Array<{ type: string; function: { name: string } }>; + }; + expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' }); + expect(parsedUpstream.tools?.[0]?.type).toBe('function'); + expect(parsedUpstream.tools?.[0]?.function.name).toBe('search'); + }); + + it('falls back to Anthropic JSON for non-streaming requests', async () => { + const response = await requestProxy({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }); + const body = (await response.json()) as { + type?: string; + content?: Array<{ type?: string; text?: string }>; + }; + + expect(response.status).toBe(200); + expect(body.type).toBe('message'); + expect(body.content?.[0]).toEqual({ type: 'text', text: 'Plain answer' }); + }); + + it('returns invalid_request_error for malformed JSON', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': 'test-proxy-token', + }, + body: '{bad-json', + }); + const body = (await response.json()) as { error?: { type?: string; message?: string } }; + + expect(response.status).toBe(400); + expect(body.error?.type).toBe('invalid_request_error'); + expect(body.error?.message).toContain('Invalid JSON'); + }); + + it('rejects requests without the local proxy auth token', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + const body = (await response.json()) as { error?: { type?: string } }; + + expect(response.status).toBe(401); + expect(body.error?.type).toBe('authentication_error'); + }); + + it('translates supported Anthropic image blocks into OpenAI image_url content', async () => { + const response = await requestProxy({ + model: 'hf-model', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe this' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'ZmFrZQ==', + }, + }, + ], + }, + ], + }); + + expect(response.status).toBe(200); + const parsedUpstream = upstreamBody as { + messages?: Array<{ + role: string; + content: Array<{ type: string; text?: string; image_url?: { url?: string } }>; + }>; + }; + expect(parsedUpstream.messages?.[0]).toEqual({ + role: 'user', + content: [ + { type: 'text', text: 'Describe this' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,ZmFrZQ==' }, + }, + ], + }); + }); + + it('accepts query strings and trailing slashes on the messages route', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages/?beta=test`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + authorization: 'Bearer test-proxy-token', + }, + body: JSON.stringify({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + expect(response.status).toBe(200); + }); +}); diff --git a/tests/integration/proxy/request-routing.test.ts b/tests/integration/proxy/request-routing.test.ts new file mode 100644 index 00000000..d5a8ce0d --- /dev/null +++ b/tests/integration/proxy/request-routing.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import getPort from 'get-port'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server'; +import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +let originalCcsHome: string | undefined; +let tempDir: string; +let proxyServer: http.Server; +let upstreamServers: http.Server[] = []; +let proxyPort: number; + +function startMockUpstream( + port: number, + hitLabel: string, + hits: string[], + bodies: Array<{ label: string; body: unknown }> +): Promise { + return new Promise((resolve) => { + const server = http.createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) { + body += chunk.toString(); + } + hits.push(hitLabel); + bodies.push({ label: hitLabel, body: JSON.parse(body) }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: `chatcmpl_${hitLabel}`, + model: hitLabel, + choices: [ + { + index: 0, + message: { role: 'assistant', content: `Reply from ${hitLabel}` }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 3 }, + }) + ); + }); + upstreamServers.push(server); + server.listen(port, '127.0.0.1', () => resolve()); + }); +} + +function writeSettings(profileName: string, env: Record): string { + const settingsPath = path.join(tempDir, '.ccs', `${profileName}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2), 'utf8'); + return settingsPath; +} + +async function requestProxy(payload: unknown): Promise { + return fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test-proxy-token', + }, + body: JSON.stringify(payload), + }); +} + +beforeEach(async () => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-routing-')); + fs.mkdirSync(path.join(tempDir, '.ccs'), { recursive: true }); + process.env.CCS_HOME = tempDir; + proxyPort = await getPort(); +}); + +afterEach(async () => { + await Promise.all( + upstreamServers.map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + }) + ) + ); + upstreamServers = []; + if (proxyServer) { + await new Promise((resolve) => proxyServer.close(() => resolve())); + } + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('openai proxy request routing', () => { + it('routes explicit profile:model selectors to the matching upstream profile', async () => { + const primaryPort = await getPort(); + const secondaryPort = await getPort(); + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + await startMockUpstream(primaryPort, 'primary', hits, bodies); + await startMockUpstream(secondaryPort, 'secondary', hits, bodies); + + const primarySettings = writeSettings('hf', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`, + ANTHROPIC_AUTH_TOKEN: 'hf_token', + ANTHROPIC_MODEL: 'hf-default', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + const secondarySettings = writeSettings('deepseek', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${secondaryPort}`, + ANTHROPIC_AUTH_TOKEN: 'deepseek_token', + ANTHROPIC_MODEL: 'deepseek-chat', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify({ profiles: { hf: primarySettings, deepseek: secondarySettings } }, null, 2), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'hf', + settingsPath: primarySettings, + baseUrl: `http://127.0.0.1:${primaryPort}`, + apiKey: 'hf_token', + provider: 'generic-chat-completion-api', + model: 'hf-default', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: proxyPort, + authToken: 'test-proxy-token', + }); + + const response = await requestProxy({ + model: 'deepseek:deepseek-reasoner', + messages: [{ role: 'user', content: 'hello' }], + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + content: [{ type: 'text', text: 'Reply from secondary' }], + }); + expect(hits).toEqual(['secondary']); + expect(bodies[0]?.body).toMatchObject({ model: 'deepseek-reasoner' }); + }); + + it('routes thinking requests through the configured think scenario', async () => { + const primaryPort = await getPort(); + const thinkPort = await getPort(); + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + await startMockUpstream(primaryPort, 'primary', hits, bodies); + await startMockUpstream(thinkPort, 'thinker', hits, bodies); + + const primarySettings = writeSettings('hf', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`, + ANTHROPIC_AUTH_TOKEN: 'hf_token', + ANTHROPIC_MODEL: 'hf-default', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + const thinkSettings = writeSettings('thinker', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${thinkPort}`, + ANTHROPIC_AUTH_TOKEN: 'think_token', + ANTHROPIC_MODEL: 'deepseek-reasoner', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify( + { + profiles: { hf: primarySettings, thinker: thinkSettings }, + proxy: { + routing: { + think: 'thinker:deepseek-reasoner', + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'hf', + settingsPath: primarySettings, + baseUrl: `http://127.0.0.1:${primaryPort}`, + apiKey: 'hf_token', + provider: 'generic-chat-completion-api', + model: 'hf-default', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: proxyPort, + authToken: 'test-proxy-token', + }); + + const response = await requestProxy({ + model: 'hf-default', + thinking: { type: 'enabled', budget_tokens: 9000 }, + messages: [{ role: 'user', content: 'think hard' }], + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + content: [{ type: 'text', text: 'Reply from thinker' }], + }); + expect(hits).toEqual(['thinker']); + expect(bodies[0]?.body).toMatchObject({ model: 'deepseek-reasoner' }); + }); +}); diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index 5d4d20ce..78448948 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -87,17 +87,36 @@ describe('npm CLI', () => { }); it('routes cursor probe through the cursor command handler', function() { + let output = ''; try { - execSync(`bun "${srcCcsPath}" cursor probe`, { + output = execSync(`bun "${srcCcsPath}" cursor probe`, { + encoding: 'utf8', stdio: 'pipe', timeout: 3000, env: { ...process.env, CCS_HOME: testCcsHome } }); } catch (e) { - const output = e.stderr?.toString() || e.stdout?.toString() || ''; - assert(!output.includes("Profile 'cursor' not found"), 'Should not fall through to profile lookup'); - assert(output.includes('Cursor Live Probe'), 'Should render the cursor probe command output'); + output = e.stderr?.toString() || e.stdout?.toString() || ''; } + assert(!output.includes("Profile 'cursor' not found"), 'Should not fall through to profile lookup'); + assert( + output.includes('Cursor Live Probe') || output.includes('legacy cursor probe'), + 'Should route through the legacy cursor compatibility handler' + ); + }); + + it('routes gitlab --help to provider shortcut help instead of starting auth', function() { + const output = execSync(`bun "${srcCcsPath}" gitlab --help`, { + encoding: 'utf8', + timeout: 3000, + env: { ...process.env, CCS_HOME: testCcsHome } + }); + + assert(output.includes('CCS gitlab Shortcut Help'), 'Should render provider shortcut help'); + assert(output.includes('--gitlab-token-login'), 'Should document canonical GitLab PAT flag'); + assert(output.includes('--token-login'), 'Should document legacy GitLab PAT alias'); + assert(output.includes('--gitlab-url '), 'Should document self-hosted GitLab URL flag'); + assert(!output.includes('Starting GitLab Duo OAuth'), 'Should not start OAuth when help is requested'); }); }); diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 84d57415..6c8293c4 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -93,6 +93,12 @@ describe('ProfileDetector', () => { expect(result.provider).toBe('gemini'); }); + it('should detect newly added cliproxy providers', () => { + expect(detector.detectProfileType('gitlab').provider).toBe('gitlab'); + expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy'); + expect(detector.detectProfileType('kilo').provider).toBe('kilo'); + }); + it('should detect settings-based profile from unified config', () => { const settingsPath = path.join(tempDir, 'glm.settings.json'); fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } })); @@ -196,7 +202,14 @@ describe('ProfileDetector', () => { } }); - it('should detect cursor as a first-class runtime profile when enabled', () => { + it('should detect cursor as a CLIProxy provider shortcut', () => { + const result = detector.detectProfileType('cursor'); + expect(result.type).toBe('cliproxy'); + expect(result.name).toBe('cursor'); + expect(result.provider).toBe('cursor'); + }); + + it('should detect legacy-cursor as a first-class runtime profile when enabled', () => { const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({ enabled: true, @@ -207,9 +220,9 @@ describe('ProfileDetector', () => { }); try { - const result = detector.detectProfileType('cursor'); + const result = detector.detectProfileType('legacy-cursor'); expect(result.type).toBe('cursor'); - expect(result.name).toBe('cursor'); + expect(result.name).toBe('legacy-cursor'); expect(result.cursorConfig?.auto_start).toBe(true); } finally { isUnifiedModeSpy.mockRestore(); @@ -217,7 +230,7 @@ describe('ProfileDetector', () => { } }); - it('should merge default cursor fields when enabled via partial unified config', () => { + it('should merge default legacy cursor fields when enabled via partial unified config', () => { const originalCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tempDir; const ccsDir = path.join(tempDir, '.ccs'); @@ -229,7 +242,7 @@ describe('ProfileDetector', () => { try { const localDetector = new ProfileDetector(); - const result = localDetector.detectProfileType('cursor'); + const result = localDetector.detectProfileType('legacy-cursor'); expect(result.type).toBe('cursor'); expect(result.cursorConfig).toEqual({ enabled: true, @@ -247,7 +260,7 @@ describe('ProfileDetector', () => { } }); - it('should throw a helpful error when cursor profile is disabled', () => { + it('should throw a helpful error when legacy cursor profile is disabled', () => { const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({ enabled: false, @@ -258,7 +271,9 @@ describe('ProfileDetector', () => { }); try { - expect(() => detector.detectProfileType('cursor')).toThrow(/Cursor profile is not enabled/); + expect(() => detector.detectProfileType('legacy-cursor')).toThrow( + /Legacy Cursor profile is not enabled/ + ); } finally { isUnifiedModeSpy.mockRestore(); getCursorConfigSpy.mockRestore(); diff --git a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts index 732902dd..4ad5145f 100644 --- a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts +++ b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts @@ -253,7 +253,7 @@ describe('Quota Exhaustion Handlers', () => { expect(result.reason).toContain('no alternatives'); }); - it('should switch Claude accounts when fallback quota is unavailable but auth is valid', async () => { + it('should switch Claude accounts when fallback quota endpoint returns 404', async () => { writeRegistry({ claude: { default: 'exhausted@example.com', @@ -294,16 +294,7 @@ describe('Quota Exhaustion Handlers', () => { global.fetch = mock((_url: string, options?: RequestInit) => { const authHeader = new Headers(options?.headers).get('Authorization') ?? ''; if (authHeader === 'Bearer fallback-token') { - return Promise.resolve( - new Response( - JSON.stringify({ - error: { - message: 'OAuth authentication is currently not supported.', - }, - }), - { status: 401, headers: { 'Content-Type': 'application/json' } } - ) - ); + return Promise.resolve(new Response('', { status: 404 })); } return Promise.resolve(new Response('', { status: 500 })); diff --git a/tests/unit/cliproxy/backend-selection.test.js b/tests/unit/cliproxy/backend-selection.test.js index bcccb347..ff2ead96 100644 --- a/tests/unit/cliproxy/backend-selection.test.js +++ b/tests/unit/cliproxy/backend-selection.test.js @@ -121,6 +121,13 @@ describe('Backend Selection', () => { assert(types.PLUS_ONLY_PROVIDERS.includes('ghcp')); }); + it('includes the newer CLIProxyAPIPlus providers as plus-only', () => { + assert(types.PLUS_ONLY_PROVIDERS.includes('cursor')); + assert(types.PLUS_ONLY_PROVIDERS.includes('gitlab')); + assert(types.PLUS_ONLY_PROVIDERS.includes('codebuddy')); + assert(types.PLUS_ONLY_PROVIDERS.includes('kilo')); + }); + it('does not include gemini as plus-only provider', () => { assert(!types.PLUS_ONLY_PROVIDERS.includes('gemini')); }); diff --git a/tests/unit/cliproxy/base-config-loader.test.ts b/tests/unit/cliproxy/base-config-loader.test.ts new file mode 100644 index 00000000..528d9106 --- /dev/null +++ b/tests/unit/cliproxy/base-config-loader.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'bun:test'; + +import { loadBaseConfig } from '../../../src/cliproxy/base-config-loader'; + +describe('base-config-loader new providers', () => { + it.each([ + ['cursor', '/api/provider/cursor', 'composer-2'], + ['gitlab', '/api/provider/gitlab', 'gitlab-duo'], + ['codebuddy', '/api/provider/codebuddy', 'auto'], + ['kilo', '/api/provider/kilo', 'kilo/auto'], + ] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => { + const config = loadBaseConfig(provider); + + expect(config.env.ANTHROPIC_BASE_URL).toContain(baseUrlPath); + expect(config.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); + expect(config.env.ANTHROPIC_MODEL).toBe(defaultModel); + expect(config.env.ANTHROPIC_DEFAULT_OPUS_MODEL.length).toBeGreaterThan(0); + expect(config.env.ANTHROPIC_DEFAULT_SONNET_MODEL.length).toBeGreaterThan(0); + expect(config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index 533f38bc..92140f1d 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -1,4 +1,28 @@ -import { describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let originalCcsHome: string | undefined; +let tempHome = ''; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-binary-manager-')); + process.env.CCS_HOME = tempHome; +}); + +afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } +}); describe('installCliproxyVersion', () => { it('attempts to stop the proxy even when there is no tracked running session', async () => { @@ -42,4 +66,19 @@ describe('installCliproxyVersion', () => { expect(calls.deleteBinary).toBe(0); expect(calls.ensureBinary).toBe(1); }); + + it('fails fast when runtime startup forbids installing a missing binary', async () => { + const binaryManager = await import( + `../../../src/cliproxy/binary-manager?binary-manager-runtime=${Date.now()}` + ); + + await expect( + binaryManager.ensureCLIProxyBinary(false, { + allowInstall: false, + skipAutoUpdate: true, + }) + ).rejects.toThrow( + 'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + }); }); diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index 4cdca89f..1f004389 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -9,7 +9,7 @@ afterEach(() => { mock.restore(); }); -function createCodexSettingsFixture(haikuModel: string = 'gpt-5-codex-mini'): { +function createCodexSettingsFixture(haikuModel: string = 'gpt-5.4-mini'): { tmpDir: string; settingsPath: string; } { @@ -80,7 +80,7 @@ describe('codex plan compatibility reconcile', () => { expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.3-codex-spark'); expect(errorSpy).toHaveBeenCalledWith( - 'Codex free plan detected. Keeping saved model "gpt-5.3-codex" in settings; runtime requests will fall back to "gpt-5-codex" when needed.' + 'Codex free plan detected. Keeping saved model "gpt-5.3-codex" in settings; runtime requests will fall back to "gpt-5.4" when needed.' ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -115,7 +115,7 @@ describe('codex plan compatibility reconcile', () => { }; expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(errorSpy).toHaveBeenCalledWith( - 'Configured Codex model "gpt-5.3-codex" may require a paid Codex plan. If startup fails, switch to "gpt-5-codex" with "ccs codex --config".' + 'Configured Codex model "gpt-5.3-codex" may require a paid Codex plan. If startup fails, switch to "gpt-5.4" with "ccs codex --config".' ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -195,7 +195,7 @@ describe('codex plan compatibility reconcile', () => { }; expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(errorSpy).toHaveBeenCalledWith( - 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5-codex" via "ccs codex --config".' + 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5.4" via "ccs codex --config".' ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -234,9 +234,9 @@ describe('codex plan compatibility reconcile', () => { env: Record; }; expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); expect(errorSpy).toHaveBeenCalledWith( - 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5-codex" via "ccs codex --config".' + 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5.4" via "ccs codex --config".' ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts index 23bb7bc9..2fc1266a 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -9,22 +9,21 @@ import { describe('codex plan compatibility', () => { it('uses a cross-plan safe Codex default', () => { - expect(getDefaultCodexModel()).toBe('gpt-5-codex'); - expect(getProviderCatalog('codex')?.defaultModel).toBe('gpt-5-codex'); + expect(getDefaultCodexModel()).toBe('gpt-5.4'); + expect(getProviderCatalog('codex')?.defaultModel).toBe('gpt-5.4'); }); it('maps paid-only free-plan models to safe fallbacks', () => { - expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5-codex'); - expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5-codex'); - expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5-codex'); - expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBe('gpt-5-codex'); - expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-spark')).toBe('gpt-5-codex-mini'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5.4'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5.4'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5.4'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-spark')).toBe('gpt-5.4-mini'); }); it('does not rewrite cross-plan or already-safe Codex models', () => { - expect(getFreePlanFallbackCodexModel('gpt-5-codex')).toBeNull(); - expect(getFreePlanFallbackCodexModel('gpt-5.2-codex')).toBeNull(); - expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.4-mini')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.2')).toBeNull(); }); it('detects upstream Codex model_not_supported responses', () => { @@ -54,25 +53,27 @@ describe('codex plan compatibility', () => { it('resolves runtime fallbacks without retrying the rejected model again', () => { expect( resolveRuntimeCodexFallbackModel({ - requestedModel: 'gpt-5.4', - modelMap: { defaultModel: 'gpt-5-codex' }, + requestedModel: 'gpt-5.3-codex', + modelMap: { defaultModel: 'gpt-5.4' }, }) - ).toBe('gpt-5-codex'); + ).toBe('gpt-5.4'); expect( resolveRuntimeCodexFallbackModel({ - requestedModel: 'gpt-5.4', + requestedModel: 'gpt-5.3-codex', modelMap: { defaultModel: 'gpt-5.4', - haikuModel: 'gpt-5-codex-mini', + haikuModel: 'gpt-5.4-mini', }, - excludeModels: ['gpt-5-codex'], + excludeModels: ['gpt-5.4'], }) - ).toBe('gpt-5-codex-mini'); + ).toBe('gpt-5.4-mini'); }); - it('tracks Codex thinking caps for current safe defaults and paid models', () => { - expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high'); + it('tracks Codex thinking caps for current safe defaults, paid models, and legacy aliases', () => { + expect(getModelMaxLevel('codex', 'gpt-5.4')).toBe('xhigh'); + expect(getModelMaxLevel('codex', 'gpt-5.4-mini')).toBe('high'); + expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high'); expect(getModelMaxLevel('codex', 'gpt-5.2-codex')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.3-codex')).toBe('xhigh'); diff --git a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts index 5a6dc3a2..569b9dc3 100644 --- a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts +++ b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts @@ -306,11 +306,11 @@ describe('CodexReasoningProxy extended-context compatibility', () => { expect(firstResponse.statusCode).toBe(200); expect(secondResponse.statusCode).toBe(200); - expect(firstResponse.body.model).toBe('gpt-5-codex'); + expect(firstResponse.body.model).toBe('gpt-5.4-mini'); expect(firstResponse.body.effort).toBe('high'); - expect(secondResponse.body.model).toBe('gpt-5-codex'); + expect(secondResponse.body.model).toBe('gpt-5.4-mini'); expect(secondResponse.body.effort).toBe('high'); - expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5-codex', 'gpt-5-codex']); + expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-mini']); expect(capturedEfforts).toEqual(['xhigh', 'high', 'high']); }); diff --git a/tests/unit/cliproxy/composite-env-routing.test.ts b/tests/unit/cliproxy/composite-env-routing.test.ts index 0ef51fc7..3bd86e6c 100644 --- a/tests/unit/cliproxy/composite-env-routing.test.ts +++ b/tests/unit/cliproxy/composite-env-routing.test.ts @@ -8,7 +8,7 @@ import { buildClaudeEnvironment } from '../../../src/cliproxy/executor/env-resol const tiers = { opus: { provider: 'agy' as const, model: 'claude-opus-4-6-thinking' }, sonnet: { provider: 'gemini' as const, model: 'gemini-2.5-pro' }, - haiku: { provider: 'codex' as const, model: 'gpt-5.1-codex-mini' }, + haiku: { provider: 'codex' as const, model: 'gpt-5.4-mini' }, }; describe('buildClaudeEnvironment - composite remote routing', () => { @@ -82,7 +82,7 @@ describe('buildClaudeEnvironment - composite remote routing', () => { compositeTiers: { opus: { provider: 'agy', model: 'claude-opus-4.6-thinking' }, sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' }, - haiku: { provider: 'codex', model: 'gpt-5.1-codex-mini' }, + haiku: { provider: 'codex', model: 'gpt-5.4-mini' }, }, compositeDefaultTier: 'opus', }); @@ -90,6 +90,6 @@ describe('buildClaudeEnvironment - composite remote routing', () => { expect(env.ANTHROPIC_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toMatch(/^gemini-2.5-pro(\([^)]+\))?$/); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toMatch(/^gpt-5.1-codex-mini(?:-medium)?$/); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toMatch(/^gpt-5.4-mini(?:-medium)?$/); }); }); diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 8b589ca5..7f96cde9 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -47,7 +47,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', }); const env = getEffectiveEnvVars('codex', 8317, settingsPath); @@ -55,7 +55,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; @@ -63,7 +63,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); }); it('rewrites wrong local provider path to the requested provider', () => { @@ -73,7 +73,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', }); const env = getEffectiveEnvVars('codex', 8317, settingsPath); @@ -318,6 +318,65 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeDefined(); }); + it('imports legacy cursor settings into the dedicated provider path without reusing legacy transport auth', () => { + process.env.CCS_HOME = tempHome; + const legacySettingsPath = path.join(tempHome, '.ccs', 'cursor.settings.json'); + const providerSettingsPath = path.join( + tempHome, + '.ccs', + 'cliproxy', + 'providers', + 'cursor.settings.json' + ); + fs.mkdirSync(path.dirname(legacySettingsPath), { recursive: true }); + fs.writeFileSync( + legacySettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:20129', + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + ANTHROPIC_API_KEY: 'legacy-cursor-api-key', + ANTHROPIC_MODEL: 'claude-4-sonnet', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-4-opus', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-4-sonnet', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'cursor-small', + ANTHROPIC_SMALL_FAST_MODEL: 'cursor-small', + DISABLE_TELEMETRY: '1', + }, + hooks: { + PreToolUse: [{ matcher: 'Read', hooks: [] }], + }, + }, + null, + 2 + ) + ); + + const env = getEffectiveEnvVars('cursor'); + + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/cursor'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); + expect(env.ANTHROPIC_MODEL).toBe('claude-4-sonnet'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-4-opus'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('cursor-small'); + expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('cursor-small'); + expect(env.DISABLE_TELEMETRY).toBe('1'); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + + const migrated = JSON.parse(fs.readFileSync(providerSettingsPath, 'utf-8')) as { + env: Record; + hooks?: Record; + }; + expect(migrated.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/cursor'); + expect(migrated.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); + expect(migrated.env.ANTHROPIC_MODEL).toBe('claude-4-sonnet'); + expect(migrated.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-4-opus'); + expect(migrated.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('cursor-small'); + expect(migrated.env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(migrated.hooks?.PreToolUse).toBeDefined(); + }); + it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => { process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); @@ -379,7 +438,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { ANTHROPIC_MODEL: ' gpt-5.3-codex-xhigh ', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', }, presets: [ { @@ -387,7 +446,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { default: 'gpt-5.3-codex-xhigh', opus: 'gpt-5.3-codex-xhigh', sonnet: 'gpt-5.3-codex-high', - haiku: 'gpt-5-mini-medium', + haiku: 'gpt-5.4-mini-medium', }, ], }, @@ -405,11 +464,11 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex'); expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex'); expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.haiku).toBe('gpt-5-mini'); + expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini'); }); it('recovers malformed provider settings files by writing defaults and backup copy', () => { diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 36036cfd..39ee4e6d 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -87,7 +87,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { defaultModel: 'gpt-5.3-codex-high', opusModel: 'gpt-5.3-codex-xhigh', sonnetModel: 'gpt-5.3-codex-high', - haikuModel: 'gpt-5-mini-medium', + haikuModel: 'gpt-5.4-mini-medium', }); const env = buildClaudeEnvironment({ @@ -101,7 +101,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex(high)'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex(xhigh)'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex(high)'); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini(medium)'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini(medium)'); }); it('keeps codex effort aliases when reasoning proxy is active', () => { @@ -109,7 +109,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { defaultModel: 'gpt-5.3-codex-high', opusModel: 'gpt-5.3-codex-xhigh', sonnetModel: 'gpt-5.3-codex-high', - haikuModel: 'gpt-5-mini-medium', + haikuModel: 'gpt-5.4-mini-medium', }); const env = buildClaudeEnvironment({ @@ -124,7 +124,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-high'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex'); }); diff --git a/tests/unit/cliproxy/executor-option-value.test.ts b/tests/unit/cliproxy/executor-option-value.test.ts index 1f4971f0..b6ff278e 100644 --- a/tests/unit/cliproxy/executor-option-value.test.ts +++ b/tests/unit/cliproxy/executor-option-value.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import { readOptionValue } from '../../../src/cliproxy/executor/index'; +import { + hasGitLabTokenLoginFlag, + readOptionValue, +} from '../../../src/cliproxy/executor/index'; describe('readOptionValue', () => { it('parses split-token option values', () => { @@ -30,4 +33,10 @@ describe('readOptionValue', () => { missingValue: true, }); }); + + it('treats both GitLab token-login flags as enabled', () => { + expect(hasGitLabTokenLoginFlag(['--gitlab-token-login'])).toBe(true); + expect(hasGitLabTokenLoginFlag(['--token-login'])).toBe(true); + expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false); + }); }); diff --git a/tests/unit/cliproxy/gemini-refresh-delegation.test.ts b/tests/unit/cliproxy/gemini-refresh-delegation.test.ts new file mode 100644 index 00000000..7a89a416 --- /dev/null +++ b/tests/unit/cliproxy/gemini-refresh-delegation.test.ts @@ -0,0 +1,72 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +describe('Gemini refresh delegation', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + let moduleVersion = 0; + + beforeEach(() => { + moduleVersion += 1; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-delegation-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + + 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; + } + }); + + it('treats Gemini as runtime-managed even when the local token lacks OAuth client metadata', async () => { + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?gemini-delegation-config=${moduleVersion}` + ); + const { ensureTokenValid } = await import( + `../../../src/cliproxy/auth/token-manager?gemini-delegation-manager=${moduleVersion}` + ); + + const authDir = getProviderAuthDir('gemini'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'gemini-delegated.json'), + JSON.stringify( + { + type: 'gemini', + email: 'delegated@example.com', + project_id: 'delegated-project', + token: { + access_token: 'expired-access-token', + refresh_token: 'still-present-refresh-token', + expiry: Date.now() - 60_000, + }, + }, + null, + 2 + ) + ); + + const result = await ensureTokenValid('gemini'); + + expect(result).toEqual({ + valid: true, + refreshed: false, + }); + }); +}); diff --git a/tests/unit/cliproxy/gitlab-pat-response.test.ts b/tests/unit/cliproxy/gitlab-pat-response.test.ts new file mode 100644 index 00000000..029a276b --- /dev/null +++ b/tests/unit/cliproxy/gitlab-pat-response.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test'; +import { parseGitLabPatAuthResponse } from '../../../src/cliproxy/auth/gitlab-pat-response'; + +describe('parseGitLabPatAuthResponse', () => { + it('sanitizes HTML error bodies for non-ok responses', () => { + const result = parseGitLabPatAuthResponse( + false, + 502, + 'gateway error', + 'glpat-secret-token' + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorMessage).toBe('[HTML error response omitted]'); + } + }); + + it('sanitizes reflected PAT tokens from structured error payloads', () => { + const result = parseGitLabPatAuthResponse( + false, + 400, + JSON.stringify({ status: 'error', error: 'Rejected token glpat-secret-token for login' }), + 'glpat-secret-token' + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorMessage).toContain('[redacted]'); + expect(result.errorMessage).not.toContain('glpat-secret-token'); + } + }); + + it('rejects ok responses whose body is not valid success JSON', () => { + const result = parseGitLabPatAuthResponse( + true, + 200, + 'upstream temporarily unavailable', + 'glpat-secret-token' + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorMessage).toContain('upstream temporarily unavailable'); + } + }); + + it('accepts explicit success payloads', () => { + const result = parseGitLabPatAuthResponse( + true, + 200, + JSON.stringify({ status: 'ok', saved_path: '/tmp/gitlab.json' }), + 'glpat-secret-token' + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.payload.status).toBe('ok'); + } + }); +}); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 17068475..3e8f3751 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -186,6 +186,25 @@ describe('Model Catalog', () => { }); }); + describe('Codex models', () => { + it('has correct default model', () => { + const { MODEL_CATALOG } = modelCatalog; + assert.strictEqual(MODEL_CATALOG.codex.defaultModel, 'gpt-5.4'); + }); + + it('advertises the current official Codex model set', () => { + const { MODEL_CATALOG } = modelCatalog; + const ids = MODEL_CATALOG.codex.models.map((m) => m.id); + assert.deepStrictEqual(ids, [ + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex', + 'gpt-5.3-codex-spark', + 'gpt-5.2', + ]); + }); + }); + describe('supportsModelConfig', () => { it('returns true for agy', () => { const { supportsModelConfig } = modelCatalog; diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index 6d4cc361..d7e969cf 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -8,6 +8,7 @@ import { migrateDeniedAntigravityModelAliases, normalizeClaudeDottedMajorMinor, normalizeClaudeDottedThinkingMajorMinor, + normalizeCodexLegacyModelAliases, normalizeModelIdForProvider, normalizeModelIdForRouting, normalizeModelEnvVarsForProvider, @@ -115,6 +116,14 @@ describe('model-id-normalizer', () => { expect(canonicalizeModelIdForProvider('minimax-m2.5', 'iflow')).toBe('qwen3-coder-plus'); expect(canonicalizeModelIdForProvider('kimi-k2.5', 'gemini')).toBe('kimi-k2.5'); }); + + it('normalizes legacy codex aliases to the current supported model IDs', () => { + expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4'); + expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]'); + expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2'); + expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini'); + expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4'); + }); }); describe('env normalization', () => { diff --git a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts index 287b95f8..91b21a20 100644 --- a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts +++ b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts @@ -1,8 +1,9 @@ -import { afterEach, describe, expect, it } from 'bun:test'; +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver'; +import { InteractivePrompt } from '../../../src/utils/prompt'; import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; const remoteTarget: ProxyTarget = { @@ -233,3 +234,65 @@ describe('getCliAuthNicknameError', () => { expect(getCliAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull(); }); }); + +describe('promptGitLabPersonalAccessToken', () => { + it('uses the masked password prompt and trims the token', async () => { + const passwordSpy = spyOn(InteractivePrompt, 'password').mockImplementation( + mock(async () => ' glpat-secret-token ') + ); + + const { promptGitLabPersonalAccessToken } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-pat-prompt=${Date.now()}` + ); + + await expect(promptGitLabPersonalAccessToken()).resolves.toBe('glpat-secret-token'); + expect(passwordSpy).toHaveBeenCalledWith('GitLab Personal Access Token'); + }); + + it('returns null when the masked prompt is left blank', async () => { + const passwordSpy = spyOn(InteractivePrompt, 'password').mockImplementation( + mock(async () => ' ') + ); + + const { promptGitLabPersonalAccessToken } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-pat-prompt-blank=${Date.now()}` + ); + + await expect(promptGitLabPersonalAccessToken()).resolves.toBeNull(); + expect(passwordSpy).toHaveBeenCalledWith('GitLab Personal Access Token'); + }); +}); + +describe('normalizeGitLabBaseUrl', () => { + it('returns undefined for blank values', async () => { + const { normalizeGitLabBaseUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-url-empty=${Date.now()}` + ); + + expect(normalizeGitLabBaseUrl(undefined)).toBeUndefined(); + expect(normalizeGitLabBaseUrl(' ')).toBeUndefined(); + }); + + it('normalizes whitespace and trailing slashes for self-hosted URLs', async () => { + const { normalizeGitLabBaseUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-url-normalize=${Date.now()}` + ); + + expect(normalizeGitLabBaseUrl(' https://gitlab.example.com/custom/ ')).toBe( + 'https://gitlab.example.com/custom' + ); + }); + + it('rejects malformed or scheme-less URLs before hitting CLIProxy', async () => { + const { normalizeGitLabBaseUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-url-invalid=${Date.now()}` + ); + + expect(() => normalizeGitLabBaseUrl('gitlab.example.com')).toThrow( + 'GitLab URL must be a valid http:// or https:// URL' + ); + expect(() => normalizeGitLabBaseUrl('ftp://gitlab.example.com')).toThrow( + 'GitLab URL must use http:// or https://' + ); + }); +}); diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index d6f1d565..7c462b6d 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -41,24 +41,38 @@ describe('provider-capabilities', () => { 'ghcp', 'claude', 'kimi', + 'cursor', + 'gitlab', + 'codebuddy', + 'kilo', ]); }); it('validates provider IDs', () => { expect(isCLIProxyProvider('gemini')).toBe(true); expect(isCLIProxyProvider('ghcp')).toBe(true); + expect(isCLIProxyProvider('gitlab')).toBe(true); expect(isCLIProxyProvider('not-a-provider')).toBe(false); expect(isCLIProxyProvider('Gemini')).toBe(false); }); it('returns providers by OAuth flow capability', () => { - expect(getProvidersByOAuthFlow('device_code')).toEqual(['qwen', 'kiro', 'ghcp', 'kimi']); + expect(getProvidersByOAuthFlow('device_code')).toEqual([ + 'qwen', + 'kiro', + 'ghcp', + 'kimi', + 'cursor', + 'codebuddy', + 'kilo', + ]); expect(getProvidersByOAuthFlow('authorization_code')).toEqual([ 'gemini', 'codex', 'agy', 'iflow', 'claude', + 'gitlab', ]); }); @@ -69,6 +83,8 @@ describe('provider-capabilities', () => { expect(mapExternalProviderName('github-copilot')).toBe('ghcp'); expect(mapExternalProviderName('copilot')).toBe('ghcp'); expect(mapExternalProviderName('anthropic')).toBe('claude'); + expect(mapExternalProviderName('gitlab-duo')).toBe('gitlab'); + expect(mapExternalProviderName('tencent')).toBe('codebuddy'); expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp'); expect(mapExternalProviderName('')).toBeNull(); expect(mapExternalProviderName('unknown-provider')).toBeNull(); @@ -99,8 +115,12 @@ describe('provider-capabilities', () => { it('exposes callback port and display name capabilities', () => { expect(getOAuthCallbackPort('qwen')).toBeNull(); expect(getOAuthCallbackPort('kiro')).toBeNull(); + expect(getOAuthCallbackPort('cursor')).toBeNull(); + expect(getOAuthCallbackPort('gitlab')).toBe(17171); expect(getOAuthCallbackPort('gemini')).toBe(8085); + expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy'); expect(getProviderDisplayName('agy')).toBe('Antigravity'); + expect(getProviderDisplayName('kilo')).toBe('Kilo AI'); }); it('throws when provider aliases collide across providers', () => { diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts index 25455a5c..c1bcc4ae 100644 --- a/tests/unit/cliproxy/quota-fetcher-claude.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -1,7 +1,7 @@ /** * Claude Quota Fetcher Unit Tests * - * Covers policy limits parsing and auth/token edge cases. + * Covers Claude quota parsing and auth/token edge cases. */ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; @@ -157,6 +157,45 @@ describe('Claude Quota Fetcher', () => { expect(windows[0].rateLimitType).toBe('five_hour'); expect(windows[0].remainingPercent).toBe(40); }); + + it('parses OAuth usage payload keyed by window name', () => { + const windows = buildClaudeQuotaWindows({ + five_hour: { + utilization: 39, + resets_at: '2026-02-28T10:00:00Z', + }, + seven_day_sonnet: { + utilization: 9, + resets_at: '2026-03-06T10:00:00Z', + }, + extra_usage: { + is_enabled: true, + monthly_limit: 5000, + used_credits: 1200, + utilization: 0.24, + }, + }); + + expect(windows).toHaveLength(2); + expect(windows[0].rateLimitType).toBe('five_hour'); + expect(windows[0].remainingPercent).toBe(61); + expect(windows[1].rateLimitType).toBe('seven_day_sonnet'); + expect(windows[1].remainingPercent).toBe(91); + }); + + it('parses future OAuth usage windows without hardcoded keys', () => { + const windows = buildClaudeQuotaWindows({ + seven_day_haiku: { + utilization: 16, + resets_at: '2026-03-06T10:00:00Z', + }, + }); + + expect(windows).toHaveLength(1); + expect(windows[0].rateLimitType).toBe('seven_day_haiku'); + expect(windows[0].label).toBe('Seven Day Haiku'); + expect(windows[0].remainingPercent).toBe(84); + }); }); describe('buildClaudeCoreUsageSummary', () => { @@ -285,7 +324,7 @@ describe('Claude Quota Fetcher', () => { }); describe('fetchClaudeQuota', () => { - it('fetches and normalizes policy limits response', async () => { + it('fetches and normalizes Claude OAuth usage response', async () => { createClaudeAccount('claude-main@example.com', { access_token: 'claude-token', expired: '2099-01-01T00:00:00.000Z', @@ -293,30 +332,26 @@ describe('Claude Quota Fetcher', () => { }); global.fetch = mock((url: string, options?: RequestInit) => { - expect(url).toBe('https://api.anthropic.com/api/claude_code/policy_limits'); + expect(url).toBe('https://api.anthropic.com/api/oauth/usage'); expect(options?.method).toBe('GET'); expect(options?.headers).toMatchObject({ Authorization: 'Bearer claude-token', Accept: 'application/json', + 'Content-Type': 'application/json', + 'anthropic-beta': 'oauth-2025-04-20', }); return Promise.resolve( new Response( JSON.stringify({ - restrictions: [ - { - rateLimitType: 'five_hour', - utilization: 0.5, - resetsAt: '2026-03-01T01:00:00Z', - status: 'allowed', - }, - { - rateLimitType: 'seven_day', - utilization: 0.75, - resetsAt: '2026-03-07T01:00:00Z', - status: 'allowed_warning', - }, - ], + five_hour: { + utilization: 39, + resets_at: '2026-03-01T01:00:00Z', + }, + seven_day: { + utilization: 75, + resets_at: '2026-03-07T01:00:00Z', + }, }), { status: 200, @@ -331,7 +366,7 @@ describe('Claude Quota Fetcher', () => { expect(result.success).toBe(true); expect(result.accountId).toBe('claude-main@example.com'); expect(result.windows).toHaveLength(2); - expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(50); + expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(61); expect(result.coreUsage?.weekly?.remainingPercent).toBe(25); const all = await fetchAllClaudeQuotas(); @@ -340,7 +375,7 @@ describe('Claude Quota Fetcher', () => { expect(all[0].quota.success).toBe(true); }); - it('returns needsReauth on 401 responses', async () => { + it('returns needsReauth on empty 401 OAuth usage responses', async () => { createClaudeAccount( 'claude-auth@example.com', { @@ -360,9 +395,9 @@ describe('Claude Quota Fetcher', () => { expect(result.error).toContain('Authentication'); }); - it('treats OAuth-unsupported 401 as policy-limits unavailable', async () => { + it('surfaces nested OAuth usage 401 messages', async () => { createClaudeAccount( - 'claude-oauth-unsupported@example.com', + 'claude-oauth-nested-message@example.com', { access_token: 'oauth-token', expired: '2099-01-01T00:00:00.000Z', @@ -378,7 +413,7 @@ describe('Claude Quota Fetcher', () => { type: 'error', error: { type: 'authentication_error', - message: 'OAuth authentication is currently not supported.', + message: 'OAuth session expired.', }, }), { status: 401, headers: { 'Content-Type': 'application/json' } } @@ -386,16 +421,14 @@ describe('Claude Quota Fetcher', () => { ) ) as typeof fetch; - const result = await fetchClaudeQuota('claude-oauth-unsupported@example.com'); + const result = await fetchClaudeQuota('claude-oauth-nested-message@example.com'); - expect(result.success).toBe(true); - expect(result.needsReauth).toBeUndefined(); - expect(result.windows).toHaveLength(0); - expect(result.coreUsage?.fiveHour).toBeNull(); - expect(result.coreUsage?.weekly).toBeNull(); + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(result.error).toContain('OAuth session expired.'); }); - it('treats root-level OAuth-unsupported 401 message as policy-limits unavailable', async () => { + it('surfaces root-level OAuth usage 401 messages', async () => { createClaudeAccount('claude-oauth-root-message@example.com', { access_token: 'oauth-token', expired: '2099-01-01T00:00:00.000Z', @@ -406,7 +439,7 @@ describe('Claude Quota Fetcher', () => { Promise.resolve( new Response( JSON.stringify({ - message: 'OAuth authentication is currently not supported.', + message: 'OAuth session expired.', }), { status: 401, headers: { 'Content-Type': 'application/json' } } ) @@ -415,14 +448,12 @@ describe('Claude Quota Fetcher', () => { const result = await fetchClaudeQuota('claude-oauth-root-message@example.com'); - expect(result.success).toBe(true); - expect(result.needsReauth).toBeUndefined(); - expect(result.windows).toHaveLength(0); - expect(result.coreUsage?.fiveHour).toBeNull(); - expect(result.coreUsage?.weekly).toBeNull(); + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(result.error).toContain('OAuth session expired.'); }); - it('treats plain-text OAuth-unsupported 401 as policy-limits unavailable', async () => { + it('surfaces plain-text OAuth usage 401 messages', async () => { createClaudeAccount('claude-oauth-plaintext@example.com', { access_token: 'oauth-token', expired: '2099-01-01T00:00:00.000Z', @@ -430,18 +461,14 @@ describe('Claude Quota Fetcher', () => { }); global.fetch = mock(() => - Promise.resolve( - new Response('OAuth authentication is currently not supported.', { status: 401 }) - ) + Promise.resolve(new Response('OAuth session expired.', { status: 401 })) ) as typeof fetch; const result = await fetchClaudeQuota('claude-oauth-plaintext@example.com'); - expect(result.success).toBe(true); - expect(result.needsReauth).toBeUndefined(); - expect(result.windows).toHaveLength(0); - expect(result.coreUsage?.fiveHour).toBeNull(); - expect(result.coreUsage?.weekly).toBeNull(); + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(result.error).toContain('OAuth session expired.'); }); it('keeps non-matching 401 payloads in the reauth path', async () => { @@ -469,11 +496,11 @@ describe('Claude Quota Fetcher', () => { expect(result.success).toBe(false); expect(result.needsReauth).toBe(true); - expect(result.error).toContain('Authentication'); + expect(result.error).toContain('Token revoked.'); }); - it('treats 404 policy limits responses as unavailable but successful', async () => { - createClaudeAccount('claude-policy-limits-404@example.com', { + it('treats 404 OAuth usage responses as failures', async () => { + createClaudeAccount('claude-usage-404@example.com', { access_token: 'oauth-token', expired: '2099-01-01T00:00:00.000Z', type: 'claude', @@ -481,13 +508,10 @@ describe('Claude Quota Fetcher', () => { global.fetch = mock(() => Promise.resolve(new Response('', { status: 404 }))) as typeof fetch; - const result = await fetchClaudeQuota('claude-policy-limits-404@example.com'); + const result = await fetchClaudeQuota('claude-usage-404@example.com'); - expect(result.success).toBe(true); - expect(result.needsReauth).toBeUndefined(); - expect(result.windows).toHaveLength(0); - expect(result.coreUsage?.fiveHour).toBeNull(); - expect(result.coreUsage?.weekly).toBeNull(); + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); }); it('fails fast when auth file has no token', async () => { @@ -515,7 +539,7 @@ describe('Claude Quota Fetcher', () => { global.fetch = mock(() => Promise.resolve( - new Response(JSON.stringify({ restrictions: [] }), { + new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' }, }) @@ -545,14 +569,10 @@ describe('Claude Quota Fetcher', () => { return Promise.resolve( new Response( JSON.stringify({ - restrictions: [ - { - rateLimitType: 'five_hour', - utilization: 0.4, - resetsAt: '2026-03-01T01:00:00Z', - status: 'allowed', - }, - ], + five_hour: { + utilization: 40, + resets_at: '2026-03-01T01:00:00Z', + }, }), { status: 200, headers: { 'Content-Type': 'application/json' } } ) @@ -584,14 +604,10 @@ describe('Claude Quota Fetcher', () => { return Promise.resolve( new Response( JSON.stringify({ - restrictions: [ - { - rateLimitType: 'seven_day', - utilization: 0.3, - resetsAt: '2026-03-07T01:00:00Z', - status: 'allowed', - }, - ], + seven_day: { + utilization: 30, + resets_at: '2026-03-07T01:00:00Z', + }, }), { status: 200, headers: { 'Content-Type': 'application/json' } } ) @@ -654,7 +670,7 @@ describe('Claude Quota Fetcher', () => { Authorization: 'Bearer valid-anthropic-token', }); return Promise.resolve( - new Response(JSON.stringify({ restrictions: [] }), { + new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' }, }) diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 86271f88..63c4672b 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -13,18 +13,16 @@ import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; describe('Gemini CLI Quota Fetcher', () => { const GEMINI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; const GEMINI_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; - const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + const MANAGEMENT_AUTH_FILES_URL = 'http://127.0.0.1:8317/v0/management/auth-files'; + const MANAGEMENT_API_CALL_URL = 'http://127.0.0.1:8317/v0/management/api-call'; let tempHome: string; let originalCcsHome: string | undefined; let originalCcsDir: string | undefined; - let originalGeminiClientId: string | undefined; - let originalGeminiClientSecret: string | undefined; let moduleVersion = 0; let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets; let fetchGeminiCliQuota: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').fetchGeminiCliQuota; let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId; let geminiTestExports: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').__testExports; - let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; function writeGeminiToken(token: Record, filename = 'gemini-test.json'): string { @@ -47,9 +45,6 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'access-token', refresh_token: 'refresh-token', expiry: Date.now() + 60 * 60 * 1000, - client_id: 'test-client-id', - client_secret: 'test-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, ...overrides, }); @@ -60,12 +55,7 @@ describe('Gemini CLI Quota Fetcher', () => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-')); originalCcsHome = process.env.CCS_HOME; originalCcsDir = process.env.CCS_DIR; - originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID; - originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; process.env.CCS_HOME = tempHome; - - delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; - delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; delete process.env.CCS_DIR; const configGenerator = await import( @@ -79,9 +69,6 @@ describe('Gemini CLI Quota Fetcher', () => { } = await import( `../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}` )); - ({ refreshGeminiToken } = await import( - `../../../src/cliproxy/auth/gemini-token-refresh?gemini-refresh=${moduleVersion}` - )); ({ getProviderAuthDir } = configGenerator); }); @@ -100,18 +87,6 @@ describe('Gemini CLI Quota Fetcher', () => { } else { process.env.CCS_DIR = originalCcsDir; } - - if (originalGeminiClientId === undefined) { - delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; - } else { - process.env.CCS_GEMINI_OAUTH_CLIENT_ID = originalGeminiClientId; - } - - if (originalGeminiClientSecret === undefined) { - delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; - } else { - process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = originalGeminiClientSecret; - } }); describe('resolveGeminiCliProjectId', () => { @@ -509,6 +484,12 @@ describe('Gemini CLI Quota Fetcher', () => { writeActiveGeminiAccount('reauth@example.com'); mockFetch([ + { + url: MANAGEMENT_AUTH_FILES_URL, + response: { + files: [], + }, + }, { url: GEMINI_QUOTA_URL, method: 'POST', @@ -520,14 +501,6 @@ describe('Gemini CLI Quota Fetcher', () => { }, }, }, - { - url: GOOGLE_TOKEN_URL, - method: 'POST', - status: 400, - response: { - error: 'invalid_grant', - }, - }, ]); const result = await fetchGeminiCliQuota('reauth@example.com'); @@ -667,7 +640,7 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.errorDetail).toBe('[HTML error response omitted]'); }); - it('refreshes the requested Gemini account instead of the default account', async () => { + it('uses the requested Gemini account when delegating auth recovery to CLIProxy management', async () => { writeGeminiToken( { type: 'gemini', @@ -677,9 +650,6 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'default-access-token', refresh_token: 'default-refresh-token', expiry: Date.now() + 60 * 60 * 1000, - client_id: 'default-client-id', - client_secret: 'default-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, }, 'gemini-default.json' @@ -694,9 +664,6 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'target-stale-token', refresh_token: 'target-refresh-token', expiry: Date.now() - 1000, - client_id: 'target-client-id', - client_secret: 'target-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, }, 'gemini-target.json' @@ -704,16 +671,37 @@ describe('Gemini CLI Quota Fetcher', () => { mockFetch([ { - url: GOOGLE_TOKEN_URL, + url: MANAGEMENT_AUTH_FILES_URL, + response: { + files: [ + { + auth_index: 'target-auth-index', + provider: 'gemini-cli', + email: 'target@example.com', + name: 'target@example.com-gen-lang-client-target-project.json', + }, + ], + }, + }, + { + url: MANAGEMENT_API_CALL_URL, method: 'POST', - response: { access_token: 'target-fresh-token', expires_in: 1800 }, + response: { + status_code: 200, + body: JSON.stringify({ + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + }), + }, }, { url: GEMINI_QUOTA_URL, method: 'POST', - status: 200, + status: 401, response: { - buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, }, }, { @@ -728,14 +716,15 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.success).toBe(true); - const [refreshRequest, quotaRequest] = getCapturedFetchRequests(); - expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL); - expect(refreshRequest.body).toContain('refresh_token=target-refresh-token'); - expect(refreshRequest.body).not.toContain('default-refresh-token'); - expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token'); + const [, managedLookupRequest, managedQuotaRequest] = getCapturedFetchRequests(); + expect(managedLookupRequest.url).toBe(MANAGEMENT_AUTH_FILES_URL); + expect(managedQuotaRequest.url).toBe(MANAGEMENT_API_CALL_URL); + expect(managedQuotaRequest.body).toContain('"auth_index":"target-auth-index"'); + expect(managedQuotaRequest.body).toContain('"Authorization":"Bearer $TOKEN$"'); + expect(managedQuotaRequest.body).not.toContain('default-refresh-token'); }); - it('retries a 401 quota failure after a transient proactive refresh failure', async () => { + it('retries a 401 quota failure through the management API instead of refreshing locally', async () => { writeGeminiToken( { type: 'gemini', @@ -745,20 +734,12 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'retry-stale-token', refresh_token: 'retry-refresh-token', expiry: Date.now() + 60 * 1000, - client_id: 'retry-client-id', - client_secret: 'retry-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, }, 'gemini-retry.json' ); mockFetch([ - { - url: GOOGLE_TOKEN_URL, - method: 'POST', - response: { access_token: 'unused-default', expires_in: 1800 }, - }, { url: GEMINI_QUOTA_URL, method: 'POST', @@ -776,49 +757,63 @@ describe('Gemini CLI Quota Fetcher', () => { ]); const originalFetch = globalThis.fetch; - let refreshAttempt = 0; let quotaAttempt = 0; + let managedLookupAttempt = 0; + let managedRequestAttempt = 0; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GOOGLE_TOKEN_URL) { - refreshAttempt += 1; - return refreshAttempt === 1 - ? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { - status: 503, - headers: { 'Content-Type': 'application/json' }, - }) - : new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - if (url === GEMINI_QUOTA_URL) { quotaAttempt += 1; - return quotaAttempt === 1 - ? new Response( - JSON.stringify({ - error: { - message: 'Session expired', - status: 'UNAUTHENTICATED', - }, - }), + return new Response( + JSON.stringify({ + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url === MANAGEMENT_AUTH_FILES_URL) { + managedLookupAttempt += 1; + return new Response( + JSON.stringify({ + files: [ { - status: 401, - headers: { 'Content-Type': 'application/json' }, - } - ) - : new Response( - JSON.stringify({ - buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ); + auth_index: 'retry-auth-index', + provider: 'gemini-cli', + email: 'retry@example.com', + name: 'retry@example.com-gen-lang-client-retry-project.json', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url === MANAGEMENT_API_CALL_URL) { + managedRequestAttempt += 1; + return new Response( + JSON.stringify({ + status_code: 200, + body: JSON.stringify({ + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); } return originalFetch(input, init); @@ -828,13 +823,85 @@ describe('Gemini CLI Quota Fetcher', () => { const result = await fetchGeminiCliQuota('retry@example.com'); expect(result.success).toBe(true); - expect(refreshAttempt).toBe(2); - expect(quotaAttempt).toBe(2); + expect(quotaAttempt).toBe(1); + expect(managedLookupAttempt).toBe(1); + expect(managedRequestAttempt).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); - const storedToken = JSON.parse( - fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8') - ) as { token?: { access_token?: string } }; - expect(storedToken.token?.access_token).toBe('retry-fresh-token'); + it('reports a retryable management failure instead of reauth when delegated auth is unavailable', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'managed-failure@example.com', + project_id: 'managed-failure-project', + token: { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expiry: Date.now() - 1000, + }, + }, + 'gemini-managed-failure.json' + ); + + mockFetch([ + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const originalFetch = globalThis.fetch; + let directQuotaAttempt = 0; + let managedLookupAttempt = 0; + let managedRequestAttempt = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + if (url === MANAGEMENT_AUTH_FILES_URL) { + managedLookupAttempt += 1; + return new Response('lookup unavailable', { status: 503 }); + } + + if (url === MANAGEMENT_API_CALL_URL) { + managedRequestAttempt += 1; + return new Response('should not be called', { status: 500 }); + } + + if (url === GEMINI_QUOTA_URL) { + directQuotaAttempt += 1; + return new Response( + JSON.stringify({ + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return originalFetch(input, init); + }) as typeof fetch; + + try { + const result = await fetchGeminiCliQuota('managed-failure@example.com'); + + expect(result.success).toBe(false); + expect(result.needsReauth).toBeUndefined(); + expect(result.retryable).toBe(true); + expect(result.errorCode).toBe('managed_auth_unavailable'); + expect(directQuotaAttempt).toBe(1); + expect(managedLookupAttempt).toBe(1); + expect(managedRequestAttempt).toBe(0); } finally { globalThis.fetch = originalFetch; } @@ -934,84 +1001,4 @@ describe('Gemini CLI Quota Fetcher', () => { }); }); - describe('refreshGeminiToken', () => { - it('uses OAuth client metadata stored in the token file', async () => { - writeGeminiToken({ - type: 'gemini', - email: 'file@example.com', - token: { - access_token: 'old-token', - refresh_token: 'refresh-from-file', - expiry: Date.now() - 1000, - client_id: 'file-client-id', - client_secret: 'file-client-secret', - token_uri: 'https://oauth2.googleapis.com/token', - }, - }); - - mockFetch([ - { - url: 'https://oauth2.googleapis.com/token', - method: 'POST', - response: { access_token: 'fresh-token', expires_in: 1800 }, - }, - ]); - - const result = await refreshGeminiToken(); - - expect(result.success).toBe(true); - const [request] = getCapturedFetchRequests(); - expect(request.body).toContain('client_id=file-client-id'); - expect(request.body).toContain('client_secret=file-client-secret'); - expect(request.body).toContain('refresh_token=refresh-from-file'); - }); - - it('falls back to CCS_GEMINI_OAUTH_CLIENT_* env vars when token metadata is missing', async () => { - process.env.CCS_GEMINI_OAUTH_CLIENT_ID = 'env-client-id'; - process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = 'env-client-secret'; - - writeGeminiToken({ - type: 'gemini', - email: 'env@example.com', - token: { - access_token: 'old-token', - refresh_token: 'refresh-from-file', - expiry: Date.now() - 1000, - }, - }); - - mockFetch([ - { - url: 'https://oauth2.googleapis.com/token', - method: 'POST', - response: { access_token: 'fresh-token', expires_in: 1800 }, - }, - ]); - - const result = await refreshGeminiToken(); - - expect(result.success).toBe(true); - const [request] = getCapturedFetchRequests(); - expect(request.body).toContain('client_id=env-client-id'); - expect(request.body).toContain('client_secret=env-client-secret'); - }); - - it('returns a clear error when no refresh client credentials are available', async () => { - writeGeminiToken({ - type: 'gemini', - email: 'missing@example.com', - token: { - access_token: 'old-token', - refresh_token: 'refresh-from-file', - expiry: Date.now() - 1000, - }, - }); - - const result = await refreshGeminiToken(); - - expect(result.success).toBe(false); - expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_ID'); - expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_SECRET'); - }); - }); }); diff --git a/tests/unit/cliproxy/routing-strategy.test.ts b/tests/unit/cliproxy/routing-strategy.test.ts index 9302cdac..fca87c39 100644 --- a/tests/unit/cliproxy/routing-strategy.test.ts +++ b/tests/unit/cliproxy/routing-strategy.test.ts @@ -5,8 +5,8 @@ import * as path from 'path'; describe('cliproxy routing strategy service', () => { let tempHome = ''; - let originalCcsHome: string | undefined; - let setGlobalConfigDir: (dir: string | undefined) => void; + let scopedConfigDir = ''; + let runWithScopedConfigDir: (ccsDir: string, fn: () => Promise | T) => Promise; let routingTarget = { host: '127.0.0.1', port: 8317, @@ -16,29 +16,24 @@ describe('cliproxy routing strategy service', () => { let responseFactory: (() => Promise) | null = null; beforeEach(async () => { - originalCcsHome = process.env.CCS_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-strategy-')); - process.env.CCS_HOME = tempHome; + scopedConfigDir = path.join(tempHome, '.ccs'); - ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); - setGlobalConfigDir(path.join(tempHome, '.ccs')); + ({ runWithScopedConfigDir } = await import('../../../src/utils/config-manager')); }); afterEach(() => { mock.restore(); - setGlobalConfigDir(undefined); - - if (originalCcsHome !== undefined) { - process.env.CCS_HOME = originalCcsHome; - } else { - delete process.env.CCS_HOME; - } if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } }); + async function withScopedConfig(fn: () => Promise | T): Promise { + return await runWithScopedConfigDir(scopedConfigDir, fn); + } + async function loadRoutingModule() { mock.module('../../../src/cliproxy/routing-strategy-http', () => ({ getCliproxyRoutingTarget: () => routingTarget, @@ -58,70 +53,78 @@ describe('cliproxy routing strategy service', () => { } it('normalizes canonical and shorthand strategy values', async () => { - const mod = await loadRoutingModule(); + await withScopedConfig(async () => { + const mod = await loadRoutingModule(); - expect(mod.normalizeCliproxyRoutingStrategy('round-robin')).toBe('round-robin'); - expect(mod.normalizeCliproxyRoutingStrategy('RR')).toBe('round-robin'); - expect(mod.normalizeCliproxyRoutingStrategy('fillfirst')).toBe('fill-first'); - expect(mod.normalizeCliproxyRoutingStrategy('ff')).toBe('fill-first'); - expect(mod.normalizeCliproxyRoutingStrategy('nope')).toBeNull(); + expect(mod.normalizeCliproxyRoutingStrategy('round-robin')).toBe('round-robin'); + expect(mod.normalizeCliproxyRoutingStrategy('RR')).toBe('round-robin'); + expect(mod.normalizeCliproxyRoutingStrategy('fillfirst')).toBe('fill-first'); + expect(mod.normalizeCliproxyRoutingStrategy('ff')).toBe('fill-first'); + expect(mod.normalizeCliproxyRoutingStrategy('nope')).toBeNull(); + }); }); it('falls back to the saved local default when live CLIProxy is unavailable', async () => { - const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader'); - mutateUnifiedConfig((config) => { - if (config.cliproxy) { - config.cliproxy.routing = { strategy: 'fill-first' }; - } + await withScopedConfig(async () => { + const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader'); + mutateUnifiedConfig((config) => { + if (config.cliproxy) { + config.cliproxy.routing = { strategy: 'fill-first' }; + } + }); + + const mod = await loadRoutingModule(); + const state = await mod.readCliproxyRoutingState(); + + expect(state.strategy).toBe('fill-first'); + expect(state.source).toBe('config'); + expect(state.target).toBe('local'); + expect(state.reachable).toBe(false); }); - - const mod = await loadRoutingModule(); - const state = await mod.readCliproxyRoutingState(); - - expect(state.strategy).toBe('fill-first'); - expect(state.source).toBe('config'); - expect(state.target).toBe('local'); - expect(state.reachable).toBe(false); }); it('persists the local startup default even when the live proxy is down', async () => { - const mod = await loadRoutingModule(); - const result = await mod.applyCliproxyRoutingStrategy('fill-first'); + await withScopedConfig(async () => { + const mod = await loadRoutingModule(); + const result = await mod.applyCliproxyRoutingStrategy('fill-first'); - expect(result.applied).toBe('config-only'); - expect(result.strategy).toBe('fill-first'); + expect(result.applied).toBe('config-only'); + expect(result.strategy).toBe('fill-first'); - const configPath = path.join(tempHome, '.ccs', 'cliproxy', 'config.yaml'); - const configContent = fs.readFileSync(configPath, 'utf8'); - expect(configContent).toContain('routing:'); - expect(configContent).toContain('strategy: fill-first'); + const configPath = path.join(scopedConfigDir, 'cliproxy', 'config.yaml'); + const configContent = fs.readFileSync(configPath, 'utf8'); + expect(configContent).toContain('routing:'); + expect(configContent).toContain('strategy: fill-first'); + }); }); it('reads and writes remote strategy without mutating the local default', async () => { - routingTarget = { - host: 'remote.example.com', - port: 8080, - protocol: 'http', - isRemote: true, - }; + await withScopedConfig(async () => { + routingTarget = { + host: 'remote.example.com', + port: 8080, + protocol: 'http', + isRemote: true, + }; - let methodCount = 0; - responseFactory = async () => { - methodCount += 1; - return new Response(JSON.stringify({ strategy: 'fill-first' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }; + let methodCount = 0; + responseFactory = async () => { + methodCount += 1; + return new Response(JSON.stringify({ strategy: 'fill-first' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; - const mod = await loadRoutingModule(); - const readState = await mod.readCliproxyRoutingState(); - const writeState = await mod.applyCliproxyRoutingStrategy('fill-first'); + const mod = await loadRoutingModule(); + const readState = await mod.readCliproxyRoutingState(); + const writeState = await mod.applyCliproxyRoutingStrategy('fill-first'); - expect(readState.strategy).toBe('fill-first'); - expect(readState.target).toBe('remote'); - expect(writeState.applied).toBe('live'); - expect(mod.getConfiguredCliproxyRoutingStrategy()).toBe('round-robin'); - expect(methodCount).toBe(2); + expect(readState.strategy).toBe('fill-first'); + expect(readState.target).toBe('remote'); + expect(writeState.applied).toBe('live'); + expect(mod.getConfiguredCliproxyRoutingStrategy()).toBe('round-robin'); + expect(methodCount).toBe(2); + }); }); }); diff --git a/tests/unit/cliproxy/service-manager-startup.test.ts b/tests/unit/cliproxy/service-manager-startup.test.ts new file mode 100644 index 00000000..5b85f9fc --- /dev/null +++ b/tests/unit/cliproxy/service-manager-startup.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, mock } from 'bun:test'; + +const ensureBinaryCalls: Array = []; + +mock.module('../../../src/cliproxy/binary-manager', () => ({ + ensureCLIProxyBinary: async (_verbose = false, options?: unknown) => { + ensureBinaryCalls.push(options); + throw new Error( + 'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + }, +})); + +mock.module('../../../src/cliproxy/config-generator', () => ({ + ensureConfigDir: () => undefined, + generateConfig: () => '/tmp/cliproxy-config.yaml', + regenerateConfig: () => '/tmp/cliproxy-config.yaml', + configNeedsRegeneration: () => false, + CLIPROXY_DEFAULT_PORT: 8317, + getCliproxyWritablePath: () => '/tmp', +})); + +mock.module('../../../src/cliproxy/proxy-detector', () => ({ + detectRunningProxy: async () => ({ running: false, verified: false }), + waitForProxyHealthy: async () => false, +})); + +mock.module('../../../src/cliproxy/startup-lock', () => ({ + withStartupLock: async (fn: () => Promise) => await fn(), +})); + +mock.module('../../../src/cliproxy/session-tracker', () => ({ + registerSession: () => undefined, +})); + +mock.module('../../../src/cliproxy/stats-fetcher', () => ({ + isCliproxyRunning: async () => false, +})); + +mock.module('../../../src/cliproxy/auth/token-refresh-config', () => ({ + getTokenRefreshConfig: () => null, +})); + +mock.module('../../../src/cliproxy/auth/token-refresh-worker', () => ({ + TokenRefreshWorker: class { + isActive(): boolean { + return false; + } + start(): void {} + stop(): void {} + }, +})); + +const { ensureCliproxyService } = await import( + `../../../src/cliproxy/service-manager?service-manager-startup=${Date.now()}` +); + +describe('ensureCliproxyService', () => { + it('fails fast without attempting a runtime install when the local binary is missing', async () => { + ensureBinaryCalls.length = 0; + + const result = await ensureCliproxyService(8317, false); + + expect(result).toEqual({ + started: false, + alreadyRunning: false, + port: 8317, + error: + 'Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.', + }); + expect(ensureBinaryCalls).toEqual([ + { + allowInstall: false, + skipAutoUpdate: true, + }, + ]); + }); +}); diff --git a/tests/unit/cliproxy/session-tracker-target.test.ts b/tests/unit/cliproxy/session-tracker-target.test.ts index 2a8d23ad..762e1f04 100644 --- a/tests/unit/cliproxy/session-tracker-target.test.ts +++ b/tests/unit/cliproxy/session-tracker-target.test.ts @@ -1,56 +1,111 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { - registerSession, - unregisterSession, - getProxyStatus, -} from '../../../src/cliproxy/session-tracker'; +import { spawnSync } from 'child_process'; +import { pathToFileURL } from 'url'; -describe('session-tracker target metadata', () => { - let tmpDir: string; - let originalCcsHome: string | undefined; - const port = 28317; +const REPO_ROOT = path.resolve(import.meta.dir, '../../..'); +const SESSION_TRACKER_URL = pathToFileURL( + path.join(REPO_ROOT, 'src/cliproxy/session-tracker.ts') +).href; - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-')); - originalCcsHome = process.env.CCS_HOME; - process.env.CCS_HOME = tmpDir; - }); +function withScopedSessionTrackerHome(run: (tempHome: string) => T): T { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-')); + try { + return run(tempHome); + } finally { + fs.rmSync(tempHome, { recursive: true, force: true }); + } +} - afterEach(() => { - if (originalCcsHome !== undefined) { - process.env.CCS_HOME = originalCcsHome; - } else { - delete process.env.CCS_HOME; +function runSessionTrackerScenario( + tempHome: string, + targets: string[] +): { + running: boolean; + target?: string; + sessionCount?: number; +} { + const script = ` + import { + registerSession, + unregisterSession, + getProxyStatus, + } from ${JSON.stringify(SESSION_TRACKER_URL)}; + + const port = 28317; + const sessionIds = []; + for (const target of ${JSON.stringify(targets)}) { + sessionIds.push(registerSession(port, process.pid, undefined, undefined, target)); } - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('returns single target when all sessions share same target', () => { - const s1 = registerSession(port, process.pid, undefined, undefined, 'droid'); - const s2 = registerSession(port, process.pid, undefined, undefined, 'droid'); const status = getProxyStatus(port); - expect(status.running).toBe(true); - expect(status.target).toBe('droid'); - expect(status.sessionCount).toBe(2); + for (const sessionId of sessionIds) { + unregisterSession(sessionId, port); + } - unregisterSession(s1, port); - unregisterSession(s2, port); + console.log(JSON.stringify({ + running: status.running, + target: status.target ?? null, + sessionCount: status.sessionCount ?? null, + })); + `; + + const scriptPath = path.join(tempHome, `session-target-child-${Date.now()}.mjs`); + fs.writeFileSync(scriptPath, script, 'utf8'); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + env: { + ...process.env, + CCS_HOME: tempHome, + CCS_DIR: '', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (result.status !== 0) { + throw new Error( + `child session-tracker scenario failed: ${JSON.stringify({ + command: `${process.execPath} ${scriptPath}`, + status: result.status, + signal: result.signal, + error: result.error?.message ?? null, + stdout: result.stdout, + stderr: result.stderr, + })}` + ); + } + + const lines = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + return JSON.parse(lines.at(-1) || '{}') as { + running: boolean; + target?: string; + sessionCount?: number; + }; +} + +describe('session-tracker target metadata', () => { + it('returns single target when all sessions share same target', () => { + withScopedSessionTrackerHome((tempHome) => { + const status = runSessionTrackerScenario(tempHome, ['droid', 'droid']); + expect(status.running).toBe(true); + expect(status.target).toBe('droid'); + expect(status.sessionCount).toBe(2); + }); }); it('returns mixed when active sessions use different targets', () => { - const s1 = registerSession(port, process.pid, undefined, undefined, 'claude'); - const s2 = registerSession(port, process.pid, undefined, undefined, 'droid'); - - const status = getProxyStatus(port); - expect(status.running).toBe(true); - expect(status.target).toBe('mixed'); - expect(status.sessionCount).toBe(2); - - unregisterSession(s1, port); - unregisterSession(s2, port); + withScopedSessionTrackerHome((tempHome) => { + const status = runSessionTrackerScenario(tempHome, ['claude', 'droid']); + expect(status.running).toBe(true); + expect(status.target).toBe('mixed'); + expect(status.sessionCount).toBe(2); + }); }); }); diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index fe63fb7d..7e98a4ff 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -100,7 +100,7 @@ cliproxy: expect(result.success).toBe(true); expect(result.variant?.provider).toBe('codex'); - expect(result.variant?.model).toBe('gpt-5.1-codex-mini'); + expect(result.variant?.model).toBe('gpt-5.4-mini'); const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json'); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { @@ -109,10 +109,10 @@ cliproxy: }; expect(settings.env.ANTHROPIC_BASE_URL).toContain('/api/provider/codex'); - expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini'); - expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.1-codex-mini'); - expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.1-codex-mini'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.4-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); expect(settings.env.CUSTOM_FLAG).toBe('keep-me'); expect(settings.hooks.PreToolUse.length).toBe(1); @@ -134,7 +134,7 @@ cliproxy: expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); const modelOnly = updateVariant('demo', { model: 'gpt-5.3-codex' }); expect(modelOnly.success).toBe(true); @@ -145,6 +145,6 @@ cliproxy: expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); }); }); diff --git a/tests/unit/cliproxy/version-checker-stale-cache.test.ts b/tests/unit/cliproxy/version-checker-stale-cache.test.ts index 27c03871..c8829faa 100644 --- a/tests/unit/cliproxy/version-checker-stale-cache.test.ts +++ b/tests/unit/cliproxy/version-checker-stale-cache.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -14,6 +14,8 @@ describe('version-checker stale cache fallback', () => { }); afterEach(() => { + mock.restore(); + if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { @@ -86,4 +88,50 @@ describe('version-checker stale cache fallback', () => { expect(result.latest).toBe('6.9.23-0'); expect(result.fromCache).toBe(true); }); + + it('skips update lookups when runtime startup prefers the installed binary', async () => { + const { getExecutableName } = await import('../../../src/cliproxy/platform-detector'); + const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus'); + fs.mkdirSync(plusBinDir, { recursive: true }); + fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'binary'); + + let checkForUpdatesCalls = 0; + + mock.module('../../../src/cliproxy/binary/version-checker', () => ({ + checkForUpdates: async () => { + checkForUpdatesCalls += 1; + return { + hasUpdate: false, + currentVersion: '6.8.2-0', + latestVersion: '6.8.2-0', + fromCache: false, + checkedAt: Date.now(), + }; + }, + fetchLatestVersion: async () => { + throw new Error('fetchLatestVersion should not run when skipAutoUpdate is enabled'); + }, + isNewerVersion: () => false, + isVersionFaulty: () => false, + })); + + const { ensureBinary } = await import( + `../../../src/cliproxy/binary/lifecycle?skip-auto-update=${Date.now()}` + ); + + const binaryPath = await ensureBinary({ + version: '6.8.2-0', + releaseUrl: 'https://example.com/releases/download', + binPath: plusBinDir, + maxRetries: 1, + verbose: false, + forceVersion: false, + skipAutoUpdate: true, + allowInstall: true, + backend: 'plus', + }); + + expect(binaryPath).toBe(path.join(plusBinDir, getExecutableName('plus'))); + expect(checkForUpdatesCalls).toBe(0); + }); }); diff --git a/tests/unit/commands/browser-command.test.ts b/tests/unit/commands/browser-command.test.ts new file mode 100644 index 00000000..c7c55412 --- /dev/null +++ b/tests/unit/commands/browser-command.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, test, spyOn } from 'bun:test'; + +import * as browserUtils from '../../../src/utils/browser'; +import { handleBrowserCommand } from '../../../src/commands/browser-command'; + +function stripAnsi(input: string): string { + return input.replace(/\u001b\[[0-9;]*m/g, ''); +} + +async function renderLines(args: string[]): Promise { + const lines: string[] = []; + await handleBrowserCommand(args, (line) => lines.push(line)); + return stripAnsi(lines.join('\n')); +} + +function currentPlatform(): 'darwin' | 'linux' | 'win32' { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; +} + +describe('browser command', () => { + afterEach(() => { + process.exitCode = 0; + }); + + test('status renders both browser lanes from the shared status payload', async () => { + const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ + claude: { + enabled: true, + source: 'config', + overrideActive: false, + state: 'ready', + title: 'Claude Browser Attach is ready.', + detail: 'CCS can reach the configured Chrome DevTools endpoint.', + nextStep: 'Launch Claude.', + effectiveUserDataDir: '/tmp/browser-profile', + recommendedUserDataDir: '/tmp/browser-profile', + devtoolsPort: 9222, + managedMcpServerName: 'ccs-browser', + managedMcpServerPath: '/tmp/ccs-browser-server.cjs', + launchCommands: { + darwin: 'open -na "Google Chrome" --args', + linux: 'google-chrome --remote-debugging-port=9222', + win32: 'chrome.exe --remote-debugging-port=9222', + }, + runtimeEnv: { + CCS_BROWSER_USER_DATA_DIR: '/tmp/browser-profile', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9222', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }, + }, + codex: { + enabled: true, + state: 'enabled', + title: 'Codex Browser Tools are enabled.', + detail: 'CCS can inject the managed Playwright MCP overrides.', + nextStep: 'Use a Codex-target launch.', + serverName: 'ccs_browser', + supportsConfigOverrides: true, + binaryPath: '/usr/local/bin/codex', + version: 'codex-cli 0.120.0', + }, + }); + + try { + const rendered = await renderLines(['status']); + + expect(rendered.includes('ccs browser status')).toBe(true); + expect(rendered.includes('Claude Browser Attach reuses a local Chrome session')).toBe(true); + expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe( + true + ); + expect(rendered.includes('Managed MCP: ccs-browser')).toBe(true); + expect(rendered.includes('Managed server: ccs_browser')).toBe(true); + expect(rendered.includes('DevTools endpoint: http://127.0.0.1:9222')).toBe(true); + } finally { + statusSpy.mockRestore(); + } + }); + + test('doctor prints env override context and launch guidance when Claude attach is not ready', async () => { + const launchCommands = { + darwin: 'open -na "Google Chrome" --args --remote-debugging-port=9444', + linux: + 'google-chrome --remote-debugging-port=9444 --user-data-dir="/tmp/browser-profile"', + win32: 'chrome.exe --remote-debugging-port=9444 --user-data-dir="/tmp/browser-profile"', + }; + const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ + claude: { + enabled: true, + source: 'CCS_BROWSER_PROFILE_DIR', + overrideActive: true, + state: 'browser_not_running', + title: 'Claude Browser Attach could not find a running browser session.', + detail: 'Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort', + nextStep: 'Start Chrome with remote debugging.', + effectiveUserDataDir: '/tmp/browser-profile', + recommendedUserDataDir: '/tmp/browser-profile', + devtoolsPort: 9444, + managedMcpServerName: 'ccs-browser', + managedMcpServerPath: '/tmp/ccs-browser-server.cjs', + launchCommands, + }, + codex: { + enabled: true, + state: 'unsupported_build', + title: 'Codex Browser Tools need a Codex build with --config override support.', + detail: 'Detected Codex at /usr/local/bin/codex, but it does not advertise --config overrides.', + nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', + serverName: 'ccs_browser', + supportsConfigOverrides: false, + binaryPath: '/usr/local/bin/codex', + version: 'codex-cli 0.70.0', + }, + }); + + try { + const rendered = await renderLines(['doctor']); + + expect(rendered.includes('Result: action required')).toBe(true); + expect(rendered.includes('Source: CCS_BROWSER_PROFILE_DIR (env override active)')).toBe( + true + ); + expect( + rendered.includes( + `Launch command (${currentPlatform()}): ${launchCommands[currentPlatform()]}` + ) + ).toBe(true); + expect(rendered.includes('Detected Codex at /usr/local/bin/codex')).toBe(true); + expect(process.exitCode).toBe(1); + } finally { + statusSpy.mockRestore(); + } + }); + + test('doctor stays ready on Claude-only machines when Codex is not installed', async () => { + const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ + claude: { + enabled: false, + source: 'config', + overrideActive: false, + state: 'disabled', + title: 'Claude Browser Attach is disabled.', + detail: 'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.', + nextStep: + 'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.', + effectiveUserDataDir: '/tmp/browser-profile', + recommendedUserDataDir: '/tmp/browser-profile', + devtoolsPort: 9222, + managedMcpServerName: 'ccs-browser', + managedMcpServerPath: '/tmp/ccs-browser-server.cjs', + launchCommands: { + darwin: 'open -na "Google Chrome" --args --remote-debugging-port=9222', + linux: + 'google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + win32: + 'chrome.exe --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + }, + }, + codex: { + enabled: true, + state: 'unsupported_build', + title: 'Codex Browser Tools need a Codex build with --config override support.', + detail: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', + nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', + serverName: 'ccs_browser', + supportsConfigOverrides: false, + binaryPath: null, + }, + }); + + try { + const rendered = await renderLines(['doctor']); + + expect(rendered.includes('Result: partial')).toBe(true); + expect(rendered.includes('run `ccs browser enable`')).toBe(false); + expect(process.exitCode).toBe(0); + } finally { + statusSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index 5bb12d0a..60d3a814 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -82,6 +82,9 @@ describe('completion backend', () => { expect(values).toContain('cursor'); expect(values).toContain('copilot'); expect(values).toContain('gemini'); + expect(values).toContain('gitlab'); + expect(values).toContain('codebuddy'); + expect(values).toContain('kilo'); expect(values).toContain('localglm'); expect(values).toContain('work'); expect(values).toContain('my-codex'); @@ -137,6 +140,15 @@ describe('completion backend', () => { expect(suggestionValues(['cliproxy'])).toEqual(expect.arrayContaining(['remove', '--backend'])); }); + test('treats cursor as a provider shortcut in completion', () => { + const values = suggestionValues(['cursor']); + expect(values).toEqual( + expect.arrayContaining(['--auth', '--accounts', '--config', '--logout']) + ); + expect(values).not.toContain('probe'); + expect(values).not.toContain('start'); + }); + test('filters suggestions by the current token prefix', () => { const values = suggestionValues([], 'do'); expect(values).toEqual(expect.arrayContaining(['docker', 'doctor'])); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 98d2320e..2fd3fbcf 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -171,6 +171,26 @@ describe('config command dashboard startup', () => { expect(errorLines).toHaveLength(0); }); + it('still opens the dashboard when CLIProxy is unavailable', async () => { + await handleConfigCommand([], { + ...createTestDeps(), + ensureCliproxyService: async () => ({ + started: false, + alreadyRunning: false, + port: 8317, + error: + 'Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.', + }), + }); + + expect(startServerCalls).toHaveLength(1); + const rendered = logLines.join('\n'); + expect(rendered).toContain( + 'CLIProxy not available: Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + expect(rendered).toContain('Dashboard will work but Control Panel/Stats may be limited'); + }); + it('fails cleanly when the server cannot bind the requested host', async () => { startServerError = new Error( 'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 017ec18e..2a493d4c 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -25,6 +25,7 @@ describe('help command parity', () => { expect(visibleLines.length).toBeLessThanOrEqual(90); expect(rendered.includes('ccs help ')).toBe(true); + expect(rendered.includes('ccs help browser')).toBe(true); expect(rendered.includes('ccs help completion')).toBe(true); }); @@ -50,6 +51,12 @@ describe('help command parity', () => { expect(rendered.includes('gemini')).toBe(true); expect(rendered.includes('codex')).toBe(true); expect(rendered.includes('ghcp')).toBe(true); + expect(rendered.includes('gitlab')).toBe(true); + expect(rendered.includes('codebuddy')).toBe(true); + expect(rendered.includes('kilo')).toBe(true); + expect(rendered.includes('--gitlab-token-login')).toBe(true); + expect(rendered.includes('--token-login')).toBe(true); + expect(rendered.includes('--gitlab-url ')).toBe(true); }); test('kiro topic documents IDC and callback flags', async () => { @@ -63,6 +70,18 @@ describe('help command parity', () => { expect(rendered.includes('GitHub OAuth is dashboard-only')).toBe(true); }); + test('browser topic explains Claude attach versus Codex browser tools', async () => { + const rendered = await renderLines((writeLine) => handleHelpRoute(['browser'], writeLine)); + + expect(rendered.includes('CCS Browser Help')).toBe(true); + expect(rendered.includes('Claude Browser Attach reuses a local Chrome session')).toBe(true); + expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe( + true + ); + expect(rendered.includes('ccs browser status')).toBe(true); + expect(rendered.includes('ccs browser doctor')).toBe(true); + }); + test('completion topic documents install and verification paths', async () => { const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine)); diff --git a/tests/unit/commands/tokens-command-auth-rotation.test.ts b/tests/unit/commands/tokens-command-auth-rotation.test.ts index 4c3e95aa..9692148a 100644 --- a/tests/unit/commands/tokens-command-auth-rotation.test.ts +++ b/tests/unit/commands/tokens-command-auth-rotation.test.ts @@ -1,105 +1,126 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { pathToFileURL } from 'url'; +import { setGlobalConfigDir } from '../../../src/utils/config-manager'; -async function loadTokensCommand() { - return await import( - `../../../src/commands/tokens-command?test=${Date.now()}-${Math.random()}` - ); +const REPO_ROOT = path.resolve(import.meta.dir, '../../..'); +const TOKENS_COMMAND_URL = pathToFileURL( + path.join(REPO_ROOT, 'src/commands/tokens-command.ts') +).href; +const UNIFIED_CONFIG_LOADER_URL = pathToFileURL( + path.join(REPO_ROOT, 'src/config/unified-config-loader.ts') +).href; + +function withScopedTokensHome(run: (tempHome: string) => T): T { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-')); + setGlobalConfigDir(undefined); + + try { + return run(tempHome); + } finally { + setGlobalConfigDir(undefined); + fs.rmSync(tempHome, { recursive: true, force: true }); + } } -async function loadCliproxyModule() { - return await import(`../../../src/cliproxy?test=${Date.now()}-${Math.random()}`); -} +function runTokensCommandInChild(tempHome: string, args: string[]) { + const script = ` + import { handleTokensCommand } from ${JSON.stringify(TOKENS_COMMAND_URL)}; + import { loadUnifiedConfig } from ${JSON.stringify(UNIFIED_CONFIG_LOADER_URL)}; -async function loadUnifiedConfigModule() { - return await import( - `../../../src/config/unified-config-loader?test=${Date.now()}-${Math.random()}` - ); + const exitCode = await handleTokensCommand(${JSON.stringify(args)}); + const config = loadUnifiedConfig(); + const managementSecret = config?.cliproxy.auth?.management_secret ?? null; + + console.log(JSON.stringify({ + exitCode, + apiKey: config?.cliproxy.auth?.api_key ?? null, + managementSecretLength: typeof managementSecret === 'string' ? managementSecret.length : 0, + })); + `; + + const scriptPath = path.join(tempHome, `tokens-child-${Date.now()}.mjs`); + fs.writeFileSync(scriptPath, script, 'utf8'); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + env: { + ...process.env, + CCS_HOME: tempHome, + CCS_DIR: '', + NO_COLOR: '1', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (result.status !== 0) { + throw new Error( + `child tokens command failed: ${JSON.stringify({ + command: `${process.execPath} ${scriptPath}`, + status: result.status, + signal: result.signal, + error: result.error?.message ?? null, + stdout: result.stdout, + stderr: result.stderr, + })}` + ); + } + + const lines = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const payload = JSON.parse(lines.at(-1) || '{}') as { + exitCode: number; + apiKey: string | null; + managementSecretLength: number; + }; + + return { payload, stdout: result.stdout, stderr: result.stderr }; } describe('tokens command auth rotation', () => { - let tempHome = ''; - let logLines: string[] = []; - let errorLines: string[] = []; - let originalCcsHome: string | undefined; - let originalNoColor: string | undefined; - let originalConsoleLog: typeof console.log; - let originalConsoleError: typeof console.error; + it('applies api-key and regenerated secret in a single invocation', () => { + withScopedTokensHome((tempHome) => { + const { payload } = runTokensCommandInChild(tempHome, [ + '--api-key', + 'ccs-custom-key-123', + '--regenerate-secret', + ]); + const configYamlPath = path.join(tempHome, '.ccs', 'config.yaml'); - beforeEach(() => { - tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-')); - logLines = []; - errorLines = []; - originalCcsHome = process.env.CCS_HOME; - originalNoColor = process.env.NO_COLOR; - originalConsoleLog = console.log; - originalConsoleError = console.error; + const diagnostics = { + exitCode: payload.exitCode, + configYamlPath, + configExists: fs.existsSync(configYamlPath), + apiKey: payload.apiKey, + managementSecretLength: payload.managementSecretLength, + }; - process.env.CCS_HOME = tempHome; - process.env.NO_COLOR = '1'; - console.log = (...args: unknown[]) => { - logLines.push(args.map(String).join(' ')); - }; - console.error = (...args: unknown[]) => { - errorLines.push(args.map(String).join(' ')); - }; + if ( + payload.exitCode !== 0 || + payload.apiKey !== 'ccs-custom-key-123' || + payload.managementSecretLength <= 20 + ) { + throw new Error(`tokens rotation diagnostics: ${JSON.stringify(diagnostics)}`); + } + }); }); - afterEach(() => { - if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; - else delete process.env.CCS_HOME; + it('rejects conflicting manual and generated secret flags', () => { + withScopedTokensHome((tempHome) => { + const { payload } = runTokensCommandInChild(tempHome, [ + '--secret', + 'manual-secret', + '--regenerate-secret', + ]); - if (originalNoColor !== undefined) process.env.NO_COLOR = originalNoColor; - else delete process.env.NO_COLOR; - - console.log = originalConsoleLog; - console.error = originalConsoleError; - fs.rmSync(tempHome, { recursive: true, force: true }); - }); - - it('applies api-key and regenerated secret in a single invocation', async () => { - const { handleTokensCommand } = await loadTokensCommand(); - const { getCliproxyConfigPath } = await loadCliproxyModule(); - const { loadUnifiedConfig } = await loadUnifiedConfigModule(); - - const exitCode = await handleTokensCommand([ - '--api-key', - 'ccs-custom-key-123', - '--regenerate-secret', - ]); - - expect(exitCode).toBe(0); - expect(errorLines).toHaveLength(0); - expect(logLines.some((line) => line.includes('New management secret generated'))).toBe(true); - expect(logLines.some((line) => line.includes('Global API key updated'))).toBe(true); - expect(logLines.filter((line) => line.includes('CLIProxy config regenerated'))).toHaveLength(1); - - const config = loadUnifiedConfig(); - const managementSecret = config?.cliproxy.auth?.management_secret; - expect(config?.cliproxy.auth?.api_key).toBe('ccs-custom-key-123'); - expect(typeof managementSecret).toBe('string'); - expect((managementSecret ?? '').length).toBeGreaterThan(20); - - const cliproxyConfig = fs.readFileSync(getCliproxyConfigPath(), 'utf8'); - expect(cliproxyConfig).toContain('"ccs-custom-key-123"'); - }); - - it('rejects conflicting manual and generated secret flags', async () => { - const { handleTokensCommand } = await loadTokensCommand(); - const { getConfigYamlPath } = await loadUnifiedConfigModule(); - - const exitCode = await handleTokensCommand([ - '--secret', - 'manual-secret', - '--regenerate-secret', - ]); - - expect(exitCode).toBe(1); - expect( - errorLines.some((line) => line.includes('Cannot combine --secret with --regenerate-secret')) - ).toBe(true); - expect(fs.existsSync(getConfigYamlPath())).toBe(false); + expect(payload.exitCode).toBe(1); + expect(fs.existsSync(path.join(tempHome, '.ccs', 'config.yaml'))).toBe(false); + }); }); }); diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index 0abaec2b..9da161fe 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -281,7 +281,7 @@ describe('createAnthropicProxyResponse', () => { }; expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); - expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + expect(body.error?.message).toBe('Failed to translate OpenAI-compatible JSON response'); }); it('returns 502 when Cursor response is missing choices', async () => { @@ -305,7 +305,7 @@ describe('createAnthropicProxyResponse', () => { }; expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); - expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + expect(body.error?.message).toBe('Failed to translate OpenAI-compatible JSON response'); }); it('returns 502 when Cursor response has empty choices', async () => { @@ -330,7 +330,7 @@ describe('createAnthropicProxyResponse', () => { }; expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); - expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + expect(body.error?.message).toBe('Failed to translate OpenAI-compatible JSON response'); }); it('returns Anthropic error envelopes for non-OK upstream JSON errors', async () => { @@ -382,7 +382,7 @@ describe('createAnthropicProxyResponse', () => { }; expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); - expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + expect(body.error?.message).toBe('Failed to translate OpenAI-compatible JSON response'); }); it('converts OpenAI SSE chunks into Anthropic SSE events', async () => { @@ -420,7 +420,7 @@ describe('createAnthropicProxyResponse', () => { expect(body).toContain('event: error'); expect(body).toContain('"type":"error"'); expect(body).toContain('"error":{"type":"api_error"'); - expect(body).toContain('Failed to translate Cursor SSE response'); + expect(body).toContain('Failed to translate OpenAI-compatible SSE response'); }); it('emits Anthropic-style error events when SSE JSON is malformed', async () => { @@ -435,6 +435,6 @@ describe('createAnthropicProxyResponse', () => { expect(body).toContain('event: error'); expect(body).toContain('"type":"error"'); expect(body).toContain('"error":{"type":"api_error"'); - expect(body).toContain('Failed to translate Cursor SSE response'); + expect(body).toContain('Failed to translate OpenAI-compatible SSE response'); }); }); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 6dd2afee..edfac3bd 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -321,8 +321,10 @@ describe('handleCursorCommand', () => { expect(exitCode).toBe(0); expect(errors).toHaveLength(0); - expect(logs.some((line) => line.includes('Cursor IDE Integration'))).toBe(true); - expect(logs.some((line) => line.includes('Usage: ccs cursor '))).toBe(true); + expect(logs.some((line) => line.includes('Legacy Cursor Compatibility'))).toBe(true); + expect(logs.some((line) => line.includes('Usage: ccs legacy cursor '))).toBe( + true + ); } finally { console.log = originalLog; console.error = originalError; @@ -423,7 +425,9 @@ describe('renderCursorStatus', () => { true ); expect(logs.some((line) => line.includes('Next steps:'))).toBe(true); - expect(logs.some((line) => line.includes(' - Help: ccs cursor help'))).toBe(true); + expect(logs.some((line) => line.includes(' - Help: ccs legacy cursor help'))).toBe( + true + ); } finally { console.log = originalLog; } @@ -471,7 +475,7 @@ describe('renderCursorStatus', () => { }); describe('renderCursorHelp', () => { - it('shows bare ccs cursor as the runtime entrypoint', () => { + it('marks the legacy Cursor surface deprecated while keeping compatibility guidance', () => { const originalLog = console.log; const logs: string[] = []; @@ -483,12 +487,23 @@ describe('renderCursorHelp', () => { const exitCode = renderCursorHelp(); expect(exitCode).toBe(0); - expect(logs.some((line) => line.includes('Usage: ccs cursor '))).toBe(true); + expect(logs.some((line) => line.includes('Usage: ccs legacy cursor '))).toBe( + true + ); + expect(logs.some((line) => line.includes('Legacy Cursor Compatibility'))).toBe(true); + expect( + logs.some((line) => + line.includes('Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.') + ) + ).toBe(true); expect(logs.some((line) => line.includes('probe Run a live authenticated runtime probe'))).toBe( true ); expect( - logs.some((line) => line.includes('ccs cursor [claude args]')) + logs.some((line) => line.includes('ccs cursor --auth')) + ).toBe(true); + expect( + logs.some((line) => line.includes('ccs legacy cursor [claude args]')) ).toBe(true); } finally { console.log = originalLog; diff --git a/tests/unit/cursor/cursor-shortcut-routing.test.ts b/tests/unit/cursor/cursor-shortcut-routing.test.ts new file mode 100644 index 00000000..5b50ca51 --- /dev/null +++ b/tests/unit/cursor/cursor-shortcut-routing.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'bun:test'; +import { shouldUseCursorCliproxyShortcut } from '../../../src/cursor/constants'; + +describe('cursor CLIProxy shortcut routing', () => { + it('accepts shortcut flags after leading generic flags', () => { + expect(shouldUseCursorCliproxyShortcut(['cursor', '--auth'])).toBe(true); + expect(shouldUseCursorCliproxyShortcut(['cursor', '--verbose', '--auth'])).toBe(true); + expect(shouldUseCursorCliproxyShortcut(['cursor', '-v', '--accounts'])).toBe(true); + }); + + it('stops scanning once a positional legacy runtime argument appears', () => { + expect(shouldUseCursorCliproxyShortcut(['cursor', 'write', '--auth'])).toBe(false); + expect(shouldUseCursorCliproxyShortcut(['cursor', 'status'])).toBe(false); + }); +}); diff --git a/tests/unit/glmt/sse-parser.test.ts b/tests/unit/glmt/sse-parser.test.ts new file mode 100644 index 00000000..740fc0e1 --- /dev/null +++ b/tests/unit/glmt/sse-parser.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test'; +import { SSEParser } from '../../../src/glmt/sse-parser'; + +describe('SSEParser', () => { + it('merges multi-line data fields into one JSON payload', () => { + const parser = new SSEParser({ throwOnMalformedJson: true }); + const events = parser.parse( + [ + 'event: message', + 'data: {"choices":[', + 'data: {"delta":{"content":"Hello"}}', + 'data: ]}', + '', + '', + ].join('\n') + ); + + expect(events).toHaveLength(1); + expect( + (events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0] + ?.delta?.content + ).toBe('Hello'); + }); + + it('keeps incomplete events buffered until the separator arrives', () => { + const parser = new SSEParser({ throwOnMalformedJson: true }); + expect(parser.parse('data: {"choices":[{"delta":{"content":"Hi"}}]}')).toEqual([]); + const events = parser.parse('\n\n'); + + expect(events).toHaveLength(1); + expect( + (events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0] + ?.delta?.content + ).toBe('Hi'); + }); + + it('accepts standalone carriage-return line endings', () => { + const parser = new SSEParser({ throwOnMalformedJson: true }); + const events = parser.parse( + ['event: message', 'data: {"choices":[{"delta":{"content":"Legacy"}}]}', '', ''].join('\r') + ); + + expect(events).toHaveLength(1); + expect( + (events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0] + ?.delta?.content + ).toBe('Legacy'); + }); +}); diff --git a/tests/unit/proxy/messages-route.test.ts b/tests/unit/proxy/messages-route.test.ts new file mode 100644 index 00000000..3ac40437 --- /dev/null +++ b/tests/unit/proxy/messages-route.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'bun:test'; +import { EventEmitter } from 'events'; +import { attachDisconnectAbortHandlers } from '../../../src/proxy/server/messages-route'; + +class FakeSocket extends EventEmitter { + destroyed = false; +} + +class FakeRequest extends EventEmitter { + destroyed = false; + socket = new FakeSocket(); +} + +class FakeResponse extends EventEmitter { + destroyed = false; + writableEnded = false; + socket = new FakeSocket(); +} + +describe('attachDisconnectAbortHandlers', () => { + it('cleans up registered listeners after the request completes', () => { + const req = new FakeRequest(); + const res = new FakeResponse(); + const controller = new AbortController(); + + const cleanup = attachDisconnectAbortHandlers( + req as never, + res as never, + controller, + () => {} + ); + + expect(req.listenerCount('aborted')).toBe(1); + expect(req.listenerCount('close')).toBe(1); + expect(req.socket.listenerCount('close')).toBe(1); + expect(res.listenerCount('close')).toBe(1); + expect(res.socket.listenerCount('close')).toBe(1); + + cleanup(); + + expect(req.listenerCount('aborted')).toBe(0); + expect(req.listenerCount('close')).toBe(0); + expect(req.socket.listenerCount('close')).toBe(0); + expect(res.listenerCount('close')).toBe(0); + expect(res.socket.listenerCount('close')).toBe(0); + }); + + it('aborts at most once when disconnect signals race each other', () => { + const req = new FakeRequest(); + const res = new FakeResponse(); + const controller = new AbortController(); + let disconnectCount = 0; + + const cleanup = attachDisconnectAbortHandlers( + req as never, + res as never, + controller, + () => { + disconnectCount += 1; + } + ); + + req.emit('close'); + req.socket.emit('close'); + res.emit('close'); + + expect(controller.signal.aborted).toBe(true); + expect(disconnectCount).toBe(1); + + cleanup(); + }); +}); diff --git a/tests/unit/proxy/profile-router.test.ts b/tests/unit/proxy/profile-router.test.ts new file mode 100644 index 00000000..ba523ed9 --- /dev/null +++ b/tests/unit/proxy/profile-router.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'bun:test'; +import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +describe('resolveOpenAICompatProfileConfig', () => { + it('detects generic OpenAI-compatible profiles from base URL and provider hint', () => { + const result = resolveOpenAICompatProfileConfig('hf', '/tmp/hf.settings.json', { + ANTHROPIC_BASE_URL: 'https://router.huggingface.co/v1', + ANTHROPIC_AUTH_TOKEN: 'hf_token', + ANTHROPIC_MODEL: 'openai/gpt-oss-120b:fastest', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('generic-chat-completion-api'); + expect(result?.profileName).toBe('hf'); + }); + + it('detects official OpenAI-style endpoints', () => { + const result = resolveOpenAICompatProfileConfig('openai', '/tmp/openai.settings.json', { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-openai', + ANTHROPIC_MODEL: 'gpt-4.1', + CCS_OPENAI_PROXY_INSECURE: 'true', + }); + + expect(result?.provider).toBe('openai'); + expect(result?.model).toBe('gpt-4.1'); + expect(result?.insecure).toBe(true); + }); + + it('ignores Anthropic-compatible profiles', () => { + const result = resolveOpenAICompatProfileConfig('glm', '/tmp/glm.settings.json', { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'glm-key', + ANTHROPIC_MODEL: 'glm-5', + }); + + expect(result).toBeNull(); + }); + + it('prefers OpenAI-compatible URL inference over stale anthropic provider hints', () => { + const result = resolveOpenAICompatProfileConfig('q', '/tmp/q.settings.json', { + ANTHROPIC_BASE_URL: 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + ANTHROPIC_AUTH_TOKEN: 'q-token', + ANTHROPIC_MODEL: 'qwen3.6-plus', + CCS_DROID_PROVIDER: 'anthropic', + }); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('generic-chat-completion-api'); + expect(result?.model).toBe('qwen3.6-plus'); + }); +}); diff --git a/tests/unit/proxy/request-router.test.ts b/tests/unit/proxy/request-router.test.ts new file mode 100644 index 00000000..b1650084 --- /dev/null +++ b/tests/unit/proxy/request-router.test.ts @@ -0,0 +1,204 @@ +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 { resolveProxyRequestRoute } from '../../../src/proxy/request-router'; +import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; +import { loadSettings } from '../../../src/utils/config-manager'; + +let originalCcsHome: string | undefined; +let tempDir: string; + +function writeSettings(profileName: string, env: Record): string { + const settingsPath = path.join(tempDir, '.ccs', `${profileName}.settings.json`); + fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2), 'utf8'); + return settingsPath; +} + +function buildProfile(profileName: string) { + const settingsPath = path.join(tempDir, '.ccs', `${profileName}.settings.json`); + const profile = resolveOpenAICompatProfileConfig( + profileName, + settingsPath, + loadSettings(settingsPath).env || {} + ); + expect(profile).toBeTruthy(); + return profile!; +} + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-router-')); + fs.mkdirSync(path.join(tempDir, '.ccs'), { recursive: true }); +}); + +afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('resolveProxyRequestRoute', () => { + beforeEach(() => { + process.env.CCS_HOME = tempDir; + + const profiles = { + hf: writeSettings('hf', { + ANTHROPIC_BASE_URL: 'https://router.huggingface.co/v1', + ANTHROPIC_AUTH_TOKEN: 'hf_token', + ANTHROPIC_MODEL: 'hf-default', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }), + deepseek: writeSettings('deepseek', { + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/v1', + ANTHROPIC_AUTH_TOKEN: 'deepseek_token', + ANTHROPIC_MODEL: 'deepseek-chat', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }), + thinker: writeSettings('thinker', { + ANTHROPIC_BASE_URL: 'https://thinking.example.com/v1', + ANTHROPIC_AUTH_TOKEN: 'think_token', + ANTHROPIC_MODEL: 'deepseek-reasoner', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }), + search: writeSettings('search', { + ANTHROPIC_BASE_URL: 'https://search.example.com/v1', + ANTHROPIC_AUTH_TOKEN: 'search_token', + ANTHROPIC_MODEL: 'sonar-pro', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }), + background: writeSettings('background', { + ANTHROPIC_BASE_URL: 'https://background.example.com/v1', + ANTHROPIC_AUTH_TOKEN: 'background_token', + ANTHROPIC_MODEL: 'qwen-small', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }), + long: writeSettings('long', { + ANTHROPIC_BASE_URL: 'https://long.example.com/v1', + ANTHROPIC_AUTH_TOKEN: 'long_token', + ANTHROPIC_MODEL: 'gemini-2.5-pro', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }), + }; + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify( + { + profiles, + proxy: { + routing: { + default: 'hf:hf-default', + background: 'background:qwen-small', + think: 'thinker:deepseek-reasoner', + longContext: 'long:gemini-2.5-pro', + longContextThreshold: 10, + webSearch: 'search:sonar-pro', + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + }); + + it('uses explicit profile:model selectors', () => { + const route = resolveProxyRequestRoute(buildProfile('hf'), { + model: 'deepseek:deepseek-reasoner', + stream: true, + messages: [{ role: 'user', content: 'plan this' }], + }); + + expect(route.profile.profileName).toBe('deepseek'); + expect(route.model).toBe('deepseek-reasoner'); + expect(route.source).toBe('explicit-profile'); + }); + + it('matches plain model ids to the profile that owns them', () => { + const route = resolveProxyRequestRoute(buildProfile('hf'), { + model: 'deepseek-chat', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }); + + expect(route.profile.profileName).toBe('deepseek'); + expect(route.model).toBe('deepseek-chat'); + expect(route.source).toBe('profile-model-match'); + }); + + it('does not fuzzy-match plain model ids', () => { + const route = resolveProxyRequestRoute(buildProfile('hf'), { + model: 'deepseek-chat-v2', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }); + expect(route.profile.profileName).toBe('hf'); + expect(route.source).toBe('request-model'); + }); + + it('routes thinking requests through the configured think scenario', () => { + const route = resolveProxyRequestRoute(buildProfile('hf'), { + model: 'hf-default', + stream: true, + reasoning_effort: 'high', + reasoning: { enabled: true, effort: 'high' }, + messages: [{ role: 'user', content: 'work this out carefully' }], + }); + + expect(route.profile.profileName).toBe('thinker'); + expect(route.model).toBe('deepseek-reasoner'); + expect(route.scenario).toBe('think'); + expect(route.source).toBe('scenario'); + }); + + it('routes long-context requests through the configured longContext scenario', () => { + const route = resolveProxyRequestRoute(buildProfile('hf'), { + model: 'hf-default', + stream: true, + messages: [{ role: 'user', content: 'x'.repeat(120) }], + }); + + expect(route.profile.profileName).toBe('long'); + expect(route.model).toBe('gemini-2.5-pro'); + expect(route.scenario).toBe('longContext'); + expect(route.estimatedTokens).toBeGreaterThan(10); + }); + + it('routes web_search tool requests through the configured webSearch scenario', () => { + const route = resolveProxyRequestRoute(buildProfile('hf'), { + model: 'hf-default', + stream: true, + messages: [{ role: 'user', content: 'search the docs' }], + tools: [ + { + type: 'function', + function: { + name: 'web_search', + parameters: { type: 'object' }, + }, + }, + ], + }); + + expect(route.profile.profileName).toBe('search'); + expect(route.model).toBe('sonar-pro'); + expect(route.scenario).toBe('webSearch'); + }); + + it('routes haiku requests through the configured background scenario', () => { + const route = resolveProxyRequestRoute(buildProfile('hf'), { + model: 'claude-3-haiku', + stream: true, + messages: [{ role: 'user', content: 'run this in the background' }], + }); + + expect(route.profile.profileName).toBe('background'); + expect(route.model).toBe('qwen-small'); + expect(route.scenario).toBe('background'); + }); +}); diff --git a/tests/unit/proxy/transformers/request-transformer.test.ts b/tests/unit/proxy/transformers/request-transformer.test.ts new file mode 100644 index 00000000..25687c20 --- /dev/null +++ b/tests/unit/proxy/transformers/request-transformer.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'bun:test'; +import { ProxyRequestTransformer } from '../../../../src/proxy/transformers/request-transformer'; + +describe('ProxyRequestTransformer', () => { + it('translates Anthropic messages into OpenAI-compatible chat payloads', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + model: 'claude-sonnet-4.5', + stream: true, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Find release notes' }] }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'release' } }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'v7.69.1' }], + }, + ], + thinking: { type: 'enabled', budget_tokens: 9000 }, + max_tokens: 1024, + temperature: 0.2, + top_p: 0.9, + stop_sequences: ['STOP'], + metadata: { trace: 'abc' }, + }); + + expect(result.stream).toBe(true); + expect(result.reasoning_effort).toBe('high'); + expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); + expect(result.max_tokens).toBe(1024); + expect(result.temperature).toBe(0.2); + expect(result.top_p).toBe(0.9); + expect(result.stop).toEqual(['STOP']); + expect(result.metadata).toEqual({ trace: 'abc' }); + expect(result.messages[0]).toEqual({ role: 'user', content: 'Find release notes' }); + expect(result.messages[1]?.tool_calls?.[0]?.function.name).toBe('search'); + expect(result.messages[2]).toEqual({ + role: 'tool', + tool_call_id: 'toolu_1', + content: 'v7.69.1', + }); + }); + + it('translates base64 image blocks into OpenAI image_url parts', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe this image' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'ZmFrZS1pbWFnZS1ieXRlcw==', + }, + }, + ], + }, + ], + }); + + expect(result.messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe this image' }, + { + type: 'image_url', + image_url: { + url: 'data:image/png;base64,ZmFrZS1pbWFnZS1ieXRlcw==', + }, + }, + ], + }, + ]); + }); + + it('drops malformed optional fields but preserves the translated core request', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 'bad', + temperature: 'bad', + top_p: 'bad', + stop_sequences: ['A', 1], + metadata: 'bad', + }); + + expect(result.messages).toEqual([{ role: 'user', content: 'hello' }]); + expect(result.max_tokens).toBeUndefined(); + expect(result.temperature).toBeUndefined(); + expect(result.top_p).toBeUndefined(); + expect(result.stop).toEqual(['A']); + expect(result.metadata).toBeUndefined(); + }); +}); diff --git a/tests/unit/proxy/transformers/sse-stream-transformer.test.ts b/tests/unit/proxy/transformers/sse-stream-transformer.test.ts new file mode 100644 index 00000000..9faebabc --- /dev/null +++ b/tests/unit/proxy/transformers/sse-stream-transformer.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'bun:test'; +import { createAnthropicProxyResponse } from '../../../../src/proxy/transformers/sse-stream-transformer'; + +describe('proxy SSE stream transformer', () => { + it('converts OpenAI JSON into Anthropic message JSON', async () => { + const response = new Response( + JSON.stringify({ + id: 'chatcmpl_1', + model: 'claude-sonnet-4.5', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Here is the result.', + reasoning_content: 'Need to call the tool first.', + tool_calls: [ + { + id: 'toolu_2', + type: 'function', + function: { name: 'search', arguments: '{"q":"proxy"}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + + const transformed = await createAnthropicProxyResponse(response); + const body = (await transformed.json()) as { + type: string; + stop_reason: string; + content: Array<{ type: string; thinking?: string; name?: string }>; + }; + + expect(body.type).toBe('message'); + expect(body.stop_reason).toBe('tool_use'); + expect(body.content.map((block) => block.type)).toEqual(['thinking', 'text', 'tool_use']); + expect(body.content[0]?.thinking).toContain('Need to call the tool first'); + expect(body.content[2]?.name).toBe('search'); + }); + + it('converts OpenAI SSE chunks into Anthropic SSE events', async () => { + const openAISse = [ + 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}\n\n', + 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}\n\n', + 'data: [DONE]\n\n', + ].join(''); + + const transformed = await createAnthropicProxyResponse( + new Response(openAISse, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: message_start'); + expect(body).toContain('event: content_block_start'); + expect(body).toContain('"type":"text_delta"'); + expect(body).toContain('event: message_stop'); + }); +}); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 240769c6..730d2f57 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -360,6 +360,95 @@ describe('SharedManager', () => { expect(reconciled.stale).toBeUndefined(); }); + it('does not register transient marketplace directories left behind by interrupted auto-updates', () => { + // Regression: CCS used to write bare { installLocation } entries for marketplace + // directories with no registry record. Claude Code requires source + lastUpdated, + // so those entries corrupted known_marketplaces.json and broke /plugin. + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + // Simulate Claude Code leaving rename-dance temp dirs behind in both the + // global claude dir and the instance dir (discoverMarketplaceEntries scans + // each independently). + for (const suffix of ['.staging', '.bak']) { + fs.mkdirSync(marketplacePath(claudeDir(), `claude-plugins-official${suffix}`), { + recursive: true, + }); + fs.mkdirSync(marketplacePath(instancePath, `claude-plugins-official${suffix}`), { + recursive: true, + }); + } + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + const global = readJson(globalRegistryPath) as Record; + expect(global['claude-plugins-official.staging']).toBeUndefined(); + expect(global['claude-plugins-official.bak']).toBeUndefined(); + + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + const instance = readJson(instanceRegistryPath) as Record; + expect(instance['claude-plugins-official.staging']).toBeUndefined(); + expect(instance['claude-plugins-official.bak']).toBeUndefined(); + }); + + it('removes registry entries whose physical marketplace directory no longer exists', () => { + // Regression guard: buildMarketplaceRegistryContent merges JSON sources then + // cross-checks against discoveredEntries. Any name in the merged registry that + // has no matching directory on disk must be pruned so stale entries don't + // accumulate across marketplace uninstalls or renames. + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + // Write a registry entry for a marketplace that has no physical directory. + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'vanished-marketplace': { + source: { type: 'github', repo: 'example/vanished' }, + lastUpdated: '2024-01-01T00:00:00.000Z', + installLocation: marketplacePath(claudeDir(), 'vanished-marketplace'), + }, + }); + // Intentionally do NOT create the physical directory — simulate an uninstalled + // marketplace whose registry entry was not cleaned up. + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const global = readJson(globalRegistryPath) as Record; + expect(global['vanished-marketplace']).toBeUndefined(); + + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + const instance = readJson(instanceRegistryPath) as Record; + expect(instance['vanished-marketplace']).toBeUndefined(); + }); + + it('drops malformed marketplace entries even when the payload directory still exists', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + fs.mkdirSync(marketplacePath(claudeDir(), 'claude-code-plugins'), { recursive: true }); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'claude-code-plugins': 'bad-entry', + }); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const global = readJson(globalRegistryPath) as Record; + expect(global['claude-code-plugins']).toBeUndefined(); + + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + const instance = readJson(instanceRegistryPath) as Record; + expect(instance['claude-code-plugins']).toBeUndefined(); + }); + it('warns and skips malformed marketplace registries while keeping valid sources', () => { const manager = new SharedManager(); const instancePath = instanceDir('work'); diff --git a/tests/unit/targets/codex-detector.test.ts b/tests/unit/targets/codex-detector.test.ts index bf4b0f4a..b0bb1e0d 100644 --- a/tests/unit/targets/codex-detector.test.ts +++ b/tests/unit/targets/codex-detector.test.ts @@ -73,7 +73,7 @@ describe('codex-detector', () => { execFileSyncSpy.mockRestore(); }); - it('prefers a sibling PowerShell wrapper over cmd when Windows PATH only exposes codex.cmd', () => { + it('keeps the cmd wrapper when Windows PATH exposes codex.cmd and a sibling ps1 also exists', () => { const fakeCmdCodex = path.join(tmpDir, 'codex.cmd'); const fakePsCodex = path.join(tmpDir, 'codex.ps1'); fs.writeFileSync(fakeCmdCodex, ''); @@ -82,7 +82,21 @@ describe('codex-detector', () => { const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => `${fakeCmdCodex}\n`); - expect(detectCodexCli()).toBe(fakePsCodex); + expect(detectCodexCli()).toBe(fakeCmdCodex); + + execSyncSpy.mockRestore(); + }); + + it('replaces a Windows PowerShell wrapper with a sibling cmd shim when PATH returns codex.ps1', () => { + const fakeCmdCodex = path.join(tmpDir, 'codex.cmd'); + const fakePsCodex = path.join(tmpDir, 'codex.ps1'); + fs.writeFileSync(fakeCmdCodex, ''); + fs.writeFileSync(fakePsCodex, ''); + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => `${fakePsCodex}\n`); + + expect(detectCodexCli()).toBe(fakeCmdCodex); execSyncSpy.mockRestore(); }); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 1afdb956..bfdb1212 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -3,6 +3,7 @@ import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; interface RunResult { status: number | null; @@ -193,6 +194,47 @@ process.exit(0); ]); }); + it('skips Codex browser MCP overrides when browser tooling is disabled in config', () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: false, + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: false, + }, + }; + }); + + const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + }); + + expect(result.status).toBe(0); + const calls = readLoggedCodexCalls(codexArgsLogPath); + expect(calls).toEqual([['fix failing tests']]); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => { if (process.platform === 'win32') return; diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index 2ee938a3..93dd4552 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -4,6 +4,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; @@ -245,4 +246,83 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); }); + + it('uses config-backed browser attach settings when env overrides are absent', async () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/config-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const port = await waitForMockDevtoolsPort(mockServerPortPath); + await waitForDevtoolsVersionEndpoint(port); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/config-target`, + 'utf8' + ); + + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: browserProfileDir, + devtools_port: Number.parseInt(port, 10), + }, + codex: { + enabled: true, + }, + }; + }); + + const result = runCcs(['default', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-target'); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); }); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index 61052620..b94846fc 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -3,6 +3,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; @@ -196,4 +197,89 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); }); + + it('uses config-backed browser attach settings for settings-profile launches', async () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/config-settings-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const startDeadline = Date.now() + 5000; + while (!fs.existsSync(mockServerPortPath)) { + if (Date.now() > startDeadline) { + throw new Error('Timed out waiting for mock DevTools server to start'); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/config-settings-target`, + 'utf8' + ); + + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: browserProfileDir, + devtools_port: Number.parseInt(port, 10), + }, + codex: { + enabled: true, + }, + }; + }); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-settings-target'); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); }); diff --git a/tests/unit/ui/provider-config.test.ts b/tests/unit/ui/provider-config.test.ts new file mode 100644 index 00000000..fe85c2aa --- /dev/null +++ b/tests/unit/ui/provider-config.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'bun:test'; +import { + getDeviceCodeProviderInstruction, + getProviderDisplayName, +} from '../../../ui/src/lib/provider-config'; + +describe('provider-config fallbacks', () => { + it('uses translated fallback copy for unknown providers', () => { + expect(getProviderDisplayName('not-a-provider')).toBe('Unknown provider: not-a-provider'); + expect(getProviderDisplayName(undefined)).toBe('Unknown provider: unknown'); + }); + + it('uses translated default device-code guidance for unknown providers', () => { + expect(getDeviceCodeProviderInstruction('not-a-provider')).toBe( + 'Complete the authorization in your browser.' + ); + }); +}); diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts new file mode 100644 index 00000000..a15cd461 --- /dev/null +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mutateUnifiedConfig } from '../../../../src/config/unified-config-loader'; +import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse'; +import { getBrowserStatus } from '../../../../src/utils/browser/browser-status'; +import * as codexDetector from '../../../../src/targets/codex-detector'; + +describe('browser status', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalBrowserUserDataDir: string | undefined; + let originalBrowserProfileDir: string | undefined; + let originalBrowserDevtoolsPort: string | undefined; + + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-status-')); + originalCcsHome = process.env.CCS_HOME; + originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + + process.env.CCS_HOME = tempHome; + delete process.env.CCS_BROWSER_USER_DATA_DIR; + delete process.env.CCS_BROWSER_PROFILE_DIR; + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalBrowserUserDataDir !== undefined) { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } else { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } + + if (originalBrowserProfileDir !== undefined) { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } else { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } + + if (originalBrowserDevtoolsPort !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } + + rmSync(tempHome, { recursive: true, force: true }); + }); + + it('returns a disabled Claude lane with the recommended managed user-data dir by default', async () => { + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(status.claude).toMatchObject({ + enabled: false, + state: 'disabled', + source: 'config', + effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtoolsPort: 9222, + managedMcpServerName: 'ccs-browser', + }); + expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222'); + expect(status.codex).toMatchObject({ + enabled: true, + state: 'enabled', + serverName: 'ccs_browser', + supportsConfigOverrides: true, + }); + } finally { + codexSpy.mockRestore(); + } + }); + + it('prefers CCS_BROWSER_USER_DATA_DIR over config when an env override is present', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '/config-browser', + devtools_port: 9333, + }, + codex: { + enabled: true, + }, + }; + }); + process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser'; + process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444'; + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ + CCS_BROWSER_USER_DATA_DIR: '/env-browser', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9444', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9444', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(status.claude).toMatchObject({ + enabled: true, + state: 'ready', + source: 'CCS_BROWSER_USER_DATA_DIR', + effectiveUserDataDir: '/env-browser', + devtoolsPort: 9444, + }); + expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('9444'); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); + + it('reports browser_not_running when attach metadata is missing', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '/tmp/browser-profile', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, + }; + }); + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue( + new Error('Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort') + ); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(status.claude.state).toBe('browser_not_running'); + expect(status.claude.detail).toContain('DevToolsActivePort'); + expect(status.claude.nextStep).toContain('--remote-debugging-port=9222'); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); + + it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => { + process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser'; + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ + CCS_BROWSER_USER_DATA_DIR: '/legacy-browser', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '50123', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:50123', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/legacy', + }); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ + profileDir: '/legacy-browser', + devtoolsPort: undefined, + }); + expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123'); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); + + it('always forwards an explicit port for config-backed browser attach sessions', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '/tmp/config-browser', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, + }; + }); + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ + CCS_BROWSER_USER_DATA_DIR: '/tmp/config-browser', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9222', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/config', + }); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + await getBrowserStatus(); + + expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ + profileDir: '/tmp/config-browser', + devtoolsPort: '9222', + }); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/utils/browser/platform.test.ts b/tests/unit/utils/browser/platform.test.ts new file mode 100644 index 00000000..2c6ab7a0 --- /dev/null +++ b/tests/unit/utils/browser/platform.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'bun:test'; +import { getNodePlatformKey } from '../../../../src/utils/browser/platform'; + +describe('browser platform helper', () => { + it('maps darwin explicitly', () => { + expect(getNodePlatformKey('darwin')).toBe('darwin'); + }); + + it('maps win32 explicitly', () => { + expect(getNodePlatformKey('win32')).toBe('win32'); + }); + + it('falls back to linux for other node platforms', () => { + expect(getNodePlatformKey('linux')).toBe('linux'); + expect(getNodePlatformKey('freebsd')).toBe('linux'); + }); +}); diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts index 7525e7d3..246a0d3f 100644 --- a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -82,6 +82,21 @@ describe('image-analysis-backend-resolver', () => { expect(status.resolutionSource).toBe('profile-backend'); }); + it('treats cliproxy cursor as a provider-backed profile rather than a legacy bridge alias', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'cursor', + profileType: 'cliproxy', + cliproxyProvider: 'cursor', + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.backendId).toBe('cursor'); + expect(status.resolutionSource).toBe('cliproxy-provider'); + expect(status.reason).toContain('no image-analysis model configured'); + }); + it('uses the fallback backend for an unmapped third-party settings profile', () => { const status = resolveImageAnalysisStatus( { diff --git a/tests/unit/web-server/api-routes-remote-write-guard.test.ts b/tests/unit/web-server/api-routes-remote-write-guard.test.ts index 83575173..85d4d6a6 100644 --- a/tests/unit/web-server/api-routes-remote-write-guard.test.ts +++ b/tests/unit/web-server/api-routes-remote-write-guard.test.ts @@ -149,68 +149,72 @@ describe('api-routes remote write guard', () => { }); }); - it('allows remote writes again when dashboard auth is enabled', async () => { - const password = 'testpassword123'; - process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; - process.env.CCS_DASHBOARD_USERNAME = 'admin'; - process.env.CCS_DASHBOARD_PASSWORD_HASH = await bcrypt.hash(password, 10); + it( + 'allows remote writes again when dashboard auth is enabled', + async () => { + const password = 'testpassword123'; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + process.env.CCS_DASHBOARD_USERNAME = 'admin'; + process.env.CCS_DASHBOARD_PASSWORD_HASH = await bcrypt.hash(password, 4); - const authApp = express(); - authApp.use(express.json()); - authApp.use((req, _res, next) => { - Object.defineProperty(req.socket, 'remoteAddress', { - value: forcedRemoteAddress, - configurable: true, + const authApp = express(); + authApp.use(express.json()); + authApp.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); }); - next(); - }); - authApp.use(createSessionMiddleware()); - authApp.use(authMiddleware); - authApp.use('/api', apiRoutes); + authApp.use(createSessionMiddleware()); + authApp.use(authMiddleware); + authApp.use('/api', apiRoutes); - const authServer = await new Promise((resolve, reject) => { - const instance = authApp.listen(0, '127.0.0.1'); - instance.once('error', reject); - instance.once('listening', () => resolve(instance)); - }); + const authServer = await new Promise((resolve, reject) => { + const instance = authApp.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); - const address = authServer.address(); - if (!address || typeof address === 'string') { - throw new Error('Unable to resolve auth-enabled test server port'); - } - const authBaseUrl = `http://127.0.0.1:${address.port}`; + const address = authServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve auth-enabled test server port'); + } + const authBaseUrl = `http://127.0.0.1:${address.port}`; - const loginResponse = await fetch(`${authBaseUrl}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: 'admin', - password, - }), - }); - const cookie = loginResponse.headers.get('set-cookie'); + const loginResponse = await fetch(`${authBaseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: 'admin', + password, + }), + }); + const cookie = loginResponse.headers.get('set-cookie'); - expect(loginResponse.status).toBe(200); - expect(cookie).toBeTruthy(); + expect(loginResponse.status).toBe(200); + expect(cookie).toBeTruthy(); - const response = await fetch(`${authBaseUrl}/api/profiles`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Cookie: cookie as string, - }, - body: JSON.stringify({ - name: 'demo', - baseUrl: 'https://api.example.com', - apiKey: 'token', - }), - }); + const response = await fetch(`${authBaseUrl}/api/profiles`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: cookie as string, + }, + body: JSON.stringify({ + name: 'demo', + baseUrl: 'https://api.example.com', + apiKey: 'token', + }), + }); - expect(response.status).toBe(201); + expect(response.status).toBe(201); - await new Promise((resolve) => authServer.close(() => resolve())); + await new Promise((resolve) => authServer.close(() => resolve())); - delete process.env.CCS_DASHBOARD_USERNAME; - delete process.env.CCS_DASHBOARD_PASSWORD_HASH; - }); + delete process.env.CCS_DASHBOARD_USERNAME; + delete process.env.CCS_DASHBOARD_PASSWORD_HASH; + }, + 15000 + ); }); diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts new file mode 100644 index 00000000..bc6e0305 --- /dev/null +++ b/tests/unit/web-server/browser-routes.test.ts @@ -0,0 +1,214 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import { mkdtempSync, rmSync } from 'node:fs'; +import type { Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import browserRoutes from '../../../src/web-server/routes/browser-routes'; +import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader'; + +describe('browser routes', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalDashboardAuthEnabled: string | undefined; + let forcedRemoteAddress = '127.0.0.1'; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); + app.use('/api/browser', browserRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-')); + originalCcsHome = process.env.CCS_HOME; + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + process.env.CCS_HOME = tempHome; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '127.0.0.1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } + + rmSync(tempHome, { recursive: true, force: true }); + }); + + it('blocks remote access when dashboard auth is disabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + + const response = await fetch(`${baseUrl}/api/browser`); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Browser endpoints require localhost access when dashboard auth is disabled.', + }); + }); + + it('returns the default browser config and status payload', async () => { + const response = await fetch(`${baseUrl}/api/browser`); + expect(response.status).toBe(200); + const payload = await response.json(); + + expect(payload.config).toMatchObject({ + claude: { + enabled: false, + userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtoolsPort: 9222, + }, + codex: { + enabled: true, + }, + }); + expect(payload.status.claude).toMatchObject({ + state: 'disabled', + managedMcpServerName: 'ccs-browser', + }); + expect(payload.status.codex).toMatchObject({ + enabled: true, + serverName: 'ccs_browser', + }); + }); + + it('updates the saved browser config through the dashboard route', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + enabled: true, + userDataDir: '/tmp/ccs-browser', + devtoolsPort: 9333, + }, + codex: { + enabled: false, + }, + }), + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.browser.config).toMatchObject({ + claude: { + enabled: true, + userDataDir: '/tmp/ccs-browser', + devtoolsPort: 9333, + }, + codex: { + enabled: false, + }, + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.browser).toMatchObject({ + claude: { + enabled: true, + user_data_dir: '/tmp/ccs-browser', + devtools_port: 9333, + }, + codex: { + enabled: false, + }, + }); + }); + + it('treats a blank user-data directory as a reset to the recommended path', async () => { + const firstResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + enabled: true, + userDataDir: '/tmp/ccs-browser-custom', + devtoolsPort: 9333, + }, + }), + }); + + expect(firstResponse.status).toBe(200); + + const resetResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + userDataDir: ' ', + }, + }), + }); + + expect(resetResponse.status).toBe(200); + const payload = await resetResponse.json(); + expect(payload.browser.config.claude).toMatchObject({ + enabled: true, + userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtoolsPort: 9333, + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.browser).toMatchObject({ + claude: { + enabled: true, + user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtools_port: 9333, + }, + }); + }); + + it('rejects invalid DevTools ports at the route boundary', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + devtoolsPort: 0, + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.', + }); + }); +}); diff --git a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts index fe8a70c2..a583ea28 100644 --- a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts @@ -6,6 +6,11 @@ import * as path from 'path'; import * as http from 'http'; import type { Server } from 'http'; import cliproxyAuthRoutes from '../../../src/web-server/routes/cliproxy-auth-routes'; +import { + clearQuotaCache, + getCachedQuota, + setCachedQuota, +} from '../../../src/cliproxy/quota-response-cache'; import { restoreFetch, mockFetch } from '../../mocks'; describe('cliproxy-auth-routes manual callback nickname persistence', () => { @@ -49,6 +54,7 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { afterEach(() => { restoreFetch(); + clearQuotaCache(); if (originalCcsHome === undefined) { delete process.env.CCS_HOME; @@ -564,4 +570,62 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { expect(registry.providers.codex.accounts['new@example.com']?.email).toBe('new@example.com'); }); + + it('clears stale Claude quota cache after polling auth success', async () => { + mockFetch([ + { + url: /\/v0\/management\/anthropic-auth-url\?is_webui=true$/, + response: { + auth_url: 'https://auth.example.com/authorize?state=state-claude-cache-clear', + state: 'state-claude-cache-clear', + }, + }, + { + url: /\/v0\/management\/get-auth-status\?state=state-claude-cache-clear$/, + response: { status: 'ok' }, + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/claude/start-url', {}); + expect(startResponse.status).toBe(200); + + const accountId = 'claude-team@example.com'; + setCachedQuota('claude', accountId, { + success: false, + error: 'Authentication required for policy limits', + needsReauth: true, + }); + expect(getCachedQuota('claude', accountId)).not.toBeNull(); + + const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(tokenDir, { recursive: true }); + fs.writeFileSync( + path.join(tokenDir, 'claude-claude-team@example.com.json'), + JSON.stringify({ + type: 'claude', + email: accountId, + access_token: 'fresh-token', + refresh_token: 'refresh-token', + expired: '2099-01-01T00:00:00.000Z', + }), + 'utf8' + ); + + const statusResponse = await getJson( + '/api/cliproxy/auth/claude/status?state=state-claude-cache-clear' + ); + + expect(statusResponse.status).toBe(200); + expect(statusResponse.body).toEqual({ + status: 'ok', + account: { + id: accountId, + email: accountId, + nickname: 'claude-team', + provider: 'claude', + isDefault: true, + }, + }); + expect(getCachedQuota('claude', accountId)).toBeNull(); + }); }); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 703c2870..8c21fb73 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -13,6 +13,15 @@ describe('cliproxy-auth-routes start-url guard', () => { ); expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow"); expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow"); + expect(getStartUrlUnsupportedReason('cursor')).toContain( + "Provider 'cursor' uses Device Code flow" + ); + expect(getStartUrlUnsupportedReason('codebuddy')).toContain( + "Provider 'codebuddy' uses Device Code flow" + ); + expect(getStartUrlUnsupportedReason('kilo')).toContain( + "Provider 'kilo' uses Device Code flow" + ); }); it('allows Kiro social methods on start-url', () => { @@ -36,6 +45,7 @@ describe('cliproxy-auth-routes start-url guard', () => { expect(getStartUrlUnsupportedReason('gemini')).toBeNull(); expect(getStartUrlUnsupportedReason('codex')).toBeNull(); expect(getStartUrlUnsupportedReason('claude')).toBeNull(); + expect(getStartUrlUnsupportedReason('gitlab')).toBeNull(); }); }); @@ -87,6 +97,7 @@ describe('cliproxy-auth-routes start failure messaging', () => { it('keeps generic failure text for other providers', () => { expect(getStartAuthFailureMessage('gemini')).toBe('Authentication failed or was cancelled'); expect(getStartAuthFailureMessage('kiro')).toBe('Authentication failed or was cancelled'); + expect(getStartAuthFailureMessage('gitlab')).toBe('Authentication failed or was cancelled'); }); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts index 84cba3c9..538152a4 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -137,7 +137,7 @@ describe('cliproxy-stats-routes model update canonicalization', () => { ANTHROPIC_MODEL: 'gpt-5.3-codex', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini', }); const response = await fetch(`${baseUrl}/api/cliproxy/models/codex`, { @@ -156,7 +156,7 @@ describe('cliproxy-stats-routes model update canonicalization', () => { expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); }); it('canonicalizes legacy iflow model IDs to supported upstream IDs', async () => { diff --git a/tests/unit/web-server/codex-routes.test.ts b/tests/unit/web-server/codex-routes.test.ts index 00166fcc..55009559 100644 --- a/tests/unit/web-server/codex-routes.test.ts +++ b/tests/unit/web-server/codex-routes.test.ts @@ -54,65 +54,73 @@ afterAll(async () => { }); describe('codex routes', () => { - it('returns the current raw config snapshot from PATCH /config/patch', async () => { - const res = await fetch(`${baseUrl}/api/codex/config/patch`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - kind: 'top-level', - values: { - model: 'gpt-5.4', - sandboxMode: 'workspace-write', - }, - }), - }); + it( + 'returns the current raw config snapshot from PATCH /config/patch', + async () => { + const res = await fetch(`${baseUrl}/api/codex/config/patch`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'top-level', + values: { + model: 'gpt-5.4', + sandboxMode: 'workspace-write', + }, + }), + }); - expect(res.status).toBe(200); + expect(res.status).toBe(200); - const json = (await res.json()) as { - success: boolean; - exists: boolean; - mtime: number; - rawText: string; - config: Record | null; - parseError: string | null; - readError: string | null; - }; + const json = (await res.json()) as { + success: boolean; + exists: boolean; + mtime: number; + rawText: string; + config: Record | null; + parseError: string | null; + readError: string | null; + }; - expect(json.success).toBe(true); - expect(json.exists).toBe(true); - expect(json.mtime).toBeGreaterThan(0); - expect(json.parseError).toBeNull(); - expect(json.readError).toBeNull(); - expect(json.rawText).toContain('model = "gpt-5.4"'); - expect(json.rawText).toContain('sandbox_mode = "workspace-write"'); - expect(json.config?.model).toBe('gpt-5.4'); + expect(json.success).toBe(true); + expect(json.exists).toBe(true); + expect(json.mtime).toBeGreaterThan(0); + expect(json.parseError).toBeNull(); + expect(json.readError).toBeNull(); + expect(json.rawText).toContain('model = "gpt-5.4"'); + expect(json.rawText).toContain('sandbox_mode = "workspace-write"'); + expect(json.config?.model).toBe('gpt-5.4'); - const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - expect(written).toBe(json.rawText); - }); + const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + expect(written).toBe(json.rawText); + }, + 10000 + ); - it('returns 409 when PATCH /config/patch receives a stale expectedMtime', async () => { - const configPath = path.join(codexHome, 'config.toml'); - fs.writeFileSync(configPath, 'model = "gpt-5.3-codex"\n'); + it( + 'returns 409 when PATCH /config/patch receives a stale expectedMtime', + async () => { + const configPath = path.join(codexHome, 'config.toml'); + fs.writeFileSync(configPath, 'model = "gpt-5.3-codex"\n'); - const res = await fetch(`${baseUrl}/api/codex/config/patch`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - kind: 'feature', - feature: 'multi_agent', - enabled: true, - expectedMtime: 1, - }), - }); + const res = await fetch(`${baseUrl}/api/codex/config/patch`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'feature', + feature: 'multi_agent', + enabled: true, + expectedMtime: 1, + }), + }); - expect(res.status).toBe(409); + expect(res.status).toBe(409); - const json = (await res.json()) as { error: string; mtime: number }; - expect(json.error).toContain('File modified externally.'); - expect(json.mtime).toBeGreaterThan(0); - }); + const json = (await res.json()) as { error: string; mtime: number }; + expect(json.error).toContain('File modified externally.'); + expect(json.mtime).toBeGreaterThan(0); + }, + 10000 + ); it('allows PATCH /config/patch on an existing config.toml without expectedMtime', async () => { fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n'); diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index 2396422e..119a4020 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -131,18 +131,22 @@ describe('droid-dashboard-service', () => { expect(raw.rawText).toContain('invalid-json'); }); - it('includes structured docs links for fact-checking providers', async () => { - const diagnostics = await getDroidDashboardDiagnostics(); + it( + 'includes structured docs links for fact-checking providers', + async () => { + const diagnostics = await getDroidDashboardDiagnostics(); - expect(diagnostics.docsReference.links.length).toBeGreaterThan(0); - expect(diagnostics.docsReference.providerDocs.length).toBeGreaterThan(0); - expect(diagnostics.docsReference.links.every((link) => link.url.startsWith('https://'))).toBe( - true - ); - expect( - diagnostics.docsReference.providerDocs.some((doc) => doc.provider === 'anthropic') - ).toBe(true); - }); + expect(diagnostics.docsReference.links.length).toBeGreaterThan(0); + expect(diagnostics.docsReference.providerDocs.length).toBeGreaterThan(0); + expect(diagnostics.docsReference.links.every((link) => link.url.startsWith('https://'))).toBe( + true + ); + expect( + diagnostics.docsReference.providerDocs.some((doc) => doc.provider === 'anthropic') + ).toBe(true); + }, + 10000 + ); it('falls back to legacy config custom_models when settings customModels is absent', async () => { const settingsDir = path.join(testRoot, '.factory'); diff --git a/ui/bun.lock b/ui/bun.lock index d79f76ca..cd5c1000 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -8,6 +8,7 @@ "@hookform/resolvers": "^5.2.2", "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", @@ -28,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.38.0", "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", @@ -273,6 +275,8 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -735,6 +739,8 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -877,6 +883,10 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], + + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.12.4", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.7.0", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg=="], diff --git a/ui/package.json b/ui/package.json index a5a7ad53..872ab80e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,6 +22,7 @@ "@hookform/resolvers": "^5.2.2", "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", @@ -42,6 +43,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.38.0", "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", diff --git a/ui/public/assets/providers/codebuddy.png b/ui/public/assets/providers/codebuddy.png new file mode 100644 index 00000000..57c113ec Binary files /dev/null and b/ui/public/assets/providers/codebuddy.png differ diff --git a/ui/public/assets/providers/cursor.svg b/ui/public/assets/providers/cursor.svg new file mode 100644 index 00000000..f4a02f5f --- /dev/null +++ b/ui/public/assets/providers/cursor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/public/assets/providers/gitlab.svg b/ui/public/assets/providers/gitlab.svg new file mode 100644 index 00000000..394c9e01 --- /dev/null +++ b/ui/public/assets/providers/gitlab.svg @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/ui/public/assets/providers/kilo.png b/ui/public/assets/providers/kilo.png new file mode 100644 index 00000000..71c0dd2d Binary files /dev/null and b/ui/public/assets/providers/kilo.png differ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 16dc92ed..1ebba72f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'sonner'; import { queryClient } from '@/lib/query-client'; @@ -129,6 +129,10 @@ export default function App() { /> } + /> + }> diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index ec1c9f78..b8fcaa38 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -91,6 +91,9 @@ export function AddAccountDialog({ const [kiroIDCStartUrl, setKiroIDCStartUrl] = useState(''); const [kiroIDCRegion, setKiroIDCRegion] = useState(''); const [kiroIDCFlow, setKiroIDCFlow] = useState(DEFAULT_KIRO_IDC_FLOW); + const [gitlabAuthMode, setGitlabAuthMode] = useState<'oauth' | 'pat'>('oauth'); + const [gitlabBaseUrl, setGitlabBaseUrl] = useState(''); + const [gitlabPersonalAccessToken, setGitlabPersonalAccessToken] = useState(''); const { t } = useTranslation(); const wasAuthenticatingRef = useRef(false); const powerUserModeRequestIdRef = useRef(0); @@ -99,6 +102,7 @@ export function AddAccountDialog({ const kiroImportMutation = useKiroImport(); const isKiro = provider === 'kiro'; + const isGitLab = provider === 'gitlab'; const supportsPowerUserMode = provider === 'agy' || provider === 'gemini'; const requiresGeminiSafetyAcknowledgement = provider === 'gemini' && !powerUserModeEnabled; const requiresAgyResponsibilityFlow = provider === 'agy' && !powerUserModeEnabled; @@ -120,6 +124,8 @@ export function AddAccountDialog({ const nicknameTrimmed = nickname.trim(); const kiroIDCStartUrlTrimmed = kiroIDCStartUrl.trim(); const kiroIDCRegionTrimmed = kiroIDCRegion.trim(); + const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim(); + const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim(); const errorMessage = localError || authFlow.error; const fetchPowerUserModeState = useCallback(async (): Promise => { @@ -191,6 +197,9 @@ export function AddAccountDialog({ setKiroIDCStartUrl(''); setKiroIDCRegion(''); setKiroIDCFlow(DEFAULT_KIRO_IDC_FLOW); + setGitlabAuthMode('oauth'); + setGitlabBaseUrl(''); + setGitlabPersonalAccessToken(''); powerUserModeRequestIdRef.current += 1; powerUserModeLoadErrorShownRef.current = false; wasAuthenticatingRef.current = false; @@ -310,6 +319,10 @@ export function AddAccountDialog({ setLocalError('IDC Start URL is required for Kiro IAM Identity Center login.'); return; } + if (isGitLab && gitlabAuthMode === 'pat' && !gitlabPersonalAccessTokenTrimmed) { + setLocalError(t('addAccountDialog.gitlabPatRequired')); + return; + } wasAuthenticatingRef.current = true; authFlow.startAuth(provider, { nickname: nicknameTrimmed || undefined, @@ -317,8 +330,18 @@ export function AddAccountDialog({ kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined, kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined, kiroIDCFlow: isKiroIdc ? kiroIDCFlow : undefined, + gitlabAuthMode: isGitLab ? gitlabAuthMode : undefined, + gitlabBaseUrl: isGitLab && gitlabBaseUrlTrimmed ? gitlabBaseUrlTrimmed : undefined, + gitlabPersonalAccessToken: + isGitLab && gitlabAuthMode === 'pat' && gitlabPersonalAccessTokenTrimmed + ? gitlabPersonalAccessTokenTrimmed + : undefined, flowType: isKiro ? selectedKiroFlowType : undefined, - startEndpoint: isKiro ? selectedKiroStartEndpoint : undefined, + startEndpoint: isKiro + ? selectedKiroStartEndpoint + : isGitLab && gitlabAuthMode === 'pat' + ? 'start' + : undefined, riskAcknowledgement: requiresAgyResponsibilityFlow ? { version: ANTIGRAVITY_ACK_VERSION, @@ -512,6 +535,70 @@ export function AddAccountDialog({ )} + {isGitLab && !showAuthUI && ( +
+
+ + +

+ {t('addAccountDialog.gitlabAuthHint')} +

+
+ +
+ + { + setGitlabBaseUrl(e.target.value); + setLocalError(null); + }} + placeholder={t('addAccountDialog.gitlabUrlPlaceholder')} + disabled={isPending} + /> +

+ {t('addAccountDialog.gitlabUrlHint')} +

+
+ + {gitlabAuthMode === 'pat' && ( +
+ + { + setGitlabPersonalAccessToken(e.target.value); + setLocalError(null); + }} + placeholder={t('addAccountDialog.gitlabPatPlaceholder')} + disabled={isPending} + /> +

+ {t('addAccountDialog.gitlabPatHint')} api and{' '} + read_user scopes. +

+
+ )} +
+ )} + {/* Nickname input - only show before auth starts */} {!showAuthUI && (
diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index e1ecf92f..c018aaaf 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -6,6 +6,7 @@ import { AccountSurfaceCard } from '@/components/account/shared/account-surface- import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { getCodexIdentityBadge, type CodexIdentityBadge } from '@/lib/account-identity'; import { cn, formatQuotaPercent, @@ -13,7 +14,6 @@ import { getProviderResetTime, getQuotaFailureInfo, } from '@/lib/utils'; -import { formatAccountVariantPart } from '@/lib/account-identity'; import { GripVertical, Loader2, Pause, Play } from 'lucide-react'; import { useAccountQuota, @@ -28,6 +28,7 @@ import { AccountCardStats } from './account-card-stats'; import { cleanEmail } from './utils'; type Zone = 'left' | 'right' | 'top' | 'bottom'; +type AccountAudience = 'business' | 'free' | 'personal' | 'unknown'; const QUOTA_PROVIDER_ALIASES = [ 'antigravity', @@ -88,46 +89,124 @@ function getCompactQuotaColor(percentage: number) { return 'bg-red-500'; } -function getCodexPlanDetailLabel(quota: unknown): string | null { +function getCodexQuotaBadge(quota: unknown): CodexIdentityBadge { if (!quota || typeof quota !== 'object' || !('planType' in quota)) { - return null; + return { audience: 'unknown', label: null }; } const planType = (quota as { planType?: unknown }).planType; if (typeof planType !== 'string' || planType.trim().length === 0) { - return null; + return { audience: 'unknown', label: null }; } - return formatAccountVariantPart(planType); + if (planType === 'team') { + return { audience: 'business', label: 'Business' }; + } + + if (planType === 'free') { + return { audience: 'free', label: 'Free' }; + } + + if (planType === 'plus') { + return { audience: 'personal', label: 'Plus' }; + } + + if (planType === 'pro') { + return { audience: 'personal', label: 'Pro' }; + } + + return { audience: 'unknown', label: null }; +} + +function resolveCodexBadge( + identityBadge: CodexIdentityBadge, + quotaBadge: CodexIdentityBadge +): CodexIdentityBadge { + if (!quotaBadge.label) { + return identityBadge; + } + + if ( + quotaBadge.label === 'Business' || + quotaBadge.label === 'Plus' || + quotaBadge.label === 'Pro' + ) { + return quotaBadge; + } + + if (quotaBadge.label === 'Free' && identityBadge.label && identityBadge.label !== 'Free') { + return identityBadge; + } + + return quotaBadge; } function getVariantDetailLabel( variant: { - audience: string; + audience: AccountAudience; detailLabel?: string | null; compactDetailLabel?: string | null; }, quota?: unknown ) { - const codexPlanDetail = getCodexPlanDetailLabel(quota); - return variant.detailLabel ?? variant.compactDetailLabel ?? codexPlanDetail; + return resolveCodexBadge( + getCodexIdentityBadge({ + audience: variant.audience, + detailLabel: variant.detailLabel ?? null, + compactDetailLabel: variant.compactDetailLabel ?? null, + }), + getCodexQuotaBadge(quota) + ).label; +} + +function getVariantBadgeAudience( + variant: { + audience: AccountAudience; + detailLabel?: string | null; + compactDetailLabel?: string | null; + }, + quota?: unknown +) { + return resolveCodexBadge( + getCodexIdentityBadge({ + audience: variant.audience, + detailLabel: variant.detailLabel ?? null, + compactDetailLabel: variant.compactDetailLabel ?? null, + }), + getCodexQuotaBadge(quota) + ).audience; } function getVariantCompactDetailLabel( variant: { - audience: string; + audience: AccountAudience; compactDetailLabel?: string | null; detailLabel?: string | null; }, quota?: unknown ) { - const codexPlanDetail = getCodexPlanDetailLabel(quota); - return variant.compactDetailLabel ?? variant.detailLabel ?? codexPlanDetail; + return getVariantDetailLabel(variant, quota); +} + +function getDetailedAudienceLabel(audience: AccountAudience): string | null { + if (audience === 'business') return 'Business'; + if (audience === 'free') return 'Free'; + if (audience === 'personal') return 'Personal'; + return null; +} + +function getVariantAudience( + variant: { + audience: AccountAudience; + }, + quota?: unknown +) { + return getVariantBadgeAudience(variant, quota); } function getVariantInlineLabel( variant: { - audience: string; + audience: AccountAudience; audienceLabel?: string | null; detailLabel?: string | null; compactDetailLabel?: string | null; @@ -135,48 +214,49 @@ function getVariantInlineLabel( }, quota?: unknown ) { - const detailLabel = getVariantDetailLabel(variant, quota); - const composedLabel = [variant.audienceLabel, detailLabel].filter(Boolean).join(' · '); - return composedLabel || variant.inlineLabel || null; + return getVariantDetailLabel(variant, quota) || variant.inlineLabel || null; } function getVariantMarkerLabel( variant: { - audience: string; + audience: AccountAudience; audienceLabel?: string | null; detailLabel?: string | null; compactDetailLabel?: string | null; }, - audienceCounts: Map, quota?: unknown ) { + const audience = getVariantAudience(variant, quota); const compactDetailLabel = getVariantCompactDetailLabel(variant, quota); - if (variant.audience === 'business') { - const businessVariantCount = audienceCounts.get('business') ?? 0; - return businessVariantCount > 1 && compactDetailLabel ? compactDetailLabel : 'Biz'; + if (audience === 'business') { + return 'Biz'; } - if (variant.audience === 'personal') { + if (audience === 'free') { + return compactDetailLabel ?? 'Free'; + } + if (audience === 'personal') { return compactDetailLabel ?? 'Pers'; } - const normalizedFallback = - compactDetailLabel?.trim() || variant.audienceLabel?.trim() || variant.detailLabel?.trim(); + const normalizedFallback = compactDetailLabel?.trim() || getDetailedAudienceLabel(audience); return normalizedFallback?.[0]?.toUpperCase() ?? '?'; } function getGroupedVariantSummaryLabel( variants: Array<{ - audience: string; + audience: AccountAudience; audienceLabel?: string | null; detailLabel?: string | null; compactDetailLabel?: string | null; }>, - quotas: Array, - audienceCounts: Map + quotas: Array ) { const audiences = new Set(variants.map((variant) => variant.audience)); const hasDistinctDetails = variants.some((variant, index) => - Boolean(getVariantCompactDetailLabel(variant, quotas[index])) + Boolean( + getVariantCompactDetailLabel(variant, quotas[index]) || + getDetailedAudienceLabel(getVariantAudience(variant, quotas[index])) + ) ); if ( @@ -191,7 +271,7 @@ function getGroupedVariantSummaryLabel( if (variants.length === 1) { const [variant] = variants; - return getVariantMarkerLabel(variant, audienceCounts, quotas[0]); + return getVariantMarkerLabel(variant, quotas[0]); } return null; @@ -239,14 +319,9 @@ export function AccountCard({ const groupedVariantQuotas = groupedHeaderVariants.map( (_, index) => variantQuotaQueries[index]?.data ); - const groupedVariantAudienceCounts = groupedHeaderVariants.reduce((counts, variant) => { - counts.set(variant.audience, (counts.get(variant.audience) ?? 0) + 1); - return counts; - }, new Map()); const groupedVariantSummaryLabel = getGroupedVariantSummaryLabel( groupedHeaderVariants, - groupedVariantQuotas, - groupedVariantAudienceCounts + groupedVariantQuotas ); const compactMetaBadges = hasGroupedVariants ? ( @@ -274,16 +349,14 @@ export function AccountCard({ index > 0 && 'border-l border-border/50', variant.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300' - : variant.audience === 'personal' - ? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300' - : 'bg-muted text-muted-foreground' + : variant.audience === 'free' + ? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200' + : variant.audience === 'personal' + ? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300' + : 'bg-muted text-muted-foreground' )} > - {getVariantMarkerLabel( - variant, - groupedVariantAudienceCounts, - groupedVariantQuotas[index] - )} + {getVariantMarkerLabel(variant, groupedVariantQuotas[index])} )) )} diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index aec49882..ad61b231 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from 'react'; import { Badge } from '@/components/ui/badge'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats'; -import { formatAccountVariantPart, getAccountIdentityPresentation } from '@/lib/account-identity'; +import { + getAccountIdentityPresentation, + getCodexIdentityBadge, + type CodexIdentityBadge, +} from '@/lib/account-identity'; import { cn } from '@/lib/utils'; import { Pause, Star, User } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -10,6 +14,7 @@ import { useTranslation } from 'react-i18next'; import { AccountQuotaPanel } from './account-quota-panel'; type AccountSurfaceMode = 'compact' | 'detailed'; +type AccountAudience = 'business' | 'free' | 'personal' | 'unknown'; type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; interface AccountSurfaceCardProps { @@ -36,11 +41,15 @@ interface AccountSurfaceCardProps { className?: string; } -function getAudienceBadgeClass(audience: 'business' | 'personal' | 'unknown') { +function getAudienceBadgeClass(audience: AccountAudience) { if (audience === 'business') { return 'bg-sky-500/12 text-sky-700 dark:text-sky-300'; } + if (audience === 'free') { + return 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'; + } + if (audience === 'personal') { return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'; } @@ -54,20 +63,22 @@ function getTierBadgeClass(tier: AccountTier | undefined) { : 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'; } -function getCompactAudienceBadgeLabel( - audience: 'business' | 'personal' | 'unknown', - t: (key: string) => string -) { +function getCompactAudienceBadgeLabel(audience: AccountAudience, t: (key: string) => string) { if (audience === 'business') return t('accountSurfaceCard.business'); + if (audience === 'free') return t('accountSurfaceCard.free'); if (audience === 'personal') return t('accountSurfaceCard.personal'); return '?'; } -function getCompactDetailBadgeClass(audience: 'business' | 'personal' | 'unknown') { +function getCompactDetailBadgeClass(audience: AccountAudience) { if (audience === 'business') { return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-400/30 dark:bg-sky-500/15 dark:text-sky-200'; } + if (audience === 'free') { + return 'border-slate-300/70 bg-slate-100/80 text-slate-700 dark:border-slate-500/40 dark:bg-slate-700/30 dark:text-slate-200'; + } + if (audience === 'personal') { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:border-emerald-400/30 dark:bg-emerald-500/15 dark:text-emerald-200'; } @@ -88,12 +99,51 @@ function resolveEffectiveTier( return tier; } -function getCodexPlanDetailLabel(quota: UnifiedQuotaResult | undefined): string | null { +function getCodexQuotaBadge(quota: UnifiedQuotaResult | undefined): CodexIdentityBadge { if (!quota || !('planType' in quota) || !quota.planType) { - return null; + return { audience: 'unknown', label: null }; } - return formatAccountVariantPart(quota.planType); + if (quota.planType === 'team') { + return { audience: 'business', label: 'Business' }; + } + + if (quota.planType === 'free') { + return { audience: 'free', label: 'Free' }; + } + + if (quota.planType === 'plus') { + return { audience: 'personal', label: 'Plus' }; + } + + if (quota.planType === 'pro') { + return { audience: 'personal', label: 'Pro' }; + } + + return { audience: 'unknown', label: null }; +} + +function resolveCodexBadge( + identityBadge: CodexIdentityBadge, + quotaBadge: CodexIdentityBadge +): CodexIdentityBadge { + if (!quotaBadge.label) { + return identityBadge; + } + + if ( + quotaBadge.label === 'Business' || + quotaBadge.label === 'Plus' || + quotaBadge.label === 'Pro' + ) { + return quotaBadge; + } + + if (quotaBadge.label === 'Free' && identityBadge.label && identityBadge.label !== 'Free') { + return identityBadge; + } + + return quotaBadge; } export function AccountSurfaceCard({ @@ -124,10 +174,10 @@ export function AccountSurfaceCard({ const title = displayEmail || identity.email || accountId; const normalizedProvider = provider.toLowerCase(); const effectiveTier = resolveEffectiveTier(tier, quota); - const codexPlanDetailLabel = - normalizedProvider === 'codex' ? getCodexPlanDetailLabel(quota) : null; - const resolvedDetailLabel = identity.detailLabel ?? codexPlanDetailLabel; - const resolvedCompactDetailLabel = identity.compactDetailLabel ?? codexPlanDetailLabel; + const effectiveCodexBadge = + normalizedProvider === 'codex' + ? resolveCodexBadge(getCodexIdentityBadge(identity), getCodexQuotaBadge(quota)) + : null; const showTierBadge = (normalizedProvider === 'agy' || normalizedProvider === 'antigravity' || @@ -148,28 +198,38 @@ export function AccountSurfaceCard({ {effectiveTier} )} - {identity.audienceLabel && ( - + {effectiveCodexBadge.label} + + ) + : identity.audienceLabel && ( + + {getCompactAudienceBadgeLabel(identity.audience, t)} + )} - > - {getCompactAudienceBadgeLabel(identity.audience, t)} - - )} - {resolvedCompactDetailLabel && ( + {normalizedProvider !== 'codex' && identity.compactDetailLabel && ( - {resolvedCompactDetailLabel} + {identity.compactDetailLabel} )} {paused && ( @@ -227,7 +287,18 @@ export function AccountSurfaceCard({ {title} {isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)} - {!isCompact && identity.audienceLabel && ( + {!isCompact && normalizedProvider === 'codex' && effectiveCodexBadge?.label && ( + + {effectiveCodexBadge.label} + + )} + {!isCompact && normalizedProvider !== 'codex' && identity.audienceLabel && ( )} - {!isCompact && resolvedDetailLabel && ( + {!isCompact && normalizedProvider !== 'codex' && identity.detailLabel && ( - {resolvedDetailLabel} + {identity.detailLabel} )} {!isCompact && isDefault && ( diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index f7cd2d2f..e20d0303 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -17,6 +17,7 @@ import { AreaChart, } from 'recharts'; import { format } from 'date-fns'; +import { useTranslation } from 'react-i18next'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; import type { DailyUsage, HourlyUsage } from '@/hooks/use-usage'; @@ -39,14 +40,13 @@ export function UsageTrendChart({ className, }: UsageTrendChartProps) { const { privacyMode } = usePrivacy(); - // TODO i18n: uncomment when keys for "No usage data for today" / "No usage data available" are added - // const { t } = useTranslation(); + const { t } = useTranslation(); const chartData = useMemo(() => { if (!data || data.length === 0) return []; // For hourly data, already sorted ascending from API - const sortedData = granularity === 'hourly' ? data : [...data].reverse(); + const sortedData = data; return sortedData.map((item) => { // Handle hourly vs daily data format @@ -67,8 +67,7 @@ export function UsageTrendChart({ return (

- {/* TODO i18n: missing keys for "No usage data for today" / "No usage data available" */} - {granularity === 'hourly' ? 'No usage data for today' : 'No usage data available'} + {granularity === 'hourly' ? t('analytics.noDailyUsage') : t('analytics.noUsageData')}

); diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 2022ce5c..72b3d7ef 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -86,8 +86,12 @@ export function ProviderEditor({ gemini: ['google'], agy: ['antigravity'], codex: ['openai'], + cursor: ['cursor'], + gitlab: ['gitlab', 'duo'], + codebuddy: ['codebuddy'], qwen: ['alibaba', 'qwen'], iflow: ['iflow'], + kilo: ['kilo'], kiro: ['kiro', 'aws'], ghcp: ['github', 'copilot'], kimi: ['kimi', 'moonshot'], diff --git a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx index dc251b61..69c4100b 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx @@ -25,6 +25,17 @@ interface ProviderEditorHeaderProps { onSave: () => void; } +function formatSettingsPathBadgeLabel(pathValue: string): string { + const normalized = pathValue.replace(/\\/g, '/'); + const cliproxySegment = '/cliproxy/providers/'; + const cliproxyIndex = normalized.lastIndexOf(cliproxySegment); + if (cliproxyIndex !== -1) { + return normalized.slice(cliproxyIndex + 1); + } + + return normalized.replace(/^.*\//, ''); +} + export function ProviderEditorHeader({ displayName, logoProvider, @@ -63,7 +74,7 @@ export function ProviderEditorHeader({ )} {!isRemoteMode && data?.path && ( - {data.path.replace(/^.*[\\/]/, '')} + {formatSettingsPathBadgeLabel(data.path)} )}
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 8412ec44..739955c1 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -15,6 +15,7 @@ import { isAnthropicModelEnvKey, } from '@/lib/extended-context-utils'; import { supportsExtendedContext } from '@/lib/model-catalogs'; +import { isValidProvider } from '@/lib/provider-config'; /** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */ const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; @@ -39,12 +40,18 @@ export function useProviderEditor( queryFn: async () => { const res = await fetch(`/api/settings/${provider}/raw`); if (!res.ok) { + const fallbackPath = + provider === 'cursor' + ? `~/.ccs/cliproxy/providers/${provider}.settings.json` + : isValidProvider(provider) + ? `~/.ccs/${provider}.settings.json` + : `~/.ccs/profiles/${provider}/settings.json`; // Return empty settings for unconfigured providers return { profile: provider, settings: { env: {} }, mtime: Date.now(), - path: `~/.ccs/profiles/${provider}/settings.json`, + path: fallbackPath, }; } return res.json(); diff --git a/ui/src/components/compatible-cli/codex-docs-tab.tsx b/ui/src/components/compatible-cli/codex-docs-tab.tsx index 8c118e9f..8a88a652 100644 --- a/ui/src/components/compatible-cli/codex-docs-tab.tsx +++ b/ui/src/components/compatible-cli/codex-docs-tab.tsx @@ -139,6 +139,11 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) { Export CLIPROXY_API_KEY before launching native Codex. +

+ CCS-managed browser tooling belongs to Settings > Browser. Do not edit + the ccs_browser entry from the generic MCP card unless you are + intentionally overriding the managed path in raw TOML. +

diff --git a/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx b/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx index 3407a550..e1570ed3 100644 --- a/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx +++ b/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx @@ -36,6 +36,8 @@ const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = { toolTimeoutSec: null, enabledTools: [], disabledTools: [], + isCcsManaged: false, + managementSurface: null, }; function toCsv(value: string[]) { @@ -70,9 +72,16 @@ function McpServerEditor({ }: McpServerEditorProps) { const { t } = useTranslation(); const [draft, setDraft] = useState(initialDraft); + const reservedManagedBrowserDraft = isNew && draft.name.trim() === 'ccs_browser'; return ( <> + {reservedManagedBrowserDraft ? ( +
+ ccs_browser is reserved for the CCS-managed browser tooling path. + Configure it from Settings > Browser instead of creating it here. +
+ ) : null}
{saving ? : null} {/* TODO i18n: missing key codex.saveMcpServer */} @@ -244,6 +255,8 @@ export function CodexMcpServersCard({ ); const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT; const draftKey = JSON.stringify(draftSeed); + const selectedEntryIsManagedBrowser = + selectedEntry?.isCcsManaged && selectedEntry.managementSurface === 'browser-settings'; return ( + {selectedEntryIsManagedBrowser ? ( +
+ {selectedEntry?.name} is CCS-managed. Configure browser tooling from{' '} + Settings > Browser; the generic MCP editor is read-only for this entry. +
+ ) : null} + setDraft((current) => + updateClaudeDraft(current ?? effectiveConfig, { + userDataDir: event.target.value, + }) + ) + } + className="rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70" + placeholder={status.claude.recommendedUserDataDir} + /> +

+ {t('settingsPage.browserSection.claude.userDataDirHint')} +

+
+ +
+ + setClaudePortDraft(event.target.value)} + inputMode="numeric" + className="max-w-[120px] rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70" + /> +

+ {claudePortInvalid + ? t('settingsPage.browserSection.claude.devtoolsPortInvalid') + : t('settingsPage.browserSection.claude.devtoolsPortHint')} +

+
+ + +
+
+
+
+

+ {t('settingsPage.browserSection.claude.launchGuidance')} +

+

+ {t('settingsPage.browserSection.claude.launchGuidanceHint')} +

+
+ +
+ + {preferredLaunchCommand} + +
+ + {status.claude.overrideActive && ( +
+ + {t('settingsPage.browserSection.claude.overrideMessage', { + source: status.claude.source, + })} +
+ )} +
+ + + +
+
+

+ {t('settingsPage.browserSection.claude.effectivePath')} +

+
+

+ {status.claude.effectiveUserDataDir} +

+
+

+ + {t('settingsPage.browserSection.claude.recommendedPath')}: + {' '} + {status.claude.recommendedUserDataDir} +

+
+
+

+ {t('settingsPage.browserSection.claude.managedRuntime')} +

+
+

+ {status.claude.managedMcpServerName} +

+

+ {status.claude.managedMcpServerPath} +

+
+
+
+
+ + + + + {/* Codex Lane Card */} + + +
+ + + setDraft((current) => + updateCodexDraft(current ?? effectiveConfig, { enabled: next }) + ) + } + /> +
+ + + } + > +
+ + + +
+
+

+ {t('settingsPage.browserSection.codex.serverName')} +

+
+

+ {status.codex.serverName} +

+
+
+
+

+ {t('settingsPage.browserSection.codex.overrideSupport')} +

+
+ {status.codex.supportsConfigOverrides ? ( + + ) : ( + + )} + {status.codex.supportsConfigOverrides + ? t('settingsPage.browserSection.codex.overrideSupported') + : t('settingsPage.browserSection.codex.overrideUnsupported')} +
+
+
+

+ {t('settingsPage.browserSection.codex.binary')} +

+
+

+ {status.codex.binaryPath ?? + t('settingsPage.browserSection.codex.notDetected')} +

+ {status.codex.version && ( +
+ {status.codex.version} +
+ )} +
+
+
+
+
+
+
+ + + + ); +} + +function updateClaudeDraft( + source: BrowserConfig, + updates: Partial +): BrowserConfig { + return { + ...source, + claude: { + ...source.claude, + ...updates, + }, + }; +} + +function updateCodexDraft( + source: BrowserConfig, + updates: Partial +): BrowserConfig { + return { + ...source, + codex: { + ...source.codex, + ...updates, + }, + }; +} diff --git a/ui/src/pages/settings/settings-context.ts b/ui/src/pages/settings/settings-context.ts index d0329ac8..0aca0404 100644 --- a/ui/src/pages/settings/settings-context.ts +++ b/ui/src/pages/settings/settings-context.ts @@ -5,6 +5,8 @@ import { createContext, type Dispatch } from 'react'; import type { + BrowserConfig, + BrowserStatus, WebSearchConfig, GlobalEnvConfig, CliproxyServerConfig, @@ -15,6 +17,14 @@ import type { // === State === export interface SettingsState { + // Browser state + browserConfig: BrowserConfig | null; + browserStatus: BrowserStatus | null; + browserLoading: boolean; + browserStatusLoading: boolean; + browserSaving: boolean; + browserError: string | null; + browserSuccess: boolean; // WebSearch state webSearchConfig: WebSearchConfig | null; webSearchStatus: WebSearchStatus | null; @@ -43,6 +53,13 @@ export interface SettingsState { } export const initialSettingsState: SettingsState = { + browserConfig: null, + browserStatus: null, + browserLoading: true, + browserStatusLoading: true, + browserSaving: false, + browserError: null, + browserSuccess: false, webSearchConfig: null, webSearchStatus: null, webSearchLoading: true, @@ -69,6 +86,13 @@ export const initialSettingsState: SettingsState = { // === Actions === export type SettingsAction = + | { type: 'SET_BROWSER_CONFIG'; payload: BrowserConfig | null } + | { type: 'SET_BROWSER_STATUS'; payload: BrowserStatus | null } + | { type: 'SET_BROWSER_LOADING'; payload: boolean } + | { type: 'SET_BROWSER_STATUS_LOADING'; payload: boolean } + | { type: 'SET_BROWSER_SAVING'; payload: boolean } + | { type: 'SET_BROWSER_ERROR'; payload: string | null } + | { type: 'SET_BROWSER_SUCCESS'; payload: boolean } | { type: 'SET_WEBSEARCH_CONFIG'; payload: WebSearchConfig | null } | { type: 'SET_WEBSEARCH_STATUS'; payload: WebSearchStatus | null } | { type: 'SET_WEBSEARCH_LOADING'; payload: boolean } @@ -93,6 +117,20 @@ export type SettingsAction = export function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState { switch (action.type) { + case 'SET_BROWSER_CONFIG': + return { ...state, browserConfig: action.payload }; + case 'SET_BROWSER_STATUS': + return { ...state, browserStatus: action.payload }; + case 'SET_BROWSER_LOADING': + return { ...state, browserLoading: action.payload }; + case 'SET_BROWSER_STATUS_LOADING': + return { ...state, browserStatusLoading: action.payload }; + case 'SET_BROWSER_SAVING': + return { ...state, browserSaving: action.payload }; + case 'SET_BROWSER_ERROR': + return { ...state, browserError: action.payload }; + case 'SET_BROWSER_SUCCESS': + return { ...state, browserSuccess: action.payload }; case 'SET_WEBSEARCH_CONFIG': return { ...state, webSearchConfig: action.payload }; case 'SET_WEBSEARCH_STATUS': diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 1cff3d33..a8765ee7 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -3,7 +3,13 @@ * Type definitions for WebSearch, GlobalEnv, and Proxy configurations */ -import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client'; +import type { + BrowserSettingsConfig, + BrowserStatusPayload, + CliproxyServerConfig, + RemoteProxyStatus, + UpdateBrowserSettingsPayload, +} from '@/lib/api-client'; // === WebSearch Types === @@ -162,6 +168,7 @@ export interface OfficialChannelsStatus { // === Tab Types === export type SettingsTab = + | 'browser' | 'websearch' | 'image' | 'channels' @@ -192,3 +199,6 @@ export interface ThinkingConfig { // === Re-exports from api-client === export type { CliproxyServerConfig, RemoteProxyStatus }; +export type BrowserConfig = BrowserSettingsConfig; +export type BrowserStatus = BrowserStatusPayload; +export type BrowserSavePayload = UpdateBrowserSettingsPayload; diff --git a/ui/tests/unit/components/account/add-account-dialog.test.tsx b/ui/tests/unit/components/account/add-account-dialog.test.tsx index e39e271a..8e7176e6 100644 --- a/ui/tests/unit/components/account/add-account-dialog.test.tsx +++ b/ui/tests/unit/components/account/add-account-dialog.test.tsx @@ -59,6 +59,22 @@ describe('AddAccountDialog power user mode', () => { vi.clearAllMocks(); fetchMock.mockReset(); vi.stubGlobal('fetch', fetchMock); + Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', { + configurable: true, + value: () => false, + }); + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: () => {}, + }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { + configurable: true, + value: () => {}, + }); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: () => {}, + }); }); afterEach(() => { @@ -168,4 +184,26 @@ describe('AddAccountDialog power user mode', () => { expect(screen.queryByText('Power user mode enabled')).not.toBeInTheDocument(); expect(authMocks.startAuth).not.toHaveBeenCalled(); }); + + it('submits GitLab PAT mode with base URL and token through the shared auth hook', async () => { + render(); + + await userEvent.click(screen.getByRole('combobox', { name: 'GitLab auth method' })); + await userEvent.click(screen.getByRole('option', { name: 'Personal Access Token' })); + + await userEvent.type(screen.getByLabelText('GitLab URL'), 'https://gitlab.example.com'); + await userEvent.type(screen.getByLabelText('Personal Access Token'), 'glpat-test-token'); + + await userEvent.click(screen.getByRole('button', { name: 'Authenticate' })); + + expect(authMocks.startAuth).toHaveBeenCalledWith( + 'gitlab', + expect.objectContaining({ + gitlabAuthMode: 'pat', + gitlabBaseUrl: 'https://gitlab.example.com', + gitlabPersonalAccessToken: 'glpat-test-token', + startEndpoint: 'start', + }) + ); + }); }); diff --git a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx index be9c28d4..71e300c2 100644 --- a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx @@ -20,7 +20,11 @@ vi.mock('@/hooks/use-cliproxy-stats', async () => { const mockedUseAccountQuota = vi.mocked(useAccountQuota); const mockedUseAccountQuotas = vi.mocked(useAccountQuotas); -function makeCodexQuota(planType: 'free' | 'plus' | 'team', fiveHour: number, weekly: number) { +function makeCodexQuota( + planType: 'free' | 'plus' | 'pro' | 'team', + fiveHour: number, + weekly: number +) { return { success: true, planType, @@ -87,11 +91,11 @@ const groupedAccount: AccountData = { isDefault: true, successCount: 4, failureCount: 1, - audience: 'personal', - audienceLabel: 'Personal', - detailLabel: 'Free', - compactDetailLabel: 'Free', - inlineLabel: 'Personal · Free', + audience: 'free', + audienceLabel: 'Free', + detailLabel: null, + compactDetailLabel: null, + inlineLabel: 'Free', }, ], }; @@ -99,9 +103,11 @@ const groupedAccount: AccountData = { const groupedAccountWithProPersonal: AccountData = { ...groupedAccount, variants: groupedAccount.variants?.map((variant) => - variant.audience === 'personal' + variant.audience === 'free' ? { ...variant, + audience: 'personal', + audienceLabel: 'Personal', detailLabel: 'Pro', compactDetailLabel: 'Pro', inlineLabel: 'Personal · Pro', @@ -148,12 +154,10 @@ describe('AccountCard grouped quota tooltip', () => { /> ); - expect( - screen.getByTitle('Business · Workspace 04a0f049 • Personal · Free') - ).toBeInTheDocument(); + expect(screen.getByTitle('Business • Free')).toBeInTheDocument(); expect(screen.getByText('Biz')).toBeInTheDocument(); - await userEvent.hover(screen.getByText('Business · Workspace 04a0f049')); + await userEvent.hover(screen.getByText('Business')); const businessPlan = (await screen.findAllByText('Plan: team')).find((node) => node.closest('[data-slot="tooltip-content"]') ); @@ -164,7 +168,14 @@ describe('AccountCard grouped quota tooltip', () => { expect(tooltipContent?.className).toContain('text-popover-foreground'); expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]'); - await userEvent.hover(screen.getByText('Personal · Free')); + const freeLabels = screen.getAllByText('Free'); + const quotaLabel = freeLabels[freeLabels.length - 1]; + expect(quotaLabel).toBeDefined(); + if (!quotaLabel) { + throw new Error('Expected a Free quota label'); + } + + await userEvent.hover(quotaLabel); const personalPlan = (await screen.findAllByText('Plan: free')).find((node) => node.closest('[data-slot="tooltip-content"]') ); @@ -191,8 +202,8 @@ describe('AccountCard grouped quota tooltip', () => { /> ); - expect(screen.getByTitle('Business · Workspace 04a0f049 • Personal · Pro')).toBeInTheDocument(); - expect(screen.getByText('Personal · Pro')).toBeInTheDocument(); - expect(screen.queryByText('Personal · Free')).not.toBeInTheDocument(); + expect(screen.getByTitle('Business • Pro')).toBeInTheDocument(); + expect(screen.getAllByText('Pro').length).toBeGreaterThan(0); + expect(screen.queryByText('Free')).not.toBeInTheDocument(); }); }); diff --git a/ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx b/ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx new file mode 100644 index 00000000..616fb160 --- /dev/null +++ b/ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; +import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card'; + +describe('CodexMcpServersCard', () => { + it('blocks creating the reserved ccs_browser entry from the generic MCP editor', async () => { + render(); + + const nameInput = screen.getByPlaceholderText('playwright'); + await userEvent.type(nameInput, 'ccs_browser'); + + expect( + screen.getAllByText( + (_, element) => + element?.textContent?.includes( + 'ccs_browser is reserved for the CCS-managed browser tooling path.' + ) ?? false + )[0] + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save MCP server' })).toBeDisabled(); + }); +}); diff --git a/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx b/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx index 8c27d933..119f48cd 100644 --- a/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx +++ b/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx @@ -42,7 +42,7 @@ describe('LocalhostDisclaimer', () => { expect( screen.getByText( - 'Remote dashboard access is read-only until you run ccs config auth setup on the host.' + 'Remote dashboard access is read-only until you run ccs config auth setup for this CCS instance. Docker deployments must run it inside the container.' ) ).toBeVisible(); expect(screen.queryByLabelText('Dismiss disclaimer')).toBeNull(); @@ -60,7 +60,7 @@ describe('LocalhostDisclaimer', () => { expect( screen.getByText( - 'Remote dashboard access is read-only because dashboard auth is currently disabled on the host. Re-enable dashboard auth on the host to unlock remote changes.' + 'Remote dashboard access is read-only because dashboard auth is currently disabled for this CCS instance. Re-enable it on the CCS host. Docker deployments must do that inside the running container.' ) ).toBeVisible(); expect(screen.queryByLabelText('Dismiss disclaimer')).toBeNull(); diff --git a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx index b37b8533..42f54ca8 100644 --- a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx +++ b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx @@ -307,6 +307,56 @@ describe('useCliproxyAuthFlow', () => { kiroIDCStartUrl: 'https://d-123.awsapps.com/start', kiroIDCRegion: 'ca-central-1', kiroIDCFlow: 'authcode', + gitlabAuthMode: undefined, + gitlabBaseUrl: undefined, + gitlabPersonalAccessToken: undefined, + riskAcknowledgement: undefined, + }); + }); + + it('forwards GitLab PAT options to the backend start endpoint payload', async () => { + let requestBody: Record | null = null; + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url.includes('/start')) { + requestBody = JSON.parse(String(init?.body)) as Record; + return createJsonResponse({ + success: true, + account: { + id: 'gitlab-account', + provider: 'gitlab', + }, + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('gitlab', { + startEndpoint: 'start', + gitlabAuthMode: 'pat', + gitlabBaseUrl: 'https://gitlab.example.com', + gitlabPersonalAccessToken: 'glpat-test-token', + }); + }); + + expect(requestBody).toEqual({ + nickname: undefined, + kiroMethod: undefined, + kiroIDCStartUrl: undefined, + kiroIDCRegion: undefined, + kiroIDCFlow: undefined, + gitlabAuthMode: 'pat', + gitlabBaseUrl: 'https://gitlab.example.com', + gitlabPersonalAccessToken: 'glpat-test-token', riskAcknowledgement: undefined, }); }); diff --git a/ui/tests/unit/hooks/use-cursor.test.tsx b/ui/tests/unit/hooks/use-cursor.test.tsx index 7b4f947f..849de3fb 100644 --- a/ui/tests/unit/hooks/use-cursor.test.tsx +++ b/ui/tests/unit/hooks/use-cursor.test.tsx @@ -32,7 +32,7 @@ describe('useCursor', () => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url.endsWith('/api/cursor/status')) { + if (url.endsWith('/api/legacy/cursor/status')) { return Promise.resolve( createJsonResponse({ enabled: true, @@ -48,7 +48,7 @@ describe('useCursor', () => { ); } - if (url.endsWith('/api/cursor/settings')) { + if (url.endsWith('/api/legacy/cursor/settings')) { return Promise.resolve( createJsonResponse({ enabled: true, @@ -60,7 +60,7 @@ describe('useCursor', () => { ); } - if (url.endsWith('/api/cursor/models')) { + if (url.endsWith('/api/legacy/cursor/models')) { return Promise.resolve( createJsonResponse({ models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }], @@ -69,7 +69,7 @@ describe('useCursor', () => { ); } - if (url.endsWith('/api/cursor/settings/raw')) { + if (url.endsWith('/api/legacy/cursor/settings/raw')) { return Promise.resolve( createJsonResponse({ settings: {}, @@ -80,7 +80,7 @@ describe('useCursor', () => { ); } - if (url.endsWith('/api/cursor/probe') && init?.method === 'POST') { + if (url.endsWith('/api/legacy/cursor/probe') && init?.method === 'POST') { return Promise.resolve(createJsonResponse(probeFailure, 503)); } @@ -102,14 +102,16 @@ describe('useCursor', () => { await waitFor(() => expect(result.current.probeResult).toMatchObject(probeFailure)); await waitFor(() => expect( - fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/status')) - .length + fetchMock.mock.calls.filter(([input]) => + String(input).endsWith('/api/legacy/cursor/status') + ).length ).toBeGreaterThanOrEqual(2) ); await waitFor(() => expect( - fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/models')) - .length + fetchMock.mock.calls.filter(([input]) => + String(input).endsWith('/api/legacy/cursor/models') + ).length ).toBeGreaterThanOrEqual(2) ); }); diff --git a/ui/tests/unit/pages/login-page.test.tsx b/ui/tests/unit/pages/login-page.test.tsx index 689e5992..393913c2 100644 --- a/ui/tests/unit/pages/login-page.test.tsx +++ b/ui/tests/unit/pages/login-page.test.tsx @@ -59,7 +59,7 @@ describe('LoginPage', () => { await waitFor(() => { expect(navigateMock).toHaveBeenCalledWith('/settings', { replace: true }); }); - expect(screen.queryByRole('heading', { name: 'Remote access needs host setup' })).toBeNull(); + expect(screen.queryByRole('heading', { name: 'Remote access needs auth setup' })).toBeNull(); }); it('renders the incomplete setup copy when auth is enabled without credentials', () => { @@ -80,7 +80,7 @@ describe('LoginPage', () => { expect( screen.getAllByText( - 'Dashboard auth is turned on, but the host setup is incomplete. Finish the host configuration before signing in.' + 'Dashboard auth is turned on, but the setup is incomplete. Finish the configuration for this CCS instance before signing in.' ) ).not.toHaveLength(0); expect( @@ -88,6 +88,10 @@ describe('LoginPage', () => { 'Create or re-enable dashboard credentials, then reopen this page from the remote device.' ) ).toBeVisible(); + expect( + screen.getByText('Docker deployment? Run the setup inside the running container:') + ).toBeVisible(); + expect(screen.getByText('docker exec -it ccs-cliproxy ccs config auth setup')).toBeVisible(); expect(screen.queryByLabelText('Username')).not.toBeInTheDocument(); }); diff --git a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx index 56079208..64d88fd0 100644 --- a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx +++ b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx @@ -57,7 +57,7 @@ describe('AccountSurfaceCard', () => { expect(screen.getByText('pro')).toBeInTheDocument(); }); - it('shows both personal identity and free-tier detail for compact Codex cards', () => { + it('shows free Codex accounts as a single standalone audience badge', () => { render( { /> ); - expect(screen.getByText('Pers')).toBeInTheDocument(); - expect(screen.getByTitle('Personal')).toBeInTheDocument(); expect(screen.getByText('Free')).toBeInTheDocument(); + expect(screen.getByTitle('Free')).toBeInTheDocument(); + expect(screen.queryByText('Pers')).not.toBeInTheDocument(); }); - it('keeps richer token-derived Codex personal detail when live quota planType is coarser', () => { + it('keeps token-derived personal detail when live quota planType is coarser', () => { render( { expect(screen.getByText('Pro')).toBeInTheDocument(); expect(screen.queryByText('Free')).not.toBeInTheDocument(); + expect(screen.queryByText('Pers')).not.toBeInTheDocument(); + }); + + it('falls back to a single free badge when Codex quota detects a free plan', () => { + render( + + ); + + expect(screen.getByText('Free')).toBeInTheDocument(); + expect(screen.queryByText('Pers')).not.toBeInTheDocument(); + }); + + it('falls back to plus or pro detail when Codex quota exposes a paid plan', () => { + render( + + ); + + expect(screen.getByText('Pro')).toBeInTheDocument(); + expect(screen.queryByText('Pers')).not.toBeInTheDocument(); + }); + + it('lets live paid Codex plans override stale free identity badges', () => { + render( + + ); + + expect(screen.getByText('Plus')).toBeInTheDocument(); + expect(screen.queryByText('Pers')).not.toBeInTheDocument(); + expect(screen.queryByTitle('Free')).not.toBeInTheDocument(); }); }); diff --git a/ui/tests/unit/ui/lib/account-identity.test.ts b/ui/tests/unit/ui/lib/account-identity.test.ts index 9ebc93fc..0b675d00 100644 --- a/ui/tests/unit/ui/lib/account-identity.test.ts +++ b/ui/tests/unit/ui/lib/account-identity.test.ts @@ -39,14 +39,40 @@ describe('account identity presentation', () => { ).toBe('Business · Workspace 04a0f049'); }); - it('formats personal codex plans deliberately instead of leaking raw free suffixes', () => { + it('classifies free codex accounts as a standalone audience', () => { + const presentation = getAccountIdentityPresentation( + 'kaidu.kd@gmail.com', + 'kaidu.kd@gmail.com', + 'codex-kaidu.kd@gmail.com-free.json' + ); + + expect(presentation.audience).toBe('free'); + expect(presentation.audienceLabel).toBe('Free'); + expect(presentation.detailLabel).toBeNull(); expect( formatAccountDisplayName( 'kaidu.kd@gmail.com', 'kaidu.kd@gmail.com', 'codex-kaidu.kd@gmail.com-free.json' ) - ).toBe('kaidu.kd@gmail.com (Personal · Free)'); + ).toBe('kaidu.kd@gmail.com (Free)'); + }); + + it('keeps plus and pro codex personal plans distinct', () => { + expect( + formatAccountDisplayName( + 'kaidu.kd@gmail.com', + 'kaidu.kd@gmail.com', + 'codex-kaidu.kd@gmail.com-plus.json' + ) + ).toBe('kaidu.kd@gmail.com (Personal · Plus)'); + expect( + formatAccountDisplayName( + 'kaidu.kd@gmail.com', + 'kaidu.kd@gmail.com', + 'codex-kaidu.kd@gmail.com-pro.json' + ) + ).toBe('kaidu.kd@gmail.com (Personal · Pro)'); }); it('leaves plain accounts without inferred state untouched', () => { diff --git a/ui/tests/unit/ui/lib/codex-config-browser.test.ts b/ui/tests/unit/ui/lib/codex-config-browser.test.ts new file mode 100644 index 00000000..6d851fc4 --- /dev/null +++ b/ui/tests/unit/ui/lib/codex-config-browser.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { readCodexMcpServers } from '@/lib/codex-config'; + +describe('readCodexMcpServers', () => { + it('marks the CCS browser MCP entry as managed by Browser settings', () => { + const entries = readCodexMcpServers({ + mcp_servers: { + ccs_browser: { + command: 'npx', + args: ['-y', '@playwright/mcp@0.0.70'], + enabled: true, + }, + playwright: { + command: 'npx', + args: ['-y', '@playwright/mcp@latest'], + enabled: true, + }, + }, + }); + + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'ccs_browser', + isCcsManaged: true, + managementSurface: 'browser-settings', + }), + expect.objectContaining({ + name: 'playwright', + isCcsManaged: false, + managementSurface: null, + }), + ]) + ); + }); +}); diff --git a/ui/tests/unit/ui/lib/platform.test.ts b/ui/tests/unit/ui/lib/platform.test.ts new file mode 100644 index 00000000..b97f923a --- /dev/null +++ b/ui/tests/unit/ui/lib/platform.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { getClientPlatformKey } from '@/lib/platform'; + +describe('getClientPlatformKey', () => { + it('prefers navigator.userAgentData.platform when available', () => { + expect( + getClientPlatformKey({ + userAgentData: { platform: 'macOS' }, + platform: 'Win32', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }) + ).toBe('darwin'); + }); + + it('falls back to navigator.platform when userAgentData is unavailable', () => { + expect( + getClientPlatformKey({ + platform: 'Win32', + userAgent: 'Mozilla/5.0 (X11; Linux x86_64)', + }) + ).toBe('win32'); + }); + + it('falls back to user-agent parsing when platform is unavailable', () => { + expect( + getClientPlatformKey({ + platform: '', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }) + ).toBe('win32'); + }); +}); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index 6d48a838..b33f24b1 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -2,9 +2,14 @@ import { describe, expect, it } from 'vitest'; import { formatRequestedUpstreamModelRules, + getProviderDescription, + getProviderDisplayName, + getProviderFallbackVisual, + getProviderLogoAsset, getRequestedUpstreamModelRuleErrors, getRequestedModelId, parseRequestedUpstreamModelRules, + PROVIDER_COLORS, } from '@/lib/provider-config'; describe('provider model mapping helpers', () => { @@ -42,3 +47,45 @@ describe('provider model mapping helpers', () => { ]); }); }); + +describe('provider presentation metadata', () => { + it.each([ + ['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'], + ['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'], + [ + 'codebuddy', + 'CodeBuddy (Tencent)', + 'Tencent CodeBuddy AI assistant', + '/assets/providers/codebuddy.png', + ], + ['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.png'], + ])('recognizes %s across dashboard display helpers', (provider, name, description, asset) => { + expect(getProviderDisplayName(provider)).toBe(name); + expect(getProviderDescription(provider)).toBe(description); + expect(getProviderLogoAsset(provider)).toBe(asset); + }); + + it('provides fallback visuals and brand colors for new providers', () => { + expect(getProviderFallbackVisual('cursor')).toEqual({ + textClass: 'text-slate-900', + letter: 'C', + }); + expect(getProviderFallbackVisual('gitlab')).toEqual({ + textClass: 'text-orange-600', + letter: 'G', + }); + expect(getProviderFallbackVisual('codebuddy')).toEqual({ + textClass: 'text-blue-600', + letter: 'B', + }); + expect(getProviderFallbackVisual('kilo')).toEqual({ + textClass: 'text-rose-600', + letter: 'K', + }); + + expect(PROVIDER_COLORS.cursor).toBe('#111827'); + expect(PROVIDER_COLORS.gitlab).toBe('#FC6D26'); + expect(PROVIDER_COLORS.codebuddy).toBe('#2563EB'); + expect(PROVIDER_COLORS.kilo).toBe('#E11D48'); + }); +}); diff --git a/ui/tests/unit/ui/pages/browser-section.test.tsx b/ui/tests/unit/ui/pages/browser-section.test.tsx new file mode 100644 index 00000000..3e6ce379 --- /dev/null +++ b/ui/tests/unit/ui/pages/browser-section.test.tsx @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; + +const mocks = vi.hoisted(() => ({ + useBrowserConfig: vi.fn(), + useRawConfig: vi.fn(), + fetchConfig: vi.fn(), + fetchStatus: vi.fn(), + saveConfig: vi.fn(), + fetchRawConfig: vi.fn(), +})); + +vi.mock('@/pages/settings/hooks', async () => { + const actual = + await vi.importActual('@/pages/settings/hooks'); + return { + ...actual, + useBrowserConfig: mocks.useBrowserConfig, + useRawConfig: mocks.useRawConfig, + }; +}); + +import BrowserSection from '@/pages/settings/sections/browser'; + +describe('BrowserSection', () => { + beforeEach(() => { + mocks.fetchConfig.mockReset(); + mocks.fetchStatus.mockReset(); + mocks.saveConfig.mockReset(); + mocks.fetchRawConfig.mockReset(); + + mocks.useRawConfig.mockReturnValue({ + rawConfig: 'browser:\n claude:\n enabled: true\n', + loading: false, + copied: false, + fetchRawConfig: mocks.fetchRawConfig, + copyToClipboard: vi.fn(), + }); + + mocks.useBrowserConfig.mockReturnValue({ + config: { + claude: { + enabled: true, + userDataDir: '/tmp/browser-profile', + devtoolsPort: 9222, + }, + codex: { + enabled: true, + }, + }, + status: { + claude: { + enabled: true, + source: 'config', + overrideActive: false, + state: 'ready', + title: 'Claude Browser Attach is ready.', + detail: 'CCS can reach the configured Chrome DevTools endpoint.', + nextStep: 'Launch Claude.', + effectiveUserDataDir: '/tmp/browser-profile', + recommendedUserDataDir: '/tmp/browser-profile', + devtoolsPort: 9222, + managedMcpServerName: 'ccs-browser', + managedMcpServerPath: '/tmp/ccs-browser-server.cjs', + launchCommands: { + darwin: + 'open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + linux: + 'google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + win32: 'chrome.exe --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + }, + }, + codex: { + enabled: true, + state: 'enabled', + title: 'Codex Browser Tools are enabled.', + detail: 'CCS can inject managed Playwright MCP overrides.', + nextStep: 'Use a Codex-target launch.', + serverName: 'ccs_browser', + supportsConfigOverrides: true, + binaryPath: '/usr/local/bin/codex', + version: 'codex-cli 0.120.0', + }, + }, + loading: false, + statusLoading: false, + saving: false, + error: null, + success: false, + fetchConfig: mocks.fetchConfig, + fetchStatus: mocks.fetchStatus, + saveConfig: mocks.saveConfig, + }); + }); + + it('uses the current draft for launch guidance and disables testing until the draft is saved', async () => { + render(, { withSettingsProvider: true }); + + const pathInput = screen.getByLabelText('Chrome user-data directory'); + await userEvent.clear(pathInput); + await userEvent.type(pathInput, '/tmp/new-browser-profile'); + + const launchCommand = screen.getByText(/new-browser-profile/); + expect(launchCommand).toBeInTheDocument(); + + const testConnectionButton = screen.getByRole('button', { name: 'Test connection' }); + expect(testConnectionButton).toBeDisabled(); + }); +}); diff --git a/ui/tests/unit/ui/pages/settings/settings-page.test.tsx b/ui/tests/unit/ui/pages/settings/settings-page.test.tsx index 651a3adb..a8253db2 100644 --- a/ui/tests/unit/ui/pages/settings/settings-page.test.tsx +++ b/ui/tests/unit/ui/pages/settings/settings-page.test.tsx @@ -31,6 +31,10 @@ vi.mock('@/pages/settings/sections/websearch', () => ({ default: () =>
WebSearch Section
, })); +vi.mock('@/pages/settings/sections/browser', () => ({ + default: () =>
Browser Section
, +})); + vi.mock('@/pages/settings/sections/channels', () => ({ default: () =>
Channels Section
, })); @@ -70,6 +74,7 @@ describe('SettingsPage raw config panel', () => { }); it('keeps the current config editor visible while raw config refreshes', async () => { + window.history.pushState({}, '', '/settings'); mocks.useRawConfig.mockReturnValue({ rawConfig: 'websearch:\n enabled: true\n', loading: true, @@ -84,4 +89,19 @@ describe('SettingsPage raw config panel', () => { expect(screen.getByLabelText('config editor')).toHaveValue('websearch:\n enabled: true\n'); expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); }); + + it('renders the Browser section when the settings tab query is browser', async () => { + window.history.pushState({}, '', '/settings?tab=browser'); + mocks.useRawConfig.mockReturnValue({ + rawConfig: 'browser:\n claude:\n enabled: false\n', + loading: false, + copied: false, + fetchRawConfig: mocks.fetchRawConfig, + copyToClipboard: mocks.copyToClipboard, + }); + + render(); + + expect(await screen.findAllByText('Browser Section')).toHaveLength(2); + }); });