mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
Merge upstream/dev into feat/browser-mcp-hover-phase11e
This commit is contained in:
commit
96db93ee6c
98 files changed
+6334
-1745
No files matched your search
+113
-11
@@ -4,35 +4,137 @@ on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
# Design notes:
|
||||
# - Matrix parallelism cuts wall time from ~3-4min to ~60-90s (cache warm).
|
||||
# - Concurrency group cancels superseded runs on the same ref (saves runner time on rapid pushes).
|
||||
# - Build leg produces dist/ artifact; test leg downloads it instead of rebuilding (DRY).
|
||||
# - fail-fast: false so every failure is visible in one run (no re-pushing to see the next failure).
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
check:
|
||||
- { name: typecheck, cmd: 'bun run typecheck' }
|
||||
- { name: lint, cmd: 'bun run lint' }
|
||||
- { name: format, cmd: 'bun run format:check' }
|
||||
name: ${{ matrix.check.name }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Clean stale artifacts
|
||||
run: rm -rf node_modules ui/node_modules dist
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.9'
|
||||
no-cache: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd ui && bun install --frozen-lockfile
|
||||
- name: Restore bun package cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache-v2-
|
||||
|
||||
- name: Build package
|
||||
- name: Ensure dependencies
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
- name: Run ${{ matrix.check.name }}
|
||||
run: ${{ matrix.check.cmd }}
|
||||
|
||||
build:
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
name: build
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.9'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Restore bun package cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache-v2-
|
||||
|
||||
- name: Ensure dependencies
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
- name: Build
|
||||
run: bun run build:all
|
||||
|
||||
- name: Validate (typecheck + lint + format + tests)
|
||||
run: bun run validate
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
test:
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
name: test
|
||||
needs: [build]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.9'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Restore bun package cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache-v2-
|
||||
|
||||
- name: Ensure dependencies
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- name: Test
|
||||
run: bun run test:all
|
||||
|
||||
- name: Test CLI e2e
|
||||
env:
|
||||
CCS_E2E_SKIP_BUILD: '1'
|
||||
run: bun run test:e2e
|
||||
@@ -26,8 +26,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
# Always use the built-in workflow token; PAT rotation/breakage must not block dev releases.
|
||||
token: ${{ github.token }}
|
||||
# PAT_TOKEN required: dev-release pushes version bump commits to dev,
|
||||
# and protected branches reject github.token writes once status checks are required.
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -41,9 +42,7 @@ jobs:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd ui && bun install --frozen-lockfile
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
- name: Build
|
||||
run: bun run build:all
|
||||
@@ -51,14 +50,19 @@ jobs:
|
||||
- name: Validate (typecheck + lint + format + tests)
|
||||
run: bun run validate
|
||||
|
||||
- name: Test CLI e2e
|
||||
env:
|
||||
CCS_E2E_SKIP_BUILD: '1'
|
||||
run: bun run test:e2e
|
||||
|
||||
- name: Release
|
||||
id: release
|
||||
env:
|
||||
HUSKY: 0
|
||||
# Use built-in GITHUB_TOKEN for release + issue operations.
|
||||
# Checkout credentials are resolved above with PAT fallback.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# PAT_TOKEN bypasses branch protection so the workflow can push
|
||||
# the dev release commit and tag back to the protected dev branch.
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
|
||||
@@ -37,9 +37,7 @@ jobs:
|
||||
bun-version: '1.3.9'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd ui && bun install --frozen-lockfile
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
- name: Build package
|
||||
run: bun run build:all
|
||||
@@ -47,6 +45,11 @@ jobs:
|
||||
- name: Validate (typecheck + lint + format + tests)
|
||||
run: bun run validate
|
||||
|
||||
- name: Test CLI e2e
|
||||
env:
|
||||
CCS_E2E_SKIP_BUILD: '1'
|
||||
run: bun run test:e2e
|
||||
|
||||
- name: Release
|
||||
id: release
|
||||
env:
|
||||
|
||||
+3
-1
@@ -35,6 +35,8 @@ package-lock.json
|
||||
.claude/active-plan
|
||||
.claude/agent-memory/
|
||||
.claude/plans-registry.json*
|
||||
CLAUDE.local.md
|
||||
AGENTS.override.md
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
@@ -43,4 +45,4 @@ logs/
|
||||
.dev-release-info.json
|
||||
|
||||
# Test coverage
|
||||
ui/coverage/
|
||||
ui/coverage/
|
||||
@@ -1,10 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Husky v9 invokes hooks via `sh -e` (see .husky/_/h), ignoring this shebang.
|
||||
# Re-exec under bash so `set -o pipefail` and `[[ ... ]]` below work portably.
|
||||
if [ -z "${BASH_VERSION:-}" ]; then
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Protected branches keep the full CI parity gate.
|
||||
# Feature branches use a faster gate and let GitHub CI run the full suite.
|
||||
# Override in emergencies only: CCS_SKIP_PREPUSH_GATE=1 git push --no-verify
|
||||
|
||||
# Skip gate entirely for delete-only pushes. Git passes refs on stdin as
|
||||
# "<local-ref> <local-sha> <remote-ref> <remote-sha>"; deletes have local-sha = 40 zeros.
|
||||
# Running a full test suite just to delete a merged branch is pure waste.
|
||||
STDIN_CONTENT="$(cat || true)"
|
||||
if [[ -n "$STDIN_CONTENT" ]]; then
|
||||
NON_DELETE_COUNT="$(printf '%s\n' "$STDIN_CONTENT" | awk 'NF >= 2 && $2 !~ /^0{40}$/' | wc -l | tr -d ' ')"
|
||||
if [[ "$NON_DELETE_COUNT" == "0" ]]; then
|
||||
echo "[i] Delete-only push, skipping pre-push gate."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||
BASE_BRANCH="${CCS_PR_BASE:-}"
|
||||
|
||||
@@ -29,6 +47,7 @@ echo " base: $BASE_BRANCH"
|
||||
bun run typecheck
|
||||
bun run lint:fix
|
||||
bun run format:check
|
||||
bun run build:all
|
||||
|
||||
git fetch origin "$BASE_BRANCH" --quiet || true
|
||||
DIFF_RANGE="HEAD"
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
## [7.72.1](https://github.com/kaitranntt/ccs/compare/v7.72.0...v7.72.1) (2026-04-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cliproxy:** pin routing config regeneration path ([d3de83e](https://github.com/kaitranntt/ccs/commit/d3de83e157b4d286cc9b684339cccbe501b911a3))
|
||||
|
||||
### Hotfixes
|
||||
|
||||
* **browser:** avoid aborting launch on missing managed profile ([41bf507](https://github.com/kaitranntt/ccs/commit/41bf5075e0ece90401dfa22fe9ffbed797825387))
|
||||
|
||||
### Tests
|
||||
|
||||
* **cliproxy:** pin routing strategy test env scope ([bc1606d](https://github.com/kaitranntt/ccs/commit/bc1606da756fdf1552029d3a74add4464caa2377))
|
||||
|
||||
## [7.72.0](https://github.com/kaitranntt/ccs/compare/v7.71.0...v7.72.0) (2026-04-17)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -15,6 +15,32 @@ AI-facing guidance for agent tooling when working with this repository.
|
||||
|
||||
Tests set `process.env.CCS_HOME` to a temp directory. Code using `os.homedir()` directly will modify the user's real files.
|
||||
|
||||
## CI-First Protocol (MANDATORY)
|
||||
|
||||
**A task is NOT complete until CI is green. After every `git push`, the AI agent MUST block on CI until it passes.**
|
||||
|
||||
### Required Sequence
|
||||
1. `git push`
|
||||
2. **Immediately** run `gh pr checks --watch` (or `gh run watch`) and block until all checks complete.
|
||||
3. If **green** → task may proceed to next step / be declared done.
|
||||
4. If **red**:
|
||||
- Pull failing logs: `gh run view --log-failed` (or `gh pr checks <n>` to identify the failing job, then `gh run view <run-id> --log-failed`).
|
||||
- Fix the root cause locally. Do NOT retry blindly.
|
||||
- Commit and push again. Re-watch CI.
|
||||
5. Applies to initial `gh pr create` AND every subsequent push on an open PR.
|
||||
|
||||
### Fallback (when `--watch` is unavailable or flaky)
|
||||
Poll with short sleep until no check is `pending` / `in_progress`:
|
||||
```bash
|
||||
until [ "$(gh pr checks <n> --json state -q '[.[] | select(.state == "IN_PROGRESS" or .state == "PENDING" or .state == "QUEUED")] | length')" = "0" ]; do
|
||||
sleep 10
|
||||
done
|
||||
gh pr checks <n>
|
||||
```
|
||||
|
||||
### Absolute rule
|
||||
AI MUST NOT declare a task done, close a session, or move to the next task while CI is red or still running. Leaving a PR red and moving on is the primary failure mode this protocol prevents.
|
||||
|
||||
## Core Function
|
||||
|
||||
Multi-provider profile and runtime manager for Claude Code, Factory Droid,
|
||||
@@ -201,9 +227,11 @@ bun run validate # Step 3: Final check (must pass)
|
||||
|
||||
| Project | Command | Runs |
|
||||
|---------|---------|------|
|
||||
| Main | `bun run validate` | typecheck + lint:fix + format:check + maintainability:check + test:all |
|
||||
| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all |
|
||||
| UI | `bun run validate` | typecheck + lint:fix + format:check |
|
||||
|
||||
**Note:** `maintainability:check` is a SEPARATE gate — not part of `validate`. Run it explicitly via `bun run maintainability:check[:strict|:warn]` when touching debt-sensitive code or before merging to protected branches.
|
||||
|
||||
### ESLint Rules (ALL errors)
|
||||
|
||||
| Rule | Level | Notes |
|
||||
@@ -238,7 +266,7 @@ bun run validate # Step 3: Final check (must pass)
|
||||
- Baseline file: `docs/metrics/maintainability-baseline.json`
|
||||
- Metric collector/check script: `scripts/maintainability-baseline.js`
|
||||
- Branch-aware gate wrapper: `scripts/maintainability-check.js`
|
||||
- Enforcement path: `bun run maintainability:check` (included in `bun run validate`)
|
||||
- Enforcement path: `bun run maintainability:check` (run separately — NOT part of `bun run validate`; invoked by `validate:ci-parity` on protected branches)
|
||||
- Gate modes:
|
||||
- `strict`: protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) and equivalent CI refs
|
||||
- `warn`: pull request CI and non-protected local branches (non-blocking for parallel PR workflow)
|
||||
@@ -499,27 +527,30 @@ rm -rf ~/.ccs # Clean environment
|
||||
|
||||
**IMPORTANT:** Use `bun run dev` at CCS root for always up-to-date code. Do NOT use `ccs config` during development as it uses the globally installed version.
|
||||
|
||||
## Pre-Commit Checklist
|
||||
## Two-Tier Pre-Push Checklist
|
||||
|
||||
**Quality (BLOCKERS):**
|
||||
- [ ] `bun run format` — formatting fixed
|
||||
- [ ] `bun run validate` — all checks pass
|
||||
- [ ] `bun run validate:ci-parity` — CI parity passed (required before protected-branch pushes; recommended before PRs)
|
||||
- [ ] `cd ui && bun run format && bun run validate` — if UI changed
|
||||
- [ ] If touching debt-sensitive code, run `bun run maintainability:check:strict` before opening/merging PR
|
||||
Optimized for iterative push-then-review workflow. Do NOT run the full gate on every push — CI is the safety net. Run the full gate once before asking for review / merge.
|
||||
|
||||
### Tier 1 — Iterative push (feature branch)
|
||||
Husky `pre-push` auto-runs: `typecheck + lint:fix + format:check + build:all` plus targeted tests based on changed files. AI does **nothing extra** at push time.
|
||||
|
||||
**After push (MANDATORY):** follow the [CI-First Protocol](#ci-first-protocol-mandatory) — watch CI until green. Do not move on while CI is red.
|
||||
|
||||
### Tier 2 — Before requesting review / merge
|
||||
Run ONCE, not per push:
|
||||
- [ ] `bun run validate:ci-parity` — full build + validate matches CI
|
||||
- [ ] `gh pr checks <n>` — all checks green
|
||||
- [ ] If touching debt-sensitive code: `bun run maintainability:check:strict`
|
||||
- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json`
|
||||
- [ ] If UI changed: `cd ui && bun run format && bun run validate`
|
||||
|
||||
**Code:**
|
||||
### Code / Docs / Standards (verify before merge)
|
||||
- [ ] Conventional commit format (`feat:`, `fix:`, etc.)
|
||||
- [ ] Respective `--help` updated (see Help Location Reference) — if CLI changed
|
||||
- [ ] Tests added/updated — if behavior changed
|
||||
- [ ] README.md updated — if user-facing
|
||||
|
||||
**Documentation:**
|
||||
- [ ] CCS docs updated (owner: `~/CloudPersonal/ccs/docs/`) — if CLI/config changed
|
||||
- [ ] Local `docs/` updated — if architecture changed
|
||||
|
||||
**Standards:**
|
||||
- [ ] CLI output ASCII only (NO emojis in terminal output), NO_COLOR respected
|
||||
- [ ] YAGNI/KISS/DRY alignment verified
|
||||
- [ ] No manual version bump or tags
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/claude",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "claude-sonnet-4-6",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-6",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-7",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001"
|
||||
}
|
||||
|
||||
+86
-220
@@ -8,6 +8,8 @@ CCS provides browser automation through two separate runtime paths:
|
||||
- **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.
|
||||
On new installs, and on upgrades that do not already have explicit browser settings, both lanes
|
||||
start **disabled** and **manual** so browser tooling is not auto-exposed until you opt in.
|
||||
|
||||
## How Browser Automation Works
|
||||
|
||||
@@ -20,206 +22,6 @@ 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.
|
||||
|
||||
The managed `ccs-browser` runtime currently exposes seven tool groups:
|
||||
|
||||
- **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot`
|
||||
- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot`, `browser_drag_element`, `browser_pointer_action`, `browser_start_replay`, `browser_get_replay`, `browser_cancel_replay`, `browser_start_orchestration`, `browser_get_orchestration`, `browser_cancel_orchestration`
|
||||
- **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot`
|
||||
- **Readiness and page evaluation**: `browser_wait_for`, `browser_eval`
|
||||
- **Event observation**: `browser_wait_for_event`
|
||||
- **Network interception**: `browser_add_intercept_rule`, `browser_remove_intercept_rule`, `browser_list_intercept_rules`, `browser_list_requests`
|
||||
- **File transfer**: `browser_set_download_behavior`, `browser_list_downloads`, `browser_cancel_download`, `browser_set_file_input`, `browser_drag_files`
|
||||
|
||||
Notable Phase 1 capability details:
|
||||
|
||||
- `browser_click` accepts zero-based `nth` so Claude can target the Nth matching element
|
||||
- `browser_query` is multi-match aware and can return `count`, `href`, and `onclick` in addition to visibility-oriented fields
|
||||
- `browser_wait_for` can wait on page text or selector state before the next step runs
|
||||
- `browser_eval` is gated by `browser.claude.eval_mode` and supports `disabled`, `readonly`, or `readwrite`; readonly mode uses side-effect-blocked evaluation and may reject expressions that could mutate page state
|
||||
|
||||
Phase 3 capability details:
|
||||
|
||||
- `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` to resolve targets inside a specific same-origin iframe
|
||||
- the same scoped-selector tools accept optional `pierceShadow: true` to search open shadow roots beneath the selected root
|
||||
- `browser_wait_for_event` observes typed page or browser events for dialogs, navigation, requests, and downloads
|
||||
|
||||
Phase 4 capability details:
|
||||
|
||||
- `browser_click` also accepts optional `offsetX`, `offsetY`, `button`, and `clickCount` for more precise element-relative click control
|
||||
- `browser_press_key` sends real browser-level key events, supports modifier combinations plus repeat counts, and covers a focused set of common special keys such as `Enter`, `Tab`, `Escape`, and arrow keys
|
||||
- `browser_scroll` supports page-level `by-offset` scrolling and element-scoped `by-offset` or `into-view` scrolling, including same-origin iframe scoping
|
||||
|
||||
Phase 5 capability details:
|
||||
|
||||
- `browser_get_session_info` marks the currently selected page
|
||||
- `browser_select_page` chooses the default target page for later tool calls
|
||||
- `browser_open_page` opens a new tab and makes it selected
|
||||
- `browser_close_page` closes the selected page by default and deterministically falls back only when the selected page is closed or no longer available
|
||||
- existing tools still honor explicit `pageIndex`; when omitted, they resolve through the selected page
|
||||
- selected page state is session-local MCP runtime state and is not persisted across runtime restarts
|
||||
|
||||
Phase 6 capability details:
|
||||
|
||||
- interception rules are session-local and bind to the concrete page selected when the rule is created
|
||||
- Phase 6 supports minimal request matching by `urlIncludes` and `method`
|
||||
- Phase 6A actions are limited to `continue` and `fail`
|
||||
- Phase 6B adds `fulfill` mock responses on top of the existing session-local interception model
|
||||
- fulfill rules can return a custom status code, optional response headers, and a UTF-8 response body
|
||||
- `browser_list_requests` returns recent request summaries, not full bodies
|
||||
- response bodies are applied only to the matched request and are not persisted beyond the current MCP session
|
||||
|
||||
Phase 7 capability details:
|
||||
|
||||
- `browser_add_intercept_rule` also accepts `resourceType`, `urlPattern`, `urlRegex`, `headerMatchers`, and `priority`
|
||||
- richer matching rules remain page-bound and session-local; creating a rule still binds it to the concrete page selected at creation time
|
||||
- higher `priority` rules win before lower-priority rules, while equal-priority rules continue to follow creation order
|
||||
- `headerMatchers` are request-matching conditions; `responseHeaders` on `fulfill` rules remain response headers
|
||||
|
||||
Phase 8 capability details:
|
||||
|
||||
- `browser_set_download_behavior` configures browser-scoped download acceptance for the current attach session; `behavior: "accept"` can use an explicit `downloadPath` or a session-local default directory
|
||||
- `browser_list_downloads` returns recent download summaries only; it does not read or return downloaded file contents
|
||||
- `browser_cancel_download` cancels an in-progress download by `downloadId` or `guid`
|
||||
- `browser_set_file_input` sets one or more local files on a matched `<input type="file">` using the existing selected-page, `pageIndex`, `pageId`, `frameSelector`, `nth`, and `pierceShadow` routing semantics
|
||||
- download controls are browser-scoped because they map to Chrome's Browser domain; file input uploads remain page-scoped selector actions
|
||||
- download behavior and recent download summaries are session-local runtime state and are not persisted across runtime restarts
|
||||
|
||||
Phase 9 capability details:
|
||||
|
||||
- `browser_drag_files` drags one or more local files onto a matched drop target by constructing page-side `File` objects and a `DataTransfer` payload, then dispatching `dragenter`, `dragover`, and `drop`
|
||||
- `browser_drag_files` reuses the existing selected-page, `pageIndex`, `pageId`, `nth`, `frameSelector`, and `pierceShadow` selector-routing semantics, and returns only a result summary rather than file contents
|
||||
- `browser_drag_element` drags a matched source element either to another matched target element or to explicit coordinates using browser-level mouse events
|
||||
- `browser_drag_element` and `browser_pointer_action` both honor selected-page routing plus explicit `pageIndex` or `pageId`; `pageIndex` and `pageId` remain mutually exclusive
|
||||
- `browser_pointer_action` is a limited fallback primitive for `move`, `down`, `up`, and `pause`; it is not a recording format, scripting DSL, or multi-pointer gesture system
|
||||
- Phase 9 remains session-local and does not add recording, replay, orchestration, touch gestures, or cross-page drag semantics
|
||||
|
||||
Phase 10A capability details:
|
||||
|
||||
- `browser_start_recording`, `browser_stop_recording`, `browser_get_recording`, and `browser_clear_recording` add a minimal recording workflow on top of the existing Browser MCP tool surface
|
||||
- recording state is session-local and is not persisted across runtime restarts
|
||||
- Phase 10A records structured steps only; replay and orchestration remain out of scope for this phase
|
||||
- high-level recording prefers `click`, `type`, `press_key`, `scroll`, and `drag_element`; unresolved interactions may be represented as `pointer_action` or warnings
|
||||
- only one active recording session is allowed per MCP runtime; if the recorded page is closed, the session stops and keeps the captured result plus a warning summary
|
||||
|
||||
Phase 10B capability details:
|
||||
|
||||
- `browser_start_replay`, `browser_get_replay`, and `browser_cancel_replay` add a minimal replay workflow that consumes Phase 10A-compatible structured steps
|
||||
- replay state is session-local and is not persisted across runtime restarts
|
||||
- replay executes steps sequentially and stops on the first failure
|
||||
- replay reuses existing Browser MCP action semantics rather than introducing a new replay DSL
|
||||
- only one active replay session is allowed per MCP runtime; orchestration remains out of scope for Phase 10B
|
||||
|
||||
Phase 10C capability details:
|
||||
|
||||
- `browser_start_orchestration`, `browser_get_orchestration`, and `browser_cancel_orchestration` add a minimal fixed-block orchestration workflow
|
||||
- orchestration state is session-local and is not persisted across runtime restarts
|
||||
- fixed orchestration blocks reuse existing wait / replay / action / query semantics rather than introducing a general DSL
|
||||
- orchestration runs blocks sequentially and stops on the first failure
|
||||
- block nesting, branching, loops, and workflow-engine behaviors remain out of scope
|
||||
|
||||
Phase 11A capability details:
|
||||
|
||||
- `assert_query` now supports structured `assertions[]` with multiple field checks in one block
|
||||
- supported operators are `equals`, `contains`, `gt`, `gte`, `lt`, and `lte`
|
||||
- assertion failures now include clearer diagnostics such as failed assertion index, field, expected value, and actual value
|
||||
- Phase 11A continues to reuse `browser_query` as the data source rather than introducing a new diagnostics runtime
|
||||
|
||||
Phase 11B capability details:
|
||||
|
||||
- `sequence` adds a single-layer grouping container on top of fixed orchestration blocks
|
||||
- sequence steps reuse existing block semantics rather than introducing a DSL
|
||||
- sequence runs steps sequentially and stops on the first failure
|
||||
- nested sequence blocks remain out of scope
|
||||
- sequence failures can report both top-level block failure and sequence-local step failure
|
||||
|
||||
Phase 11C capability details:
|
||||
|
||||
- blocks and sequence steps can opt into `continueOnError: true`
|
||||
- orchestration can continue after selected failures instead of always failing fast
|
||||
- final results now include a lightweight `failures[]` summary and failed counts
|
||||
- 11C intentionally stops short of retry, timeout override, severity, and other larger policy systems
|
||||
|
||||
Phase 11D capability details:
|
||||
|
||||
- `browser_export_artifact`, `browser_import_artifact`, `browser_list_artifacts`, and `browser_delete_artifact` add a local JSON persistence workflow
|
||||
- supported artifact kinds are `recording`, `replay`, and `orchestration`
|
||||
- imported artifacts restore session-local state only and are not executed automatically
|
||||
- artifact files carry `kind`, `version`, `name`, and `payload`
|
||||
- cloud sync, sharing, and complex template management remain out of scope
|
||||
|
||||
Phase 11E capability details:
|
||||
|
||||
- `open_page_then_run`, `select_page_then_run`, and `close_page_then_continue` add explicit cross-page orchestration blocks
|
||||
- cross-page blocks reuse existing page/tab lifecycle control and existing single-page block semantics
|
||||
- `run` accepts a normal single-page block or a single-layer `sequence`
|
||||
- nested cross-page blocks, implicit page selection, and page-graph orchestration remain out of scope
|
||||
|
||||
Minimal multi-tab workflow examples:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "browser_select_page",
|
||||
"arguments": {
|
||||
"pageIndex": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "browser_open_page",
|
||||
"arguments": {
|
||||
"url": "https://example.com/docs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "browser_add_intercept_rule",
|
||||
"arguments": {
|
||||
"resourceType": "XHR",
|
||||
"headerMatchers": [
|
||||
{ "name": "x-env", "valueIncludes": "staging" }
|
||||
],
|
||||
"priority": 10,
|
||||
"action": "fulfill",
|
||||
"statusCode": 200,
|
||||
"contentType": "application/json",
|
||||
"body": "{\"ok\":true}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A common hover-debug workflow is:
|
||||
|
||||
1. call `browser_hover` to move the browser pointer onto the card or trigger
|
||||
2. call `browser_wait_for` if the hover state needs time to appear
|
||||
3. call `browser_query` on the hover-only control to inspect `exists`, `count`, visibility, opacity, `href`, `onclick`, and bounds
|
||||
4. call `browser_take_element_screenshot` to confirm the revealed state
|
||||
5. call `browser_eval` in read-only mode when you need page-side inspection that the structured tools do not expose directly
|
||||
|
||||
Scoped selector notes:
|
||||
|
||||
- `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, `browser_take_element_screenshot`, `browser_set_file_input`, `browser_drag_files`, `browser_drag_element`, and selector-based `browser_pointer_action` moves accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible
|
||||
- the same selector-based tools accept optional `pierceShadow: true` for open shadow-root traversal
|
||||
- Phase 10A recording keeps reusing the existing selected-page routing model; recorded steps may preserve selector context such as `frameSelector` and `pierceShadow` when the target can be resolved stably
|
||||
- `browser_drag_element` reuses the same source/target selector scope for element-to-element drags; coordinate targets remain page-viewport coordinates
|
||||
- `browser_pointer_action` selector-based `move` steps resolve a matched element center before dispatching browser-level mouse events
|
||||
- closed shadow roots, frame-index routing, cross-page shared rules, request body matching, advanced boolean matcher groups, recording/replay orchestration, and touch/multi-pointer gestures are still out of scope
|
||||
|
||||
Example event wait:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "browser_wait_for_event",
|
||||
"arguments": {
|
||||
"event": { "kind": "navigation", "urlIncludes": "/checkout" },
|
||||
"timeoutMs": 2000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Codex Browser Tools
|
||||
|
||||
Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides
|
||||
@@ -240,24 +42,32 @@ The Browser screen exposes two sections:
|
||||
- enable/disable the Claude attach lane
|
||||
- choose the Chrome user-data directory
|
||||
- set the expected DevTools port
|
||||
- choose the `browser_eval` access level (`disabled`, `readonly`, `readwrite`)
|
||||
- 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
|
||||
- choose the stored `browser_eval` access level for Browser settings parity
|
||||
- review whether the detected Codex build supports managed browser overrides
|
||||
|
||||
Browser policy controls are CLI-first in this release. The dashboard remains the shared setup and
|
||||
status surface, while `ccs browser policy` is the authoritative place to decide whether browser
|
||||
tooling is auto-exposed or kept manual by default. Fresh installs, plus upgrades without an
|
||||
existing browser section, surface both lanes as off/manual until you explicitly enable them.
|
||||
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
ccs help browser
|
||||
ccs browser setup
|
||||
ccs browser status
|
||||
ccs browser doctor
|
||||
ccs browser policy
|
||||
ccs browser policy --all manual
|
||||
```
|
||||
|
||||
Use `ccs browser status` for the current state and `ccs browser doctor` for actionable
|
||||
troubleshooting guidance.
|
||||
Use `ccs browser setup` for the primary one-command setup path. Use `ccs browser status` for
|
||||
the current state, `ccs browser doctor` for read-only troubleshooting guidance, and
|
||||
`ccs browser policy` to control default browser exposure. If you only want browser access for one
|
||||
run, keep policy manual and add `--browser` to that launch.
|
||||
|
||||
### Via Config File
|
||||
|
||||
@@ -267,21 +77,46 @@ Edit `~/.ccs/config.yaml`:
|
||||
browser:
|
||||
claude:
|
||||
enabled: false
|
||||
policy: manual
|
||||
user_data_dir: "~/.ccs/browser/chrome-user-data"
|
||||
devtools_port: 9222
|
||||
eval_mode: readonly
|
||||
codex:
|
||||
enabled: true
|
||||
eval_mode: readonly
|
||||
enabled: false
|
||||
policy: manual
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `claude.policy` and `codex.policy` accept `auto` or `manual`
|
||||
- `claude.user_data_dir` is a **Chrome user-data directory**, not a display-name browser profile
|
||||
- `claude.devtools_port` is the expected remote debugging port for attach mode
|
||||
- `claude.eval_mode` controls whether `browser_eval` is disabled, read-only, or read/write for Claude Browser Attach
|
||||
- `codex.enabled` controls whether CCS injects browser tooling into Codex-target launches
|
||||
- `codex.eval_mode` is stored and surfaced in Browser settings for parity; in Phase 1, `browser_eval` enforcement primarily applies to Claude Browser Attach
|
||||
- New installs, plus upgrades without saved browser settings, default both lanes to `enabled: false` and `policy: manual`
|
||||
- `manual` keeps the lane configured but hidden until a launch explicitly opts in with `--browser`
|
||||
|
||||
## Runtime Policy Controls
|
||||
|
||||
CCS now separates **lane enablement** from **default exposure policy**:
|
||||
|
||||
- `enabled: false`
|
||||
- the lane is off; this is the default for both lanes on new installs and upgrades without saved browser settings
|
||||
- `enabled: true` + `policy: auto`
|
||||
- the lane is exposed automatically on matching launches
|
||||
- `enabled: true` + `policy: manual`
|
||||
- the lane stays configured, but CCS keeps browser tooling hidden unless the current launch uses
|
||||
`--browser`
|
||||
|
||||
One-run launch overrides:
|
||||
|
||||
```bash
|
||||
ccs browser policy --all manual
|
||||
ccs glm --browser "inspect the page"
|
||||
ccs glm --no-browser "summarize the docs"
|
||||
ccs default --target codex --browser "use the browser tools for this run"
|
||||
```
|
||||
|
||||
- `--browser` forces browser tooling on for the current launch when that lane is enabled
|
||||
- `--no-browser` suppresses browser tooling for the current launch even when policy is `auto`
|
||||
|
||||
## Environment Variable Overrides
|
||||
|
||||
@@ -292,11 +127,13 @@ CCS still supports environment-variable overrides for backward compatibility.
|
||||
| `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir |
|
||||
| `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory |
|
||||
| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override |
|
||||
| `CCS_BROWSER_EVAL_MODE` | Explicit `browser_eval` access override for Claude Browser Attach |
|
||||
|
||||
If an override is active, Browser status surfaces should report that the current session is being
|
||||
managed externally by environment variables.
|
||||
|
||||
The saved browser policy still controls default exposure. Env overrides change the effective attach
|
||||
path/port for the current shell; they do not bypass `policy: manual`.
|
||||
|
||||
Override precedence is:
|
||||
|
||||
1. `CCS_BROWSER_USER_DATA_DIR`
|
||||
@@ -316,6 +153,23 @@ legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not se
|
||||
Do not treat the generic Codex MCP editor as the primary browser setup path. CCS-managed browser
|
||||
entries should be configured from `Settings -> Browser`.
|
||||
|
||||
## Primary Setup Flow
|
||||
|
||||
The shortest supported setup path is:
|
||||
|
||||
```bash
|
||||
ccs browser setup
|
||||
```
|
||||
|
||||
That flow:
|
||||
|
||||
1. enables Claude Browser Attach in the saved CCS browser config
|
||||
2. leaves launch exposure under the saved policy, so `policy: manual` still requires `--browser`
|
||||
3. keeps the configured DevTools port normalized
|
||||
4. creates the configured browser user-data directory if needed
|
||||
5. prints the exact browser launch command for the current platform
|
||||
6. re-checks readiness and reports the next step if Chrome still needs manual attention
|
||||
|
||||
## Launching Chrome For Claude Attach
|
||||
|
||||
Claude Browser Attach needs a browser launched with remote debugging.
|
||||
@@ -336,36 +190,48 @@ chrome.exe --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.ccs\\br
|
||||
Using a dedicated CCS browser data dir is recommended. It avoids profile-locking issues and keeps
|
||||
automation state separate from your daily browser profile.
|
||||
|
||||
When Claude Browser Attach uses the recommended managed path (`~/.ccs/browser/chrome-user-data`),
|
||||
CCS now creates that directory automatically the first time it needs it. After that bootstrap step,
|
||||
the remaining requirement is a running Chrome session started with `--remote-debugging-port`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Browser status says Claude Browser Attach is disabled
|
||||
|
||||
Enable Claude Browser Attach in `Settings -> Browser` or via the browser config block in
|
||||
`~/.ccs/config.yaml`.
|
||||
Run `ccs browser setup`, enable Claude Browser Attach in `Settings -> Browser`, or edit the
|
||||
browser config block in `~/.ccs/config.yaml`.
|
||||
|
||||
### Browser status says the path is missing
|
||||
|
||||
The configured Chrome user-data directory does not exist yet.
|
||||
|
||||
1. Create the directory or use the generated launch command
|
||||
2. Start Chrome in attach mode with `--remote-debugging-port`
|
||||
1. Run `ccs browser setup`
|
||||
2. If Chrome still is not ready, use the generated launch command
|
||||
3. Rerun `ccs browser doctor`
|
||||
|
||||
If you are using the CCS-managed default path, this usually means the path could not be created
|
||||
automatically and now needs manual attention.
|
||||
|
||||
### Browser status says no running browser session was found
|
||||
|
||||
CCS could not find usable DevTools attach metadata for the configured user-data directory.
|
||||
|
||||
1. Make sure Chrome was started with `--remote-debugging-port=<port>`
|
||||
2. Make sure it is using the same `user_data_dir` configured in CCS
|
||||
3. Rerun `ccs browser doctor`
|
||||
1. Run `ccs browser setup`
|
||||
2. If needed, make sure Chrome was started with `--remote-debugging-port=<port>`
|
||||
3. Make sure it is using the same `user_data_dir` configured in CCS
|
||||
4. Rerun `ccs browser doctor`
|
||||
|
||||
For the CCS-managed default path, this is the normal first-run state after CCS bootstraps the
|
||||
directory for you.
|
||||
|
||||
### Browser status says the DevTools endpoint is unreachable
|
||||
|
||||
CCS found attach metadata, but the endpoint did not answer successfully.
|
||||
|
||||
1. Restart the attach browser session
|
||||
2. Confirm the expected port matches the real remote debugging port
|
||||
3. Rerun `ccs browser status`
|
||||
1. Run `ccs browser setup`
|
||||
2. If needed, restart the attach browser session
|
||||
3. Confirm the expected port matches the real remote debugging port
|
||||
4. Rerun `ccs browser status`
|
||||
|
||||
### Codex Browser Tools are unavailable
|
||||
|
||||
|
||||
@@ -72,7 +72,10 @@ Useful variants:
|
||||
|
||||
```bash
|
||||
ccs proxy start hf --host 127.0.0.1
|
||||
ccs proxy activate hf
|
||||
ccs proxy activate --fish
|
||||
ccs proxy status hf
|
||||
ccs proxy stop hf
|
||||
```
|
||||
|
||||
`ccs proxy activate` now prints the full local runtime contract:
|
||||
@@ -85,16 +88,26 @@ ccs proxy activate --fish
|
||||
- `API_TIMEOUT_MS`
|
||||
- `NO_PROXY`
|
||||
|
||||
## One Active Proxy Profile
|
||||
## Multiple Active Proxy Profiles
|
||||
|
||||
The current runtime is a single local proxy daemon.
|
||||
CCS now stores OpenAI-compatible proxy state per profile instead of treating the
|
||||
runtime as a singleton.
|
||||
|
||||
- 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
|
||||
- Different compatible profiles can run at the same time on separate local ports
|
||||
- `ccs proxy activate` without a profile stays convenient when only one proxy is
|
||||
running
|
||||
- When multiple proxies are running, pass the profile explicitly to
|
||||
`activate`, `status`, or `stop`
|
||||
|
||||
This is intentional to avoid breaking an in-flight Claude session by swapping
|
||||
its upstream provider out from under it.
|
||||
If you want deterministic ports, configure them in `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
port: 3456
|
||||
profile_ports:
|
||||
hf: 3456
|
||||
openai: 3461
|
||||
```
|
||||
|
||||
## Request-Time Routing
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-04-14
|
||||
Last Updated: 2026-04-19
|
||||
|
||||
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-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in.
|
||||
- **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser.<lane>.policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config.
|
||||
- **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default.
|
||||
- **2026-04-18**: **#1038** Legacy OpenAI-compatible provider writes no longer self-destruct on the next `ccs cliproxy restart`. CCS now preserves AI-provider-managed top-level sections such as `openai-compatibility` during CLIProxy config regeneration, and the legacy `openai-compat` manager now rewrites only its own YAML section instead of dumping the whole file and stripping the generated version header. Regression coverage now proves the legacy helper keeps the generated header intact and that OpenAI-compatible connectors survive regeneration.
|
||||
- **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor.
|
||||
- **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads.
|
||||
- **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.72.0",
|
||||
"version": "7.73.0-dev.2",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
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.
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,148 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -15,6 +15,17 @@ if [[ ! -f AGENTS.md ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TRACKED_PLANS="$(git ls-files -- plans)"
|
||||
if [[ -n "$TRACKED_PLANS" ]]; then
|
||||
echo "[X] Tracked files found under plans/."
|
||||
echo " plans/ is workspace-only and must stay ignored."
|
||||
while IFS= read -r tracked_path; do
|
||||
echo " $tracked_path"
|
||||
done <<< "$TRACKED_PLANS"
|
||||
echo " Remove them from the index with: git rm -r --cached plans"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||
if [[ -z "$CURRENT_BRANCH" || "$CURRENT_BRANCH" == "HEAD" ]]; then
|
||||
echo "[i] Detached HEAD detected. Skipping pre-push CI parity gate."
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
UI_DIR="$ROOT_DIR/ui"
|
||||
|
||||
ensure_root_deps() {
|
||||
echo "[deps] Syncing root dependencies"
|
||||
(cd "$ROOT_DIR" && bun install --frozen-lockfile)
|
||||
}
|
||||
|
||||
ensure_ui_deps() {
|
||||
echo "[deps] Syncing UI dependencies"
|
||||
(cd "$UI_DIR" && bun install --frozen-lockfile)
|
||||
}
|
||||
|
||||
ensure_root_deps
|
||||
ensure_ui_deps
|
||||
@@ -7,7 +7,11 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../../utils/ui';
|
||||
import { getClaudeCliInfo } from '../../utils/claude-detector';
|
||||
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
|
||||
import {
|
||||
escapeShellArg,
|
||||
getWindowsEscapedCommandShell,
|
||||
stripClaudeCodeEnv,
|
||||
} from '../../utils/shell-executor';
|
||||
import { isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { ProfileMetadata } from '../../types';
|
||||
import {
|
||||
@@ -249,7 +253,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
env: childEnv,
|
||||
});
|
||||
} else {
|
||||
|
||||
+83
-56
@@ -39,8 +39,11 @@ import {
|
||||
import {
|
||||
appendBrowserToolArgs,
|
||||
ensureBrowserMcpOrThrow,
|
||||
getBlockedBrowserOverrideWarning,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
resolveBrowserRuntimeEnv,
|
||||
resolveBrowserExposure,
|
||||
resolveBrowserLaunchFlagResolution,
|
||||
resolveOptionalBrowserAttachRuntime,
|
||||
syncBrowserMcpToConfigDir,
|
||||
} from './utils/browser';
|
||||
import {
|
||||
@@ -83,6 +86,7 @@ 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 type { BrowserLaunchOverride } from './utils/browser';
|
||||
|
||||
// Import target adapter system
|
||||
import {
|
||||
@@ -137,11 +141,21 @@ const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'hig
|
||||
const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
|
||||
|
||||
function resolveCodexRuntimeConfigOverrides(
|
||||
target: ReturnType<typeof resolveTargetType>
|
||||
target: ReturnType<typeof resolveTargetType>,
|
||||
browserLaunchOverride: BrowserLaunchOverride | undefined
|
||||
): string[] {
|
||||
if (target !== 'codex' || !getBrowserConfig().codex.enabled) {
|
||||
if (target !== 'codex') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const codexBrowserExposure = resolveBrowserExposure(
|
||||
getBrowserConfig().codex,
|
||||
browserLaunchOverride
|
||||
);
|
||||
if (!codexBrowserExposure.exposeForLaunch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return buildCodexBrowserMcpOverrides();
|
||||
}
|
||||
|
||||
@@ -449,6 +463,16 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
args = normalizeLegacyCursorArgs(args);
|
||||
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
try {
|
||||
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args);
|
||||
browserLaunchOverride = browserLaunchFlags.override;
|
||||
args = browserLaunchFlags.argsWithoutFlags;
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
cliLogger.info('command.start', 'CLI invocation started', {
|
||||
command: args[0] || 'default',
|
||||
@@ -604,6 +628,7 @@ async function main(): Promise<void> {
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect Claude CLI (needed for claude target and all CLIProxy-derived flows)
|
||||
@@ -696,7 +721,38 @@ async function main(): Promise<void> {
|
||||
|
||||
// For non-claude targets, verify target binary exists once and pass it through.
|
||||
const targetBinaryInfo = targetAdapter?.detectBinary() ?? null;
|
||||
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(resolvedTarget);
|
||||
const browserConfig = getBrowserConfig();
|
||||
const claudeAttachConfig =
|
||||
resolvedTarget === 'claude'
|
||||
? getEffectiveClaudeBrowserAttachConfig(browserConfig)
|
||||
: undefined;
|
||||
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(
|
||||
resolvedTarget,
|
||||
browserLaunchOverride
|
||||
);
|
||||
const claudeBrowserExposure =
|
||||
resolvedTarget === 'claude'
|
||||
? resolveBrowserExposure(
|
||||
{
|
||||
enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled,
|
||||
policy: browserConfig.claude.policy,
|
||||
},
|
||||
browserLaunchOverride
|
||||
)
|
||||
: undefined;
|
||||
const codexBrowserExposure =
|
||||
resolvedTarget === 'codex'
|
||||
? resolveBrowserExposure(browserConfig.codex, browserLaunchOverride)
|
||||
: undefined;
|
||||
const blockedBrowserOverrideWarning =
|
||||
resolvedTarget === 'claude' && claudeBrowserExposure
|
||||
? getBlockedBrowserOverrideWarning('Claude Browser Attach', claudeBrowserExposure)
|
||||
: resolvedTarget === 'codex' && codexBrowserExposure
|
||||
? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure)
|
||||
: undefined;
|
||||
if (blockedBrowserOverrideWarning) {
|
||||
console.error(warn(blockedBrowserOverrideWarning));
|
||||
}
|
||||
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
|
||||
const displayName = targetAdapter?.displayName || resolvedTarget;
|
||||
console.error(fail(`${displayName} CLI not found.`));
|
||||
@@ -1056,14 +1112,19 @@ async function main(): Promise<void> {
|
||||
// Settings-based profiles (glm, glmt) are third-party providers
|
||||
const imageAnalysisMcpReady =
|
||||
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
|
||||
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
||||
const browserAttachConfig =
|
||||
resolvedTarget === 'claude'
|
||||
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
|
||||
const browserAttachRuntime =
|
||||
resolvedTarget === 'claude' &&
|
||||
claudeBrowserExposure?.exposeForLaunch &&
|
||||
claudeAttachConfig?.enabled
|
||||
? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig)
|
||||
: undefined;
|
||||
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
|
||||
if (browserAttachRuntime?.warning) {
|
||||
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
|
||||
}
|
||||
if (resolvedTarget === 'claude') {
|
||||
ensureWebSearchMcpOrThrow();
|
||||
if (browserAttachConfig?.enabled) {
|
||||
if (browserRuntimeEnv) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
}
|
||||
}
|
||||
@@ -1088,15 +1149,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
||||
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
if (!syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir) && inheritedClaudeConfigDir) {
|
||||
console.error(
|
||||
warn(
|
||||
'Image Analysis MCP config could not be synced into the inherited Claude instance. This session will continue with the current fallback behavior.'
|
||||
)
|
||||
);
|
||||
}
|
||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
if (
|
||||
browserAttachConfig?.enabled &&
|
||||
browserRuntimeEnv &&
|
||||
inheritedClaudeConfigDir &&
|
||||
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
||||
) {
|
||||
@@ -1274,13 +1329,7 @@ async function main(): Promise<void> {
|
||||
...imageAnalysisEnv,
|
||||
CCS_CURRENT_PROVIDER: '',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
CCS_IMAGE_ANALYSIS_BACKEND_ID: '',
|
||||
CCS_IMAGE_ANALYSIS_MODEL: '',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '',
|
||||
};
|
||||
delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY;
|
||||
delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED;
|
||||
} else if (imageAnalysisStatus.proxyReadiness === 'stopped') {
|
||||
const ensureServiceResult = await ensureCliproxyService(
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
@@ -1296,13 +1345,7 @@ async function main(): Promise<void> {
|
||||
...imageAnalysisEnv,
|
||||
CCS_CURRENT_PROVIDER: '',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
CCS_IMAGE_ANALYSIS_BACKEND_ID: '',
|
||||
CCS_IMAGE_ANALYSIS_MODEL: '',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '',
|
||||
};
|
||||
delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY;
|
||||
delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1318,18 +1361,6 @@ async function main(): Promise<void> {
|
||||
|
||||
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
|
||||
// values from prior sessions cannot leak into the active profile.
|
||||
if (browserAttachConfig?.enabled) {
|
||||
browserRuntimeEnv = {
|
||||
...(await resolveBrowserRuntimeEnv({
|
||||
profileDir: browserAttachConfig.userDataDir,
|
||||
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
|
||||
? String(browserAttachConfig.devtoolsPort)
|
||||
: undefined,
|
||||
evalMode: browserAttachConfig.evalMode,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
...globalEnv,
|
||||
...settingsEnv,
|
||||
@@ -1489,24 +1520,20 @@ async function main(): Promise<void> {
|
||||
CCS_WEBSEARCH_SKIP: '1',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
};
|
||||
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
||||
const browserAttachConfig =
|
||||
resolvedTarget === 'claude'
|
||||
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
|
||||
const browserAttachRuntime =
|
||||
resolvedTarget === 'claude' &&
|
||||
claudeBrowserExposure?.exposeForLaunch &&
|
||||
claudeAttachConfig?.enabled
|
||||
? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig)
|
||||
: undefined;
|
||||
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
|
||||
if (browserAttachRuntime?.warning) {
|
||||
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
|
||||
}
|
||||
|
||||
if (resolvedTarget === 'claude') {
|
||||
if (browserAttachConfig?.enabled) {
|
||||
if (browserRuntimeEnv) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
browserRuntimeEnv = {
|
||||
...(await resolveBrowserRuntimeEnv({
|
||||
profileDir: browserAttachConfig.userDataDir,
|
||||
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
|
||||
? String(browserAttachConfig.devtoolsPort)
|
||||
: undefined,
|
||||
})),
|
||||
CCS_BROWSER_EVAL_MODE: browserAttachConfig.evalMode,
|
||||
};
|
||||
Object.assign(envVars, browserRuntimeEnv);
|
||||
}
|
||||
const defaultContinuityInheritance = await resolveProfileContinuityInheritance({
|
||||
@@ -1524,7 +1551,7 @@ async function main(): Promise<void> {
|
||||
if (defaultContinuityInheritance.claudeConfigDir) {
|
||||
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
|
||||
if (
|
||||
browserAttachConfig?.enabled &&
|
||||
browserRuntimeEnv &&
|
||||
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
|
||||
) {
|
||||
throw new Error(
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as path from 'path';
|
||||
import type { CLIProxyProvider, ProviderConfig } from '../types';
|
||||
import { getProviderDisplayName } from '../provider-capabilities';
|
||||
import { getModelMappingFromConfig } from '../base-config-loader';
|
||||
import { AI_PROVIDER_FAMILY_IDS } from '../ai-providers/types';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager';
|
||||
import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer';
|
||||
@@ -42,6 +43,16 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
*/
|
||||
export const CLIPROXY_CONFIG_VERSION = 17;
|
||||
|
||||
interface RegenerateConfigOptions {
|
||||
configPath?: string;
|
||||
authDir?: string;
|
||||
}
|
||||
|
||||
interface PreservedYamlSection {
|
||||
key: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface OAuthModelAliasEntry {
|
||||
name: string;
|
||||
alias: string;
|
||||
@@ -739,14 +750,18 @@ function extractYamlSection(content: string, sectionKey: string): string {
|
||||
* @param port - Default port to use if not found in existing config
|
||||
* @returns Path to new config file
|
||||
*/
|
||||
export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
const configPath = getConfigPathForPort(port);
|
||||
export function regenerateConfig(
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
options?: RegenerateConfigOptions
|
||||
): string {
|
||||
const configPath = options?.configPath ?? getConfigPathForPort(port);
|
||||
const authDir = options?.authDir ?? getAuthDir();
|
||||
|
||||
// Preserve user settings from existing config
|
||||
let effectivePort = port;
|
||||
let userApiKeys: string[] = [];
|
||||
let claudeApiKeySection = '';
|
||||
let existingAliases = '';
|
||||
const preservedSections: PreservedYamlSection[] = [];
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
@@ -761,8 +776,16 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
// Preserve user-added API keys (fix for issue #200)
|
||||
userApiKeys = parseUserApiKeys(content);
|
||||
|
||||
// Preserve claude-api-key section (managed via dashboard/API)
|
||||
claudeApiKeySection = extractYamlSection(content, 'claude-api-key');
|
||||
// Preserve AI provider sections managed outside the generated defaults.
|
||||
for (const familyId of AI_PROVIDER_FAMILY_IDS) {
|
||||
const sectionBody = extractYamlSection(content, familyId);
|
||||
if (sectionBody) {
|
||||
preservedSections.push({
|
||||
key: familyId,
|
||||
body: sectionBody,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve user customizations while pruning legacy generated Gemini preview noise.
|
||||
const existingConfigVersion = getConfigVersionFromContent(content);
|
||||
@@ -787,14 +810,14 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
|
||||
// Ensure directories exist
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Generate fresh config with preserved user API keys and aliases
|
||||
let configContent = generateUnifiedConfigContent(effectivePort, userApiKeys, existingAliases);
|
||||
|
||||
// Re-append claude-api-key section if it existed
|
||||
if (claudeApiKeySection) {
|
||||
configContent += `claude-api-key:\n${claudeApiKeySection}\n`;
|
||||
// Re-append managed top-level sections that are not part of the generated defaults.
|
||||
for (const section of preservedSections) {
|
||||
configContent += `${section.key}:\n${section.body}\n`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
|
||||
|
||||
@@ -17,7 +17,7 @@ import * as path from 'path';
|
||||
import { ProgressIndicator } from '../../utils/progress-indicator';
|
||||
import { ok, fail, info, warn } from '../../utils/ui';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { escapeShellArg } from '../../utils/shell-executor';
|
||||
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
||||
import { ensureCLIProxyBinary } from '../binary-manager';
|
||||
import {
|
||||
generateConfig,
|
||||
@@ -65,9 +65,13 @@ import {
|
||||
} from '../../utils/image-analysis';
|
||||
import {
|
||||
appendBrowserToolArgs,
|
||||
type BrowserLaunchOverride,
|
||||
ensureBrowserMcpOrThrow,
|
||||
getBlockedBrowserOverrideWarning,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
resolveBrowserRuntimeEnv,
|
||||
resolveBrowserExposure,
|
||||
resolveBrowserLaunchFlagResolution,
|
||||
resolveOptionalBrowserAttachRuntime,
|
||||
syncBrowserMcpToConfigDir,
|
||||
} from '../../utils/browser';
|
||||
import {
|
||||
@@ -245,6 +249,33 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
let argsWithoutBrowserFlags = argsWithoutProxy;
|
||||
try {
|
||||
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy);
|
||||
browserLaunchOverride = browserLaunchFlags.override;
|
||||
argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags;
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const browserConfig = getBrowserConfig();
|
||||
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig);
|
||||
const claudeBrowserExposure = resolveBrowserExposure(
|
||||
{
|
||||
enabled: browserAttachConfig.enabled,
|
||||
policy: browserConfig.claude.policy,
|
||||
},
|
||||
browserLaunchOverride
|
||||
);
|
||||
const blockedBrowserOverrideWarning = getBlockedBrowserOverrideWarning(
|
||||
'Claude Browser Attach',
|
||||
claudeBrowserExposure
|
||||
);
|
||||
if (blockedBrowserOverrideWarning) {
|
||||
console.error(warn(blockedBrowserOverrideWarning));
|
||||
}
|
||||
|
||||
// Port resolution and validation
|
||||
if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) {
|
||||
@@ -264,8 +295,15 @@ export async function execClaudeWithCLIProxy(
|
||||
// Setup first-class CCS WebSearch runtime
|
||||
ensureWebSearchMcpOrThrow();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig());
|
||||
if (browserAttachConfig.enabled) {
|
||||
const browserAttachRuntime =
|
||||
browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch
|
||||
? await resolveOptionalBrowserAttachRuntime(browserAttachConfig)
|
||||
: undefined;
|
||||
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
|
||||
if (browserAttachRuntime?.warning) {
|
||||
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
|
||||
}
|
||||
if (browserRuntimeEnv) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
}
|
||||
displayWebSearchStatus();
|
||||
@@ -1054,7 +1092,7 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
if (
|
||||
browserAttachConfig.enabled &&
|
||||
browserRuntimeEnv &&
|
||||
inheritedClaudeConfigDir &&
|
||||
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
||||
) {
|
||||
@@ -1157,18 +1195,6 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
// 11. Build final environment with all proxy chains
|
||||
const browserRuntimeEnv = browserAttachConfig.enabled
|
||||
? {
|
||||
...(await resolveBrowserRuntimeEnv({
|
||||
profileDir: browserAttachConfig.userDataDir,
|
||||
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
|
||||
? String(browserAttachConfig.devtoolsPort)
|
||||
: undefined,
|
||||
evalMode: browserAttachConfig.evalMode,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider,
|
||||
useRemoteProxy,
|
||||
@@ -1265,7 +1291,7 @@ export async function execClaudeWithCLIProxy(
|
||||
'--settings',
|
||||
...PROXY_CLI_FLAGS,
|
||||
];
|
||||
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
|
||||
const claudeArgs = argsWithoutBrowserFlags.filter((arg, idx) => {
|
||||
if (ccsFlags.includes(arg)) return false;
|
||||
if (arg.startsWith('--kiro-auth-method=')) return false;
|
||||
if (arg.startsWith('--kiro-idc-start-url=')) return false;
|
||||
@@ -1275,14 +1301,14 @@ export async function execClaudeWithCLIProxy(
|
||||
if (arg.startsWith('--effort=')) return false;
|
||||
if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false;
|
||||
if (
|
||||
argsWithoutProxy[idx - 1] === '--use' ||
|
||||
argsWithoutProxy[idx - 1] === '--nickname' ||
|
||||
argsWithoutProxy[idx - 1] === '--kiro-auth-method' ||
|
||||
argsWithoutProxy[idx - 1] === '--kiro-idc-start-url' ||
|
||||
argsWithoutProxy[idx - 1] === '--kiro-idc-region' ||
|
||||
argsWithoutProxy[idx - 1] === '--kiro-idc-flow' ||
|
||||
argsWithoutProxy[idx - 1] === '--thinking' ||
|
||||
argsWithoutProxy[idx - 1] === '--effort'
|
||||
argsWithoutBrowserFlags[idx - 1] === '--use' ||
|
||||
argsWithoutBrowserFlags[idx - 1] === '--nickname' ||
|
||||
argsWithoutBrowserFlags[idx - 1] === '--kiro-auth-method' ||
|
||||
argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-start-url' ||
|
||||
argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-region' ||
|
||||
argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-flow' ||
|
||||
argsWithoutBrowserFlags[idx - 1] === '--thinking' ||
|
||||
argsWithoutBrowserFlags[idx - 1] === '--effort'
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
@@ -1321,7 +1347,7 @@ export async function execClaudeWithCLIProxy(
|
||||
claude = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
env: tracedEnv,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -290,10 +290,24 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
displayName: 'Claude (Anthropic)',
|
||||
defaultModel: 'claude-sonnet-4-6',
|
||||
models: [
|
||||
{
|
||||
id: 'claude-opus-4-7',
|
||||
name: 'Claude Opus 4.7',
|
||||
description: 'Latest flagship model',
|
||||
nativeImageInput: true,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
min: 1024,
|
||||
max: 128000,
|
||||
zeroAllowed: false,
|
||||
dynamicAllowed: true,
|
||||
},
|
||||
extendedContext: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-6',
|
||||
name: 'Claude Opus 4.6',
|
||||
description: 'Latest flagship model',
|
||||
description: 'Previous flagship model',
|
||||
nativeImageInput: true,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getCliproxyConfigPath } from './config-generator';
|
||||
import { configExists, getCliproxyConfigPath, regenerateConfig } from './config-generator';
|
||||
import { rewriteTopLevelYamlSection } from './ai-providers/config-yaml-sections';
|
||||
|
||||
/** Model alias configuration */
|
||||
export interface OpenAICompatModel {
|
||||
@@ -62,17 +63,35 @@ function loadConfig(): ConfigYaml {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config.yaml with proper formatting
|
||||
* Persist only the openai-compatibility section so the generated config header
|
||||
* and unrelated user-managed sections survive legacy writes.
|
||||
*/
|
||||
function saveConfig(config: ConfigYaml): void {
|
||||
const configPath = getCliproxyConfigPath();
|
||||
const content = yaml.dump(config, {
|
||||
lineWidth: -1, // Disable line wrapping
|
||||
quotingType: '"',
|
||||
forceQuotes: false,
|
||||
});
|
||||
if (!configExists()) {
|
||||
regenerateConfig();
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, content, { mode: 0o600 });
|
||||
const currentContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : '';
|
||||
const sectionContent =
|
||||
config['openai-compatibility'] && config['openai-compatibility'].length > 0
|
||||
? yaml.dump(
|
||||
{ 'openai-compatibility': config['openai-compatibility'] },
|
||||
{
|
||||
lineWidth: -1,
|
||||
quotingType: '"',
|
||||
forceQuotes: false,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
const nextContent = rewriteTopLevelYamlSection(
|
||||
currentContent,
|
||||
'openai-compatibility',
|
||||
sectionContent
|
||||
);
|
||||
const tempPath = `${configPath}.tmp`;
|
||||
fs.writeFileSync(tempPath, nextContent, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mutateUnifiedConfig, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { regenerateConfig } from './config/generator';
|
||||
import { getAuthDir, getConfigPathForPort } from './config/path-resolver';
|
||||
import {
|
||||
fetchCliproxyRoutingResponse,
|
||||
getCliproxyRoutingTarget,
|
||||
@@ -98,6 +99,8 @@ export async function applyCliproxyRoutingStrategy(
|
||||
strategy: CliproxyRoutingStrategy
|
||||
): Promise<CliproxyRoutingApplyResult> {
|
||||
const target = getCliproxyRoutingTarget();
|
||||
const configPath = getConfigPathForPort(target.port);
|
||||
const authDir = getAuthDir();
|
||||
|
||||
if (target.isRemote) {
|
||||
await updateLiveCliproxyRoutingStrategy(strategy);
|
||||
@@ -116,7 +119,7 @@ export async function applyCliproxyRoutingStrategy(
|
||||
config.cliproxy.routing = { strategy };
|
||||
}
|
||||
});
|
||||
regenerateConfig(target.port);
|
||||
regenerateConfig(target.port, { configPath, authDir });
|
||||
|
||||
try {
|
||||
await updateLiveCliproxyRoutingStrategy(strategy);
|
||||
|
||||
+310
-15
@@ -1,10 +1,14 @@
|
||||
import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser';
|
||||
import * as browserUtils from '../utils/browser';
|
||||
import { getBrowserConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import type { BrowserToolPolicy } from '../config/unified-config-types';
|
||||
import { getCcsPathDisplay } from '../utils/config-manager';
|
||||
import { getNodePlatformKey } from '../utils/browser/platform';
|
||||
import { color, dim, header, initUI, subheader } from '../utils/ui';
|
||||
|
||||
type HelpWriter = (line: string) => void;
|
||||
type BrowserLane = 'claude' | 'codex' | 'all';
|
||||
|
||||
function summarizeBrowserHealth(status: BrowserStatusPayload): {
|
||||
function summarizeBrowserHealth(status: browserUtils.BrowserStatusPayload): {
|
||||
label: 'ready' | 'partial' | 'action required';
|
||||
exitCode: 0 | 1;
|
||||
} {
|
||||
@@ -20,13 +24,40 @@ function summarizeBrowserHealth(status: BrowserStatusPayload): {
|
||||
return { label: 'ready', exitCode: 0 };
|
||||
}
|
||||
|
||||
function isBrowserPolicy(value: string): value is BrowserToolPolicy {
|
||||
return value === 'auto' || value === 'manual';
|
||||
}
|
||||
|
||||
function parseBrowserLane(value: string | undefined): BrowserLane | undefined {
|
||||
if (value === 'claude' || value === 'codex' || value === 'all') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function writeCommandTable(writeLine: HelpWriter): void {
|
||||
writeLine(subheader('Commands'));
|
||||
writeLine(
|
||||
` ${color('ccs browser status', 'command')} Show Claude attach and Codex browser readiness`
|
||||
` ${color('ccs browser setup', 'command')} Configure Claude Browser Attach and print the manual launch command`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser doctor', 'command')} Explain what is missing and how to fix it`
|
||||
` ${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(
|
||||
` ${color('ccs browser policy', 'command')} Show the saved browser exposure policy and safe defaults`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser policy --all manual', 'command')} Keep browser tooling hidden unless a launch uses --browser`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser enable <claude|codex|all>', 'command')} Turn a browser lane on without forcing auto-exposure`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser disable <claude|codex|all>', 'command')} Turn a browser lane off`
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
@@ -36,19 +67,41 @@ function writeIntro(writeLine: HelpWriter): void {
|
||||
writeLine(
|
||||
' Codex Browser Tools inject managed Playwright MCP overrides into Codex-target launches.'
|
||||
);
|
||||
writeLine(
|
||||
' New installs, plus upgrades without saved browser settings, keep both lanes off by default; enable a lane and use `--browser` when you want browser access.'
|
||||
);
|
||||
writeLine('');
|
||||
writeLine(subheader('Launch Overrides'));
|
||||
writeLine(
|
||||
` ${color('--browser', 'command')} Force browser tooling on for the current launch when the lane is enabled`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('--no-browser', 'command')} Suppress browser tooling for the current launch even when policy is auto`
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
function writeLaunchPolicy(policy: BrowserToolPolicy, writeLine: HelpWriter): void {
|
||||
writeLine(` Policy: ${browserUtils.describeBrowserPolicy(policy)}`);
|
||||
writeLine(` Default launch behavior: ${browserUtils.describeDefaultBrowserExposure(policy)}`);
|
||||
}
|
||||
|
||||
function writeClaudeStatus(
|
||||
status: BrowserStatusPayload['claude'],
|
||||
status: browserUtils.BrowserStatusPayload['claude'],
|
||||
writeLine: HelpWriter,
|
||||
includeLaunchGuidance: boolean
|
||||
): void {
|
||||
const userDataDirDisplay =
|
||||
status.effectiveUserDataDir === status.recommendedUserDataDir
|
||||
? getCcsPathDisplay('browser', 'chrome-user-data')
|
||||
: status.effectiveUserDataDir;
|
||||
|
||||
writeLine(subheader('Claude Browser Attach'));
|
||||
writeLine(` State: ${status.state}`);
|
||||
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
|
||||
writeLaunchPolicy(status.policy, writeLine);
|
||||
writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`);
|
||||
writeLine(` User data dir: ${status.effectiveUserDataDir}`);
|
||||
writeLine(` User data dir: ${userDataDirDisplay}`);
|
||||
writeLine(` DevTools port: ${status.devtoolsPort}`);
|
||||
writeLine(` browser_eval access: ${status.evalMode}`);
|
||||
writeLine(` Managed MCP: ${status.managedMcpServerName}`);
|
||||
@@ -65,10 +118,14 @@ function writeClaudeStatus(
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: HelpWriter): void {
|
||||
function writeCodexStatus(
|
||||
status: browserUtils.BrowserStatusPayload['codex'],
|
||||
writeLine: HelpWriter
|
||||
): void {
|
||||
writeLine(subheader('Codex Browser Tools'));
|
||||
writeLine(` State: ${status.state}`);
|
||||
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
|
||||
writeLaunchPolicy(status.policy, writeLine);
|
||||
writeLine(` Managed server: ${status.serverName}`);
|
||||
writeLine(` browser_eval access: ${status.evalMode}`);
|
||||
writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`);
|
||||
@@ -81,27 +138,197 @@ function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: Help
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
function writeSetupSummary(
|
||||
result: browserUtils.BrowserSetupResult,
|
||||
writeLine: HelpWriter,
|
||||
label: string
|
||||
): void {
|
||||
writeLine(subheader('Overall'));
|
||||
writeLine(` Command: ${label}`);
|
||||
writeLine(` Result: ${result.ready ? 'ready' : 'action required'}`);
|
||||
writeLine(` Config updated: ${result.configUpdated ? 'yes' : 'no'}`);
|
||||
writeLine(` Created user-data dir: ${result.createdUserDataDir ? 'yes' : 'no'}`);
|
||||
writeLine(` Browser MCP ready: ${result.mcpReady ? 'yes' : 'no'}`);
|
||||
if (result.notes.length > 0) {
|
||||
for (const note of result.notes) {
|
||||
writeLine(` Note: ${note}`);
|
||||
}
|
||||
}
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
function writePolicySummary(writeLine: HelpWriter): void {
|
||||
const config = getBrowserConfig();
|
||||
|
||||
writeLine(header('ccs browser policy'));
|
||||
writeLine('');
|
||||
writeIntro(writeLine);
|
||||
writeLine(
|
||||
' New installs and upgrades without saved browser settings: both lanes start disabled and manual.'
|
||||
);
|
||||
writeLine('');
|
||||
writeLine(subheader('Claude Browser Attach'));
|
||||
writeLine(` Enabled: ${config.claude.enabled ? 'yes' : 'no'}`);
|
||||
writeLaunchPolicy(config.claude.policy, writeLine);
|
||||
writeLine('');
|
||||
writeLine(subheader('Codex Browser Tools'));
|
||||
writeLine(` Enabled: ${config.codex.enabled ? 'yes' : 'no'}`);
|
||||
writeLaunchPolicy(config.codex.policy, writeLine);
|
||||
writeLine('');
|
||||
writeLine(subheader('Examples'));
|
||||
writeLine(
|
||||
` ${color('ccs browser policy --all manual', 'command')} ${dim('# keep browser tooling hidden until a launch opts in')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs glm --browser "open the site"', 'command')} ${dim('# one-run browser opt-in')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs glm --no-browser "summarize the docs"', 'command')} ${dim('# one-run browser opt-out')}`
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
function writeToggleSummary(
|
||||
subcommand: 'enable' | 'disable',
|
||||
lane: BrowserLane,
|
||||
writeLine: HelpWriter
|
||||
) {
|
||||
const config = getBrowserConfig();
|
||||
const verb = subcommand === 'enable' ? 'enabled' : 'disabled';
|
||||
|
||||
writeLine(header(`ccs browser ${subcommand}`));
|
||||
writeLine('');
|
||||
writeLine(` Updated ${lane} browser lane${lane === 'all' ? 's' : ''}.`);
|
||||
writeLine(` Browser lanes are now ${verb} as requested.`);
|
||||
if (subcommand === 'enable') {
|
||||
writeLine(
|
||||
' Enabled lanes still respect policy, so browser access stays hidden until `--browser` while policy is manual.'
|
||||
);
|
||||
}
|
||||
writeLine('');
|
||||
writeLine(subheader('Current State'));
|
||||
writeLine(` Claude enabled: ${config.claude.enabled ? 'yes' : 'no'}`);
|
||||
writeLine(` Claude policy: ${config.claude.policy}`);
|
||||
writeLine(` Codex enabled: ${config.codex.enabled ? 'yes' : 'no'}`);
|
||||
writeLine(` Codex policy: ${config.codex.policy}`);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
function updateBrowserPolicies(updates: {
|
||||
claude?: BrowserToolPolicy;
|
||||
codex?: BrowserToolPolicy;
|
||||
}): void {
|
||||
mutateUnifiedConfig((config) => {
|
||||
const current = getBrowserConfig();
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: current.claude.enabled,
|
||||
policy: updates.claude ?? current.claude.policy,
|
||||
user_data_dir: current.claude.user_data_dir,
|
||||
devtools_port: current.claude.devtools_port,
|
||||
},
|
||||
codex: {
|
||||
enabled: current.codex.enabled,
|
||||
policy: updates.codex ?? current.codex.policy,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateBrowserEnabled(subcommand: 'enable' | 'disable', lane: BrowserLane): void {
|
||||
const nextEnabled = subcommand === 'enable';
|
||||
mutateUnifiedConfig((config) => {
|
||||
const current = getBrowserConfig();
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: lane === 'all' || lane === 'claude' ? nextEnabled : current.claude.enabled,
|
||||
policy: current.claude.policy,
|
||||
user_data_dir: current.claude.user_data_dir,
|
||||
devtools_port: current.claude.devtools_port,
|
||||
},
|
||||
codex: {
|
||||
enabled: lane === 'all' || lane === 'codex' ? nextEnabled : current.codex.enabled,
|
||||
policy: current.codex.policy,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parsePolicyArgs(args: string[]): {
|
||||
claude?: BrowserToolPolicy;
|
||||
codex?: BrowserToolPolicy;
|
||||
error?: string;
|
||||
} {
|
||||
let claude: BrowserToolPolicy | undefined;
|
||||
let codex: BrowserToolPolicy | undefined;
|
||||
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const flag = args[index];
|
||||
const value = args[index + 1];
|
||||
|
||||
if (!flag) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (flag !== '--all' && flag !== '--claude' && flag !== '--codex') {
|
||||
return { error: `Unknown browser policy argument: ${flag}` };
|
||||
}
|
||||
|
||||
if (!value || value.startsWith('-')) {
|
||||
return { error: `${flag} requires a value: auto or manual.` };
|
||||
}
|
||||
|
||||
if (!isBrowserPolicy(value)) {
|
||||
return { error: `${flag} must be one of: auto, manual.` };
|
||||
}
|
||||
|
||||
if (flag === '--all' || flag === '--claude') {
|
||||
claude = value;
|
||||
}
|
||||
if (flag === '--all' || flag === '--codex') {
|
||||
codex = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { claude, codex };
|
||||
}
|
||||
|
||||
function isHelpRequest(args: string[]): boolean {
|
||||
return args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h');
|
||||
}
|
||||
|
||||
export async function showBrowserHelp(writeLine: HelpWriter = console.log): Promise<void> {
|
||||
await initUI();
|
||||
writeLine(header('CCS Browser Help'));
|
||||
writeLine('');
|
||||
writeIntro(writeLine);
|
||||
writeLine(subheader('Usage'));
|
||||
writeLine(` ${color('ccs browser <status|doctor>', 'command')}`);
|
||||
writeLine(` ${color('ccs browser <setup|status|doctor|policy|enable|disable>', '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(
|
||||
' New installs and upgrades without saved browser settings keep both lanes off by default, so enabling a lane does not auto-expose browser tooling unless policy is set to auto.'
|
||||
);
|
||||
writeLine('');
|
||||
writeLine(subheader('Examples'));
|
||||
writeLine(` ${color('ccs browser status', 'command')} ${dim('# Quick readiness snapshot')}`);
|
||||
writeLine(
|
||||
` ${color('ccs browser doctor', 'command')} ${dim('# Detailed troubleshooting output')}`
|
||||
` ${color('ccs browser setup', 'command')} ${dim('# configure browser attach and print the manual launch command')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs config', 'command')} ${dim('# Open Settings > Browser in the dashboard')}`
|
||||
` ${color('ccs browser policy --all manual', 'command')} ${dim('# keep browser tooling hidden until a launch opts in')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs glm --browser "inspect app"', 'command')} ${dim('# one-run browser opt-in')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs glm --no-browser "summarize app"', 'command')} ${dim('# one-run browser opt-out')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs config', 'command')} ${dim('# open Settings > Browser in the dashboard')}`
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
@@ -110,24 +337,92 @@ export async function handleBrowserCommand(
|
||||
args: string[],
|
||||
writeLine: HelpWriter = console.log
|
||||
): Promise<void> {
|
||||
const subcommand = args[0];
|
||||
if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
|
||||
if (isHelpRequest(args)) {
|
||||
await showBrowserHelp(writeLine);
|
||||
return;
|
||||
}
|
||||
|
||||
const subcommand = args[0];
|
||||
if (subcommand === 'setup') {
|
||||
if (args.includes('--no-launch')) {
|
||||
await initUI();
|
||||
writeLine(color('`ccs browser setup` no longer supports `--no-launch`.', 'error'));
|
||||
writeLine(` ${dim('Setup is config-only and already prints the manual launch command.')}`);
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await initUI();
|
||||
const result = await browserUtils.runBrowserSetup();
|
||||
|
||||
const label = 'ccs browser setup';
|
||||
writeLine(header(label));
|
||||
writeLine('');
|
||||
writeIntro(writeLine);
|
||||
writeSetupSummary(result, writeLine, label);
|
||||
writeClaudeStatus(result.status.claude, writeLine, !result.ready);
|
||||
writeCodexStatus(result.status.codex, writeLine);
|
||||
process.exitCode = result.ready ? 0 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'policy') {
|
||||
const parsed = parsePolicyArgs(args.slice(1));
|
||||
await initUI();
|
||||
|
||||
if (parsed.error) {
|
||||
writeLine(color(parsed.error, 'error'));
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.claude || parsed.codex) {
|
||||
updateBrowserPolicies(parsed);
|
||||
}
|
||||
|
||||
writePolicySummary(writeLine);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'enable' || subcommand === 'disable') {
|
||||
const lane = parseBrowserLane(args[1]);
|
||||
await initUI();
|
||||
|
||||
if (!lane || args.length > 2) {
|
||||
writeLine(color(`Usage: ccs browser ${subcommand} <claude|codex|all>`, 'error'));
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
updateBrowserEnabled(subcommand, lane);
|
||||
writeToggleSummary(subcommand, lane, writeLine);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'doctor' && (args.includes('--fix') || args.includes('-f'))) {
|
||||
await initUI();
|
||||
writeLine(color('`ccs browser doctor` is read-only.', 'error'));
|
||||
writeLine(` ${dim('Run `ccs browser setup` for the browser remediation flow.')}`);
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand !== 'status' && subcommand !== 'doctor') {
|
||||
await initUI();
|
||||
writeLine(color(`Unknown browser subcommand: ${subcommand}`, 'error'));
|
||||
writeLine('');
|
||||
writeLine(` ${dim('Supported subcommands: status, doctor')}`);
|
||||
writeLine(` ${dim('Supported subcommands: setup, status, doctor, policy, enable, disable')}`);
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await initUI();
|
||||
const status = await getBrowserStatus();
|
||||
const status = await browserUtils.getBrowserStatus();
|
||||
|
||||
writeLine(header(`ccs browser ${subcommand}`));
|
||||
writeLine('');
|
||||
|
||||
@@ -123,7 +123,7 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'browser',
|
||||
summary: 'Inspect Claude Browser Attach and Codex Browser Tools readiness',
|
||||
summary: 'Set up, inspect, and control Claude Browser Attach and Codex Browser Tools',
|
||||
group: 'runtime',
|
||||
visibility: 'public',
|
||||
},
|
||||
@@ -305,6 +305,8 @@ export const PROVIDER_FLAGS = [
|
||||
'--effort',
|
||||
'--1m',
|
||||
'--no-1m',
|
||||
'--browser',
|
||||
'--no-browser',
|
||||
'--logout',
|
||||
'--headless',
|
||||
'--port-forward',
|
||||
@@ -320,7 +322,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
|
||||
config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'],
|
||||
cursor: ['--help', '-h'],
|
||||
doctor: ['--fix', '-f', '--help', '-h'],
|
||||
browser: ['status', 'doctor', '--help', '-h'],
|
||||
browser: ['setup', 'status', 'doctor', 'policy', 'enable', 'disable', '--help', '-h'],
|
||||
docker: ['--help', '-h', '--host'],
|
||||
env: ['--format', '--shell', '--ide', '--help', '-h'],
|
||||
migrate: MIGRATE_FLAGS,
|
||||
|
||||
@@ -84,6 +84,15 @@ function completeSubcommands(
|
||||
return uniqueStrings([...values, ...flags]).map((value) => suggestion(value));
|
||||
}
|
||||
|
||||
function completeBrowserPolicyArgs(tokensBeforeCurrent: string[]): CompletionSuggestion[] {
|
||||
const lastToken = tokensBeforeCurrent[tokensBeforeCurrent.length - 1];
|
||||
if (lastToken === '--all' || lastToken === '--claude' || lastToken === '--codex') {
|
||||
return completeSubcommands(['auto', 'manual'], ['--help', '-h']);
|
||||
}
|
||||
|
||||
return completeSubcommands(['--all', '--claude', '--codex'], ['--help', '-h']);
|
||||
}
|
||||
|
||||
function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSuggestion[] {
|
||||
const [command, subcommand] = tokensBeforeCurrent;
|
||||
const lastToken = tokensBeforeCurrent[tokensBeforeCurrent.length - 1];
|
||||
@@ -188,6 +197,23 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
|
||||
if (subcommand === 'logs')
|
||||
return completeSubcommands([], ['--follow', '--service', '--host', '--help', '-h']);
|
||||
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker);
|
||||
case 'browser':
|
||||
if (!subcommand || subcommand.startsWith('-')) {
|
||||
return completeSubcommands(
|
||||
['setup', 'status', 'doctor', 'policy', 'enable', 'disable'],
|
||||
['--help', '-h']
|
||||
);
|
||||
}
|
||||
if (subcommand === 'enable' || subcommand === 'disable') {
|
||||
return completeSubcommands(['claude', 'codex', 'all'], ['--help', '-h']);
|
||||
}
|
||||
if (subcommand === 'policy') {
|
||||
return completeBrowserPolicyArgs(tokensBeforeCurrent);
|
||||
}
|
||||
if (subcommand === 'setup' || subcommand === 'doctor') {
|
||||
return completeSubcommands([], ['--help', '-h']);
|
||||
}
|
||||
return completeSubcommands([], ['--help', '-h']);
|
||||
case 'cursor':
|
||||
return completeSubcommands(CURSOR_COMPLETION_SUBCOMMANDS);
|
||||
case 'proxy':
|
||||
|
||||
+105
-22
@@ -5,6 +5,7 @@ import { fail, info, ok } from '../utils/ui';
|
||||
import {
|
||||
buildOpenAICompatProxyEnv,
|
||||
getOpenAICompatProxyStatus,
|
||||
listOpenAICompatProxyStatuses,
|
||||
resolveOpenAICompatProfileConfig,
|
||||
startOpenAICompatProxy,
|
||||
stopOpenAICompatProxy,
|
||||
@@ -21,6 +22,34 @@ function parseOptionValue(args: string[], key: string): string | undefined {
|
||||
return withEquals ? withEquals.slice(prefix.length) : undefined;
|
||||
}
|
||||
|
||||
function hasHelpFlag(args: string[]): boolean {
|
||||
return args.includes('--help') || args.includes('-h');
|
||||
}
|
||||
|
||||
export function findPositionalArg(
|
||||
args: string[],
|
||||
optionsWithValues: string[] = [],
|
||||
flagOptions: string[] = []
|
||||
): string | undefined {
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === '--') {
|
||||
return i + 1 < args.length ? args[i + 1] : undefined;
|
||||
}
|
||||
if (arg.startsWith('-')) {
|
||||
if (flagOptions.includes(arg)) {
|
||||
continue;
|
||||
}
|
||||
if (optionsWithValues.includes(arg)) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function showHelp(): number {
|
||||
console.log('OpenAI-Compatible Proxy');
|
||||
console.log('');
|
||||
@@ -30,9 +59,9 @@ function showHelp(): number {
|
||||
console.log(
|
||||
' start <profile> 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(' stop [profile] Stop the running proxy (or all proxies when omitted)');
|
||||
console.log(' status [profile] Show daemon status');
|
||||
console.log(' activate [profile] Print shell exports for the running proxy');
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(' --port <n> Override the local proxy port (default: 3456)');
|
||||
@@ -62,7 +91,10 @@ function resolveProfile(profileName: string) {
|
||||
}
|
||||
|
||||
async function handleStart(args: string[]): Promise<number> {
|
||||
const profileName = args.find((arg) => !arg.startsWith('-'));
|
||||
if (hasHelpFlag(args)) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = findPositionalArg(args, ['--port', '--host'], ['--insecure']);
|
||||
if (!profileName) {
|
||||
console.error(
|
||||
fail('Usage: ccs proxy start <profile> [--port <n>] [--host <addr>] [--insecure]')
|
||||
@@ -100,31 +132,78 @@ async function handleStart(args: string[]): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleStatus(): Promise<number> {
|
||||
async function handleStatus(args: string[] = []): Promise<number> {
|
||||
if (hasHelpFlag(args)) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = findPositionalArg(args);
|
||||
const printStatus = (status: Awaited<ReturnType<typeof getOpenAICompatProxyStatus>>) => {
|
||||
console.log(
|
||||
status.running
|
||||
? ok(`Proxy running on port ${status.port ?? 'unknown'}`)
|
||||
: info(`Proxy is not running${status.port ? ` (last known port ${status.port})` : ''}`)
|
||||
);
|
||||
if (status.host && status.port) {
|
||||
console.log(` Host: ${status.host}`);
|
||||
console.log(` Local URL: http://${status.host}:${status.port}`);
|
||||
}
|
||||
if (status.profileName) {
|
||||
console.log(` Profile: ${status.profileName}`);
|
||||
}
|
||||
if (status.baseUrl) {
|
||||
console.log(` Base URL: ${status.baseUrl}`);
|
||||
}
|
||||
if (status.model) {
|
||||
console.log(` Model: ${status.model}`);
|
||||
}
|
||||
if (status.pid) {
|
||||
console.log(` PID: ${status.pid}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (profileName) {
|
||||
const status = await getOpenAICompatProxyStatus(profileName);
|
||||
if (!status.running && !status.profileName) {
|
||||
console.log(info('Proxy is not running'));
|
||||
return 0;
|
||||
}
|
||||
printStatus(status);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
if (!status.running) {
|
||||
if (!status.running && !status.profileName) {
|
||||
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}`);
|
||||
if (status.running && !status.profileName) {
|
||||
const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running);
|
||||
for (const entry of running) {
|
||||
printStatus(entry);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
printStatus(status);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleActivate(args: string[]): Promise<number> {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
if (hasHelpFlag(args)) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = findPositionalArg(args, ['--shell']);
|
||||
if (!profileName) {
|
||||
const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running);
|
||||
if (running.length > 1) {
|
||||
console.error(
|
||||
fail('Multiple proxies are running. Specify a profile: ccs proxy activate <profile>')
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const status = await getOpenAICompatProxyStatus(profileName);
|
||||
if (!status.running || !status.profileName || !status.port || !status.authToken) {
|
||||
console.error(fail('Proxy is not running. Start it with: ccs proxy start <profile>'));
|
||||
return 1;
|
||||
@@ -163,16 +242,20 @@ export async function handleProxyCommand(args: string[]): Promise<number> {
|
||||
case 'start':
|
||||
return handleStart(args.slice(1));
|
||||
case 'stop': {
|
||||
const result = await stopOpenAICompatProxy();
|
||||
if (hasHelpFlag(args.slice(1))) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = args[1] && !args[1].startsWith('-') ? args[1] : undefined;
|
||||
const result = await stopOpenAICompatProxy(profileName);
|
||||
if (!result.success) {
|
||||
console.error(fail(result.error || 'Failed to stop proxy'));
|
||||
return 1;
|
||||
}
|
||||
console.log(ok('Proxy stopped'));
|
||||
console.log(ok(profileName ? `Proxy stopped for profile ${profileName}` : 'Proxy stopped'));
|
||||
return 0;
|
||||
}
|
||||
case 'status':
|
||||
return handleStatus();
|
||||
return handleStatus(args.slice(1));
|
||||
case 'activate':
|
||||
return handleActivate(args.slice(1));
|
||||
default:
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DEFAULT_GLOBAL_ENV,
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
DEFAULT_CLIPROXY_SAFETY_CONFIG,
|
||||
DEFAULT_OPENAI_COMPAT_PROXY_CONFIG,
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
|
||||
DEFAULT_THINKING_CONFIG,
|
||||
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
|
||||
@@ -36,7 +37,7 @@ import type {
|
||||
OfficialChannelId,
|
||||
DashboardAuthConfig,
|
||||
BrowserConfig,
|
||||
BrowserEvalMode,
|
||||
BrowserToolPolicy,
|
||||
ImageAnalysisConfig,
|
||||
LoggingConfig,
|
||||
CursorConfig,
|
||||
@@ -71,25 +72,21 @@ function normalizeBrowserDevtoolsPort(value: number | undefined): number {
|
||||
return port;
|
||||
}
|
||||
|
||||
function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode {
|
||||
if (value === 'disabled' || value === 'readonly' || value === 'readwrite') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return DEFAULT_BROWSER_CONFIG.claude.eval_mode;
|
||||
function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy {
|
||||
return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy;
|
||||
}
|
||||
|
||||
function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig {
|
||||
return {
|
||||
claude: {
|
||||
enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled,
|
||||
policy: normalizeBrowserPolicy(config?.claude?.policy),
|
||||
user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(),
|
||||
devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port),
|
||||
eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode),
|
||||
},
|
||||
codex: {
|
||||
enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled,
|
||||
eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode),
|
||||
policy: normalizeBrowserPolicy(config?.codex?.policy),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -425,6 +422,10 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
port: partial.proxy?.port ?? DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port,
|
||||
profile_ports: partial.proxy?.profile_ports ?? {
|
||||
...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports,
|
||||
},
|
||||
routing: {
|
||||
default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default,
|
||||
background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background,
|
||||
@@ -966,7 +967,6 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
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('# eval_mode controls browser_eval access: disabled | readonly | readwrite.');
|
||||
lines.push('# Configure via: Settings > Browser or `ccs browser ...`.');
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
|
||||
@@ -26,8 +26,9 @@ import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
|
||||
* Version 10 = Exa + Tavily WebSearch backends
|
||||
* Version 11 = Discord Channels runtime auto-enable preferences
|
||||
* Version 12 = Official Channels multi-provider support (Telegram, Discord, iMessage)
|
||||
* Version 13 = Browser automation defaults to safe manual/off exposure
|
||||
*/
|
||||
export const UNIFIED_CONFIG_VERSION = 12;
|
||||
export const UNIFIED_CONFIG_VERSION = 13;
|
||||
|
||||
/**
|
||||
* Supported CLIProxy providers.
|
||||
@@ -508,6 +509,10 @@ export interface OpenAICompatProxyRoutingConfig {
|
||||
}
|
||||
|
||||
export interface OpenAICompatProxyConfig {
|
||||
/** Default local port for OpenAI-compatible proxy instances */
|
||||
port?: number;
|
||||
/** Optional profile-scoped local port overrides */
|
||||
profile_ports?: Record<string, number>;
|
||||
routing?: OpenAICompatProxyRoutingConfig;
|
||||
}
|
||||
|
||||
@@ -816,24 +821,24 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
|
||||
* Browser automation configuration.
|
||||
* Controls Claude browser attach and Codex browser tooling.
|
||||
*/
|
||||
export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite';
|
||||
export type BrowserToolPolicy = 'auto' | 'manual';
|
||||
|
||||
export interface BrowserClaudeConfig {
|
||||
/** Enable Claude browser attach (default: false) */
|
||||
enabled: boolean;
|
||||
/** Control whether Claude browser attach is exposed automatically or only via --browser */
|
||||
policy: BrowserToolPolicy;
|
||||
/** Chrome user-data directory used for attach mode */
|
||||
user_data_dir: string;
|
||||
/** DevTools port used for attach mode (default: 9222) */
|
||||
devtools_port: number;
|
||||
/** Eval access mode for browser_eval (default: readonly) */
|
||||
eval_mode: BrowserEvalMode;
|
||||
}
|
||||
|
||||
export interface BrowserCodexConfig {
|
||||
/** Enable Codex browser tooling injection (default: true) */
|
||||
/** Enable Codex browser tooling injection (default: false) */
|
||||
enabled: boolean;
|
||||
/** Stored for dashboard/config parity; runtime enforcement is Claude-only in Phase 1 */
|
||||
eval_mode: BrowserEvalMode;
|
||||
/** Control whether Codex browser tooling is exposed automatically or only via --browser */
|
||||
policy: BrowserToolPolicy;
|
||||
}
|
||||
|
||||
export interface BrowserConfig {
|
||||
@@ -844,13 +849,13 @@ export interface BrowserConfig {
|
||||
export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
eval_mode: 'readonly',
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
eval_mode: 'readonly',
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -994,6 +999,8 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = {
|
||||
};
|
||||
|
||||
export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = {
|
||||
port: 3456,
|
||||
profile_ports: {},
|
||||
routing: {
|
||||
longContextThreshold: 60_000,
|
||||
},
|
||||
@@ -1024,6 +1031,8 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
port: DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port,
|
||||
profile_ports: { ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports },
|
||||
routing: {
|
||||
...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.routing,
|
||||
},
|
||||
|
||||
@@ -10,10 +10,20 @@ export const DEFAULT_CURSOR_MODEL = 'gpt-5.3-codex';
|
||||
/**
|
||||
* Default models available through Cursor IDE.
|
||||
* Used as fallback when daemon is not reachable.
|
||||
* Source: Cursor docs model catalog (Feb 2026)
|
||||
* Source: Cursor docs / official announcements (Apr 2026)
|
||||
*/
|
||||
export const DEFAULT_CURSOR_MODELS: CursorModel[] = [
|
||||
// Anthropic Models
|
||||
{
|
||||
id: 'claude-4.7-opus',
|
||||
name: 'Claude 4.7 Opus',
|
||||
provider: 'anthropic',
|
||||
},
|
||||
{
|
||||
id: 'claude-4.7-opus-fast-mode',
|
||||
name: 'Claude 4.7 Opus (Fast mode)',
|
||||
provider: 'anthropic',
|
||||
},
|
||||
{
|
||||
id: 'claude-4.6-opus',
|
||||
name: 'Claude 4.6 Opus',
|
||||
|
||||
@@ -40,6 +40,7 @@ interface ToolCall {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
blockIndex: number;
|
||||
}
|
||||
|
||||
interface ToolCallDelta {
|
||||
@@ -240,7 +241,6 @@ export class DeltaAccumulator {
|
||||
addToolCallDelta(toolCallDelta: ToolCallDelta): void {
|
||||
const index = toolCallDelta.index;
|
||||
|
||||
// Initialize tool call if not exists
|
||||
if (!this.toolCallsIndex[index]) {
|
||||
const toolCall: ToolCall = {
|
||||
index: index,
|
||||
@@ -250,6 +250,7 @@ export class DeltaAccumulator {
|
||||
name: '',
|
||||
arguments: '',
|
||||
},
|
||||
blockIndex: -1,
|
||||
};
|
||||
this.toolCalls.push(toolCall);
|
||||
this.toolCallsIndex[index] = toolCall;
|
||||
@@ -257,27 +258,42 @@ export class DeltaAccumulator {
|
||||
|
||||
const toolCall = this.toolCallsIndex[index];
|
||||
|
||||
// Update id if present
|
||||
if (toolCallDelta.id) {
|
||||
toolCall.id = toolCallDelta.id;
|
||||
}
|
||||
|
||||
// Update type if present
|
||||
if (toolCallDelta.type) {
|
||||
toolCall.type = toolCallDelta.type;
|
||||
}
|
||||
|
||||
// Update function name if present
|
||||
if (toolCallDelta.function?.name) {
|
||||
toolCall.function.name += toolCallDelta.function.name;
|
||||
}
|
||||
|
||||
// Update function arguments if present
|
||||
if (toolCallDelta.function?.arguments) {
|
||||
toolCall.function.arguments += toolCallDelta.function.arguments;
|
||||
}
|
||||
}
|
||||
|
||||
setToolCallBlockIndex(toolCallIndex: number, blockIndex: number): void {
|
||||
const toolCall = this.toolCallsIndex[toolCallIndex];
|
||||
if (toolCall) {
|
||||
toolCall.blockIndex = blockIndex;
|
||||
}
|
||||
}
|
||||
|
||||
getToolCallBlockIndex(toolCallIndex: number): number {
|
||||
const toolCall = this.toolCallsIndex[toolCallIndex];
|
||||
if (!toolCall || toolCall.blockIndex < 0) {
|
||||
throw new Error(`Tool call ${toolCallIndex} does not have an assigned content block`);
|
||||
}
|
||||
return toolCall.blockIndex;
|
||||
}
|
||||
|
||||
getUnstoppedBlocks(): ContentBlock[] {
|
||||
return this.contentBlocks.filter((b) => !b.stopped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tool calls
|
||||
* @returns Tool calls array
|
||||
|
||||
@@ -209,9 +209,8 @@ export class StreamParser {
|
||||
|
||||
if (!toolCallDeltas) return events;
|
||||
|
||||
// Close current content block ONCE before processing any tool calls
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
if (currentBlock && !currentBlock.stopped) {
|
||||
if (currentBlock && !currentBlock.stopped && currentBlock.type !== 'tool_use') {
|
||||
if (currentBlock.type === 'thinking') {
|
||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
||||
} else {
|
||||
@@ -220,7 +219,6 @@ export class StreamParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Process tool call deltas
|
||||
events.push(...this.toolCallHandler.processToolCallDeltas(toolCallDeltas, accumulator));
|
||||
|
||||
return events;
|
||||
@@ -251,44 +249,41 @@ export class StreamParser {
|
||||
private forceFinalization(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
|
||||
const events: AnthropicSSEEvent[] = [];
|
||||
|
||||
// Close current block if any
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
if (currentBlock && !currentBlock.stopped) {
|
||||
if (currentBlock.type === 'thinking') {
|
||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
||||
} else {
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
|
||||
accumulator.stopCurrentBlock();
|
||||
const unstoppedBlocks = accumulator.getUnstoppedBlocks();
|
||||
for (const block of unstoppedBlocks) {
|
||||
if (block.type === 'thinking') {
|
||||
const signatureEvent = this.responseBuilder.createSignatureDeltaEvent(block);
|
||||
if (signatureEvent) {
|
||||
events.push(signatureEvent);
|
||||
}
|
||||
}
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(block));
|
||||
block.stopped = true;
|
||||
}
|
||||
|
||||
// Force finalization
|
||||
events.push(...this.finalizeDelta(accumulator));
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize streaming and generate closing events
|
||||
*/
|
||||
finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
|
||||
if (accumulator.isFinalized()) {
|
||||
return []; // Already finalized
|
||||
return [];
|
||||
}
|
||||
|
||||
const events: AnthropicSSEEvent[] = [];
|
||||
|
||||
// Close current content block if any
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
if (currentBlock && !currentBlock.stopped) {
|
||||
if (currentBlock.type === 'thinking') {
|
||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
||||
} else {
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
|
||||
accumulator.stopCurrentBlock();
|
||||
const unstoppedBlocks = accumulator.getUnstoppedBlocks();
|
||||
for (const block of unstoppedBlocks) {
|
||||
if (block.type === 'thinking') {
|
||||
const signatureEvent = this.responseBuilder.createSignatureDeltaEvent(block);
|
||||
if (signatureEvent) {
|
||||
events.push(signatureEvent);
|
||||
}
|
||||
}
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(block));
|
||||
block.stopped = true;
|
||||
}
|
||||
|
||||
// Message delta (stop reason + usage) and message stop
|
||||
const stopReason = this.responseBuilder.mapStopReason(accumulator.getFinishReason() || 'stop');
|
||||
events.push(...this.responseBuilder.createFinalizationEvents(accumulator, stopReason));
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@ import type { DeltaAccumulator } from '../delta-accumulator';
|
||||
import type { OpenAIToolCallDelta, OpenAIToolCall, ContentBlock, AnthropicSSEEvent } from './types';
|
||||
|
||||
export class ToolCallHandler {
|
||||
/**
|
||||
* Process tool calls from non-streaming response
|
||||
*/
|
||||
processToolCalls(toolCalls: OpenAIToolCall[]): ContentBlock[] {
|
||||
const content: ContentBlock[] = [];
|
||||
|
||||
@@ -38,10 +35,6 @@ export class ToolCallHandler {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tool call deltas during streaming
|
||||
* Returns events for tool use blocks and input_json deltas
|
||||
*/
|
||||
processToolCallDeltas(
|
||||
toolCallDeltas: OpenAIToolCallDelta[],
|
||||
accumulator: DeltaAccumulator
|
||||
@@ -49,15 +42,15 @@ export class ToolCallHandler {
|
||||
const events: AnthropicSSEEvent[] = [];
|
||||
|
||||
for (const toolCallDelta of toolCallDeltas) {
|
||||
// Track tool call state
|
||||
const isNewToolCall = !accumulator.hasToolCall(toolCallDelta.index);
|
||||
accumulator.addToolCallDelta(toolCallDelta);
|
||||
|
||||
// Emit tool use events (start + input_json deltas)
|
||||
if (isNewToolCall) {
|
||||
// Start new tool_use block in accumulator
|
||||
// OpenAI may interleave tool_call fragments across chunks, so blocks must stay open
|
||||
// until the stream finalizes. Closing on a later index truncates earlier tool input.
|
||||
const block = accumulator.startBlock('tool_use');
|
||||
const toolCall = accumulator.getToolCall(toolCallDelta.index);
|
||||
accumulator.setToolCallBlockIndex(toolCallDelta.index, block.index);
|
||||
|
||||
events.push({
|
||||
event: 'content_block_start',
|
||||
@@ -68,27 +61,25 @@ export class ToolCallHandler {
|
||||
type: 'tool_use',
|
||||
id: toolCall?.id || `tool_${toolCallDelta.index}`,
|
||||
name: toolCall?.function?.name || '',
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Emit input_json delta if arguments present
|
||||
if (toolCallDelta.function?.arguments) {
|
||||
const currentToolBlock = accumulator.getCurrentBlock();
|
||||
if (currentToolBlock && currentToolBlock.type === 'tool_use') {
|
||||
events.push({
|
||||
event: 'content_block_delta',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
index: currentToolBlock.index,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: toolCallDelta.function.arguments,
|
||||
},
|
||||
const toolCallBlockIndex = accumulator.getToolCallBlockIndex(toolCallDelta.index);
|
||||
events.push({
|
||||
event: 'content_block_delta',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
index: toolCallBlockIndex,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: toolCallDelta.function.arguments,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './profile-router';
|
||||
export * from './proxy-daemon';
|
||||
export * from './proxy-daemon-paths';
|
||||
export * from './proxy-env';
|
||||
export * from './proxy-port-resolver';
|
||||
export * from './upstream-url';
|
||||
export * from './transformers/request-transformer';
|
||||
export * from './transformers/sse-stream-transformer';
|
||||
@@ -8,10 +8,28 @@ export function getOpenAICompatProxyDir(): string {
|
||||
return path.join(getCcsDir(), 'proxy');
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPidPath(): string {
|
||||
export function getOpenAICompatProxyProfileKey(profileName: string): string {
|
||||
return encodeURIComponent(profileName.trim());
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPidPath(profileName: string): string {
|
||||
return path.join(
|
||||
getOpenAICompatProxyDir(),
|
||||
`${getOpenAICompatProxyProfileKey(profileName)}.daemon.pid`
|
||||
);
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxySessionPath(profileName: string): string {
|
||||
return path.join(
|
||||
getOpenAICompatProxyDir(),
|
||||
`${getOpenAICompatProxyProfileKey(profileName)}.session.json`
|
||||
);
|
||||
}
|
||||
|
||||
export function getLegacyOpenAICompatProxyPidPath(): string {
|
||||
return path.join(getOpenAICompatProxyDir(), 'daemon.pid');
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxySessionPath(): string {
|
||||
export function getLegacyOpenAICompatProxySessionPath(): string {
|
||||
return path.join(getOpenAICompatProxyDir(), 'session.json');
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
getOpenAICompatProxyDir,
|
||||
getLegacyOpenAICompatProxyPidPath,
|
||||
getLegacyOpenAICompatProxySessionPath,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from './proxy-daemon-paths';
|
||||
@@ -21,9 +23,9 @@ function ensureProxyDir(): void {
|
||||
fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPid(): number | null {
|
||||
function readPid(pidPath: string): number | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim();
|
||||
const raw = fs.readFileSync(pidPath, 'utf8').trim();
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(pid) ? pid : null;
|
||||
} catch {
|
||||
@@ -31,46 +33,113 @@ export function getOpenAICompatProxyPid(): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxyPid(pid: number): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(), String(pid), 'utf8');
|
||||
export function getOpenAICompatProxyPid(profileName: string): number | null {
|
||||
return readPid(getOpenAICompatProxyPidPath(profileName));
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxyPid(): void {
|
||||
export function getLegacyOpenAICompatProxyPid(): number | null {
|
||||
return readPid(getLegacyOpenAICompatProxyPidPath());
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxyPid(profileName: string, pid: number): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8');
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxyPid(profileName: string): void {
|
||||
try {
|
||||
fs.unlinkSync(getOpenAICompatProxyPidPath());
|
||||
fs.unlinkSync(getOpenAICompatProxyPidPath(profileName));
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenAICompatProxySession(): OpenAICompatProxySession | null {
|
||||
export function removeLegacyOpenAICompatProxyPid(): void {
|
||||
try {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8')
|
||||
) as OpenAICompatProxySession;
|
||||
fs.unlinkSync(getLegacyOpenAICompatProxyPidPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function readSession(sessionPath: string): OpenAICompatProxySession | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(sessionPath, 'utf8')) as OpenAICompatProxySession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null {
|
||||
return readSession(getOpenAICompatProxySessionPath(profileName));
|
||||
}
|
||||
|
||||
export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession | null {
|
||||
return readSession(getLegacyOpenAICompatProxySessionPath());
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxySessionPath(),
|
||||
getOpenAICompatProxySessionPath(session.profileName),
|
||||
JSON.stringify(session, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxySession(): void {
|
||||
export function removeOpenAICompatProxySession(profileName: string): void {
|
||||
try {
|
||||
fs.unlinkSync(getOpenAICompatProxySessionPath());
|
||||
fs.unlinkSync(getOpenAICompatProxySessionPath(profileName));
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export function removeLegacyOpenAICompatProxySession(): void {
|
||||
try {
|
||||
fs.unlinkSync(getLegacyOpenAICompatProxySessionPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function decodeProfileKey(profileKey: string): string[] {
|
||||
try {
|
||||
return [decodeURIComponent(profileKey)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function listProfileNamesForSuffix(suffix: string, legacyName?: string): string[] {
|
||||
try {
|
||||
const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true });
|
||||
const profileKeys = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || entry.name === legacyName || !entry.name.endsWith(suffix)) {
|
||||
continue;
|
||||
}
|
||||
const profileKey = entry.name.slice(0, -suffix.length);
|
||||
if (!profileKey) {
|
||||
continue;
|
||||
}
|
||||
profileKeys.add(profileKey);
|
||||
}
|
||||
return [...profileKeys].flatMap(decodeProfileKey);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listOpenAICompatProxyProfileNames(): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
...listProfileNamesForSuffix('.session.json', 'session.json'),
|
||||
...listProfileNamesForSuffix('.daemon.pid', 'daemon.pid'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
|
||||
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
|
||||
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
|
||||
|
||||
+512
-164
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -12,8 +11,13 @@ import {
|
||||
getOpenAICompatProxyDir,
|
||||
} from './proxy-daemon-paths';
|
||||
import {
|
||||
getLegacyOpenAICompatProxyPid,
|
||||
getOpenAICompatProxyPid,
|
||||
listOpenAICompatProxyProfileNames,
|
||||
readLegacyOpenAICompatProxySession,
|
||||
readOpenAICompatProxySession,
|
||||
removeLegacyOpenAICompatProxyPid,
|
||||
removeLegacyOpenAICompatProxySession,
|
||||
removeOpenAICompatProxyPid,
|
||||
removeOpenAICompatProxySession,
|
||||
resolveOpenAICompatProxyEntrypointCandidates,
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
writeOpenAICompatProxyPid,
|
||||
writeOpenAICompatProxySession,
|
||||
} from './proxy-daemon-state';
|
||||
import { resolveOpenAICompatProxyPortPreference } from './proxy-port-resolver';
|
||||
|
||||
export interface OpenAICompatProxyStatus extends Partial<OpenAICompatProxySession> {
|
||||
running: boolean;
|
||||
@@ -36,6 +41,37 @@ export interface StartOpenAICompatProxyResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult {
|
||||
commitState?: () => void;
|
||||
stop?: () => Promise<void>;
|
||||
bindConflict?: boolean;
|
||||
}
|
||||
|
||||
interface OpenAICompatProxyHealthPayload {
|
||||
service?: string;
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function verifyProxyProcessOwnership(
|
||||
pid: number,
|
||||
profileName?: string
|
||||
): ReturnType<typeof verifyProcessOwnership> {
|
||||
const profilePattern = profileName
|
||||
? new RegExp(`(^|\\s)--profile(?:\\s+|=)${escapeRegExp(profileName)}(?=\\s|$)`)
|
||||
: null;
|
||||
return verifyProcessOwnership(
|
||||
pid,
|
||||
(commandLine) =>
|
||||
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
||||
commandLine.includes('proxy-daemon-entry') &&
|
||||
(!profilePattern || profilePattern.test(commandLine))
|
||||
);
|
||||
}
|
||||
|
||||
function generateProxyAuthToken(): string {
|
||||
return crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
@@ -70,35 +106,73 @@ async function withOpenAICompatProxyLock<T>(operation: () => Promise<T>): Promis
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortOccupied(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
const finish = (occupied: boolean) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(occupied);
|
||||
};
|
||||
async function terminateDaemonProcess(pid?: number): Promise<void> {
|
||||
if (!pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(500, () => {
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
let attempts = 0;
|
||||
while (attempts < 10) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
attempts += 1;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Best-effort cleanup for a daemon we just spawned.
|
||||
}
|
||||
}
|
||||
|
||||
async function findOpenAICompatProxyPort(): Promise<number> {
|
||||
function listOpenAICompatProxyCandidatePorts(
|
||||
preferredPort: number,
|
||||
exact: boolean,
|
||||
excludedPorts: ReadonlySet<number> = new Set()
|
||||
): number[] {
|
||||
if (exact) {
|
||||
return excludedPorts.has(preferredPort) ? [] : [preferredPort];
|
||||
}
|
||||
|
||||
const candidates = new Set<number>();
|
||||
if (!excludedPorts.has(preferredPort)) {
|
||||
candidates.add(preferredPort);
|
||||
}
|
||||
|
||||
for (
|
||||
let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10;
|
||||
candidate += 1
|
||||
) {
|
||||
if (!(await isPortOccupied(candidate))) {
|
||||
return candidate;
|
||||
if (!excludedPorts.has(candidate)) {
|
||||
candidates.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function isPortBindConflictMessage(message?: string): boolean {
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
message.includes('EADDRINUSE') ||
|
||||
normalized.includes('address already in use') ||
|
||||
(normalized.includes('is port') && normalized.includes('in use'))
|
||||
);
|
||||
}
|
||||
|
||||
interface OpenAICompatProxyStateRecord {
|
||||
pid: number | null;
|
||||
session: OpenAICompatProxySession | null;
|
||||
source: 'profile' | 'legacy';
|
||||
}
|
||||
|
||||
async function resolveDaemonEntrypoint(): Promise<string | null> {
|
||||
@@ -113,7 +187,10 @@ async function resolveDaemonEntrypoint(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isOpenAICompatProxyRunning(port: number): Promise<boolean> {
|
||||
export async function isOpenAICompatProxyRunning(
|
||||
port: number,
|
||||
expectedProfileName?: string
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{ hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 },
|
||||
@@ -127,8 +204,11 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(body) as { service?: string };
|
||||
resolve(payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME);
|
||||
const payload = JSON.parse(body) as OpenAICompatProxyHealthPayload;
|
||||
resolve(
|
||||
payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME &&
|
||||
(!expectedProfileName || payload.profile === expectedProfileName)
|
||||
);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
@@ -144,33 +224,115 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus> {
|
||||
const session = readOpenAICompatProxySession();
|
||||
const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
const running = await isOpenAICompatProxyRunning(port);
|
||||
async function getOpenAICompatProxyStatusForProfile(
|
||||
profileName: string
|
||||
): Promise<OpenAICompatProxyStatus> {
|
||||
const state = getOpenAICompatProxyStateForProfile(profileName);
|
||||
const session = state.session;
|
||||
if (!session) {
|
||||
return { running: false, profileName, pid: state.pid || undefined };
|
||||
}
|
||||
|
||||
const port = session.port;
|
||||
const running = await isOpenAICompatProxyRunning(port, profileName);
|
||||
return {
|
||||
running,
|
||||
pid: running ? getOpenAICompatProxyPid() || undefined : undefined,
|
||||
pid: running ? state.pid || undefined : undefined,
|
||||
...session,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> {
|
||||
const pid = getOpenAICompatProxyPid();
|
||||
async function getLegacyOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus | null> {
|
||||
const session = readLegacyOpenAICompatProxySession();
|
||||
const pid = getLegacyOpenAICompatProxyPid();
|
||||
if (!session && !pid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = session?.port;
|
||||
const running =
|
||||
typeof port === 'number' ? await isOpenAICompatProxyRunning(port, session?.profileName) : false;
|
||||
return {
|
||||
running,
|
||||
pid: running ? pid || undefined : pid || undefined,
|
||||
...session,
|
||||
};
|
||||
}
|
||||
|
||||
function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatProxyStateRecord {
|
||||
const session = readOpenAICompatProxySession(profileName);
|
||||
if (session) {
|
||||
return {
|
||||
pid: getOpenAICompatProxyPid(profileName),
|
||||
session,
|
||||
source: 'profile',
|
||||
};
|
||||
}
|
||||
|
||||
const legacySession = readLegacyOpenAICompatProxySession();
|
||||
if (legacySession?.profileName === profileName) {
|
||||
return {
|
||||
pid: getLegacyOpenAICompatProxyPid(),
|
||||
session: legacySession,
|
||||
source: 'legacy',
|
||||
};
|
||||
}
|
||||
|
||||
return { pid: null, session: null, source: 'profile' };
|
||||
}
|
||||
|
||||
export async function listOpenAICompatProxyStatuses(): Promise<OpenAICompatProxyStatus[]> {
|
||||
const profileNames = new Set(listOpenAICompatProxyProfileNames());
|
||||
const legacySession = readLegacyOpenAICompatProxySession();
|
||||
if (legacySession?.profileName) {
|
||||
profileNames.add(legacySession.profileName);
|
||||
}
|
||||
const profileStatuses = await Promise.all(
|
||||
[...profileNames].map((profileName) => getOpenAICompatProxyStatusForProfile(profileName))
|
||||
);
|
||||
const statuses = profileStatuses.filter((status) => status.profileName);
|
||||
if (!legacySession?.profileName) {
|
||||
const legacyStatus = await getLegacyOpenAICompatProxyStatus();
|
||||
if (legacyStatus) {
|
||||
statuses.push(legacyStatus);
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
export async function getOpenAICompatProxyStatus(
|
||||
profileName?: string
|
||||
): Promise<OpenAICompatProxyStatus> {
|
||||
if (profileName) {
|
||||
return getOpenAICompatProxyStatusForProfile(profileName);
|
||||
}
|
||||
|
||||
const statuses = await listOpenAICompatProxyStatuses();
|
||||
const running = statuses.filter((status) => status.running);
|
||||
if (running.length === 1) {
|
||||
return running[0];
|
||||
}
|
||||
if (running.length > 1) {
|
||||
return { running: true };
|
||||
}
|
||||
const latestKnown = statuses[0];
|
||||
return latestKnown ?? { running: false };
|
||||
}
|
||||
|
||||
async function stopOpenAICompatProxyUnlocked(
|
||||
profileName: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const state = getOpenAICompatProxyStateForProfile(profileName);
|
||||
const pid = state.pid;
|
||||
if (!pid) {
|
||||
removeOpenAICompatProxySession();
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ownership = verifyProcessOwnership(
|
||||
pid,
|
||||
(commandLine) =>
|
||||
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
||||
commandLine.includes('proxy-daemon-entry')
|
||||
);
|
||||
const ownership = verifyProxyProcessOwnership(pid, profileName);
|
||||
|
||||
if (ownership === 'not-owned') {
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -182,8 +344,7 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro
|
||||
}
|
||||
|
||||
if (ownership === 'not-running') {
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -214,13 +375,114 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro
|
||||
}
|
||||
}
|
||||
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error?: string }> {
|
||||
return withOpenAICompatProxyLock(() => stopOpenAICompatProxyUnlocked());
|
||||
async function stopLegacyOpenAICompatProxyUnlocked(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const legacySession = readLegacyOpenAICompatProxySession();
|
||||
if (legacySession?.profileName) {
|
||||
return stopOpenAICompatProxyUnlocked(legacySession.profileName);
|
||||
}
|
||||
|
||||
const pid = getLegacyOpenAICompatProxyPid();
|
||||
if (!pid) {
|
||||
removeLegacyOpenAICompatProxyPid();
|
||||
removeLegacyOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ownership = verifyProxyProcessOwnership(pid);
|
||||
|
||||
if (ownership === 'not-owned' || ownership === 'not-running') {
|
||||
removeLegacyOpenAICompatProxyPid();
|
||||
removeLegacyOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (ownership === 'unknown') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`,
|
||||
};
|
||||
}
|
||||
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
|
||||
removeLegacyOpenAICompatProxyPid();
|
||||
removeLegacyOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function removeOpenAICompatProxyState(
|
||||
state: OpenAICompatProxyStateRecord,
|
||||
profileName: string
|
||||
): void {
|
||||
if (state.source === 'legacy') {
|
||||
removeLegacyOpenAICompatProxyPid();
|
||||
removeLegacyOpenAICompatProxySession();
|
||||
return;
|
||||
}
|
||||
|
||||
removeOpenAICompatProxyPid(profileName);
|
||||
removeOpenAICompatProxySession(profileName);
|
||||
}
|
||||
|
||||
export async function stopOpenAICompatProxy(
|
||||
profileName?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return withOpenAICompatProxyLock(async () => {
|
||||
if (profileName) {
|
||||
return stopOpenAICompatProxyUnlocked(profileName);
|
||||
}
|
||||
|
||||
const statuses = await listOpenAICompatProxyStatuses();
|
||||
const failures: string[] = [];
|
||||
for (const status of statuses) {
|
||||
const stopped = status.profileName
|
||||
? await stopOpenAICompatProxyUnlocked(status.profileName)
|
||||
: await stopLegacyOpenAICompatProxyUnlocked();
|
||||
if (!stopped.success) {
|
||||
failures.push(
|
||||
status.profileName
|
||||
? `${status.profileName}: ${stopped.error || 'failed to stop proxy'}`
|
||||
: `legacy proxy: ${stopped.error || 'failed to stop proxy'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
return { success: false, error: `Failed to stop some proxies: ${failures.join('; ')}` };
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function startOpenAICompatProxy(
|
||||
@@ -228,53 +490,35 @@ export async function startOpenAICompatProxy(
|
||||
options: { port?: number; host?: string; insecure?: boolean } = {}
|
||||
): Promise<StartOpenAICompatProxyResult> {
|
||||
return withOpenAICompatProxyLock(async () => {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
const status = await getOpenAICompatProxyStatus(profile.profileName);
|
||||
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
|
||||
) {
|
||||
const portPreference = resolveOpenAICompatProxyPortPreference(profile.profileName);
|
||||
const explicitPort = typeof options.port === 'number' ? options.port : undefined;
|
||||
const preferredPort =
|
||||
explicitPort ??
|
||||
(portPreference.source === 'profile'
|
||||
? portPreference.port
|
||||
: status.port || portPreference.port);
|
||||
const requiresExactPort = explicitPort !== undefined || portPreference.source === 'profile';
|
||||
if (status.running && status.port === preferredPort && (status.host || '127.0.0.1') === host) {
|
||||
return {
|
||||
success: true,
|
||||
alreadyRunning: true,
|
||||
pid: status.pid,
|
||||
port,
|
||||
port: preferredPort,
|
||||
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 (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) {
|
||||
return { success: false, port: preferredPort, error: `Invalid port: ${preferredPort}` };
|
||||
}
|
||||
if (status.pid && !status.port) {
|
||||
const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName);
|
||||
if (!stopped.success) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
error: stopped.error || 'Failed to restart the running proxy',
|
||||
port: preferredPort,
|
||||
error: stopped.error || 'Failed to clear stale proxy state',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -283,107 +527,211 @@ export async function startOpenAICompatProxy(
|
||||
if (!daemonEntry) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
port: preferredPort,
|
||||
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,
|
||||
const launchOnPort = (
|
||||
port: number,
|
||||
persistState: boolean
|
||||
): Promise<OpenAICompatProxyLaunchResult> =>
|
||||
new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
let stderr = '';
|
||||
const authToken = generateProxyAuthToken();
|
||||
const commitState = () => {
|
||||
if (proc.pid) {
|
||||
writeOpenAICompatProxyPid(profile.profileName, proc.pid);
|
||||
}
|
||||
writeOpenAICompatProxySession({
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host,
|
||||
port,
|
||||
error: `Proxy daemon did not start within 30 seconds on port ${port}`,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken,
|
||||
model: profile.model,
|
||||
insecure: options.insecure,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (result: OpenAICompatProxyLaunchResult) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (!result.success && persistState) {
|
||||
removeOpenAICompatProxyPid(profile.profileName);
|
||||
removeOpenAICompatProxySession(profile.profileName);
|
||||
}
|
||||
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', 'ignore', 'pipe'], detached: true }
|
||||
);
|
||||
|
||||
proc.unref();
|
||||
proc.stderr?.setEncoding('utf8');
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
attempts += 1;
|
||||
if (await isOpenAICompatProxyRunning(port, profile.profileName)) {
|
||||
if (persistState) {
|
||||
commitState();
|
||||
}
|
||||
finish({
|
||||
success: true,
|
||||
pid: proc.pid,
|
||||
port,
|
||||
authToken,
|
||||
...(persistState
|
||||
? {}
|
||||
: {
|
||||
commitState,
|
||||
stop: async () => {
|
||||
await terminateDaemonProcess(proc.pid);
|
||||
},
|
||||
}),
|
||||
});
|
||||
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);
|
||||
};
|
||||
|
||||
timeout = setTimeout(poll, 1000);
|
||||
proc.on('error', (error) => {
|
||||
finish({ success: false, port, error: error.message });
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
proc.on('error', (error) => {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: 'Proxy daemon exited before becoming healthy',
|
||||
error: error.message,
|
||||
bindConflict: isPortBindConflictMessage(error.message),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (code !== null) {
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
const bindConflict = isPortBindConflictMessage(stderr);
|
||||
if (code === 0) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: 'Proxy daemon exited before becoming healthy',
|
||||
bindConflict,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (code !== null) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: stderr.trim() || `Proxy daemon exited with code ${code}`,
|
||||
bindConflict,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon exited with code ${code}`,
|
||||
error: `Proxy daemon was killed by signal ${signal}`,
|
||||
bindConflict,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon was killed by signal ${signal}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const launchProxy = async (persistState: boolean): Promise<OpenAICompatProxyLaunchResult> => {
|
||||
const attemptedPorts = new Set<number>();
|
||||
let lastResult: OpenAICompatProxyLaunchResult | null = null;
|
||||
const candidates = listOpenAICompatProxyCandidatePorts(
|
||||
preferredPort,
|
||||
requiresExactPort,
|
||||
attemptedPorts
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
port: preferredPort,
|
||||
error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const port of candidates) {
|
||||
const result = await launchOnPort(port, persistState);
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
if (requiresExactPort && result.bindConflict) {
|
||||
return {
|
||||
...result,
|
||||
error: `Requested proxy port ${preferredPort} is already in use`,
|
||||
};
|
||||
}
|
||||
|
||||
lastResult = result;
|
||||
attemptedPorts.add(port);
|
||||
if (!result.bindConflict) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
lastResult ?? {
|
||||
success: false,
|
||||
port: preferredPort,
|
||||
error: requiresExactPort
|
||||
? `Requested proxy port ${preferredPort} is already in use`
|
||||
: 'No free proxy port found in the proxy port range',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (status.running) {
|
||||
const launched = await launchProxy(false);
|
||||
if (!launched.success) {
|
||||
return launched;
|
||||
}
|
||||
|
||||
const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName);
|
||||
if (!stopped.success) {
|
||||
await launched.stop?.();
|
||||
return {
|
||||
success: false,
|
||||
port: launched.port,
|
||||
error: stopped.error || 'Failed to replace the running proxy',
|
||||
};
|
||||
}
|
||||
|
||||
launched.commitState?.();
|
||||
return launched;
|
||||
}
|
||||
|
||||
return launchProxy(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths';
|
||||
|
||||
export interface OpenAICompatProxyPortPreference {
|
||||
port: number;
|
||||
source: 'default' | 'profile';
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyPortPreference(
|
||||
profileName: string
|
||||
): OpenAICompatProxyPortPreference {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const profilePort = config.proxy?.profile_ports?.[profileName];
|
||||
if (typeof profilePort === 'number') {
|
||||
return { port: profilePort, source: 'profile' };
|
||||
}
|
||||
return {
|
||||
port: config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT,
|
||||
source: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyPreferredPort(profileName: string): number {
|
||||
return resolveOpenAICompatProxyPortPreference(profileName).port;
|
||||
}
|
||||
@@ -139,20 +139,13 @@ export function attachDisconnectAbortHandlers(
|
||||
|
||||
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');
|
||||
if (req.socket?.destroyed === true) {
|
||||
abortOnDisconnect('poll.socket.destroyed');
|
||||
}
|
||||
}, 50);
|
||||
|
||||
|
||||
@@ -35,32 +35,42 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt
|
||||
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,
|
||||
});
|
||||
if ((method === 'GET' || method === 'HEAD') && pathname === '/health') {
|
||||
if (method === 'HEAD') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end();
|
||||
} else {
|
||||
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'],
|
||||
});
|
||||
if ((method === 'GET' || method === 'HEAD') && pathname === '/') {
|
||||
if (method === 'HEAD') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end();
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
interface AnthropicThinking {
|
||||
type?: 'enabled' | 'disabled' | string;
|
||||
type?: 'enabled' | 'disabled' | 'adaptive' | string;
|
||||
budget_tokens?: number;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface AnthropicImageBlock {
|
||||
type?: string;
|
||||
media_type?: string;
|
||||
data?: string;
|
||||
url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ interface AnthropicToolResultBlock {
|
||||
type: 'tool_result';
|
||||
tool_use_id?: string;
|
||||
content?: unknown;
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
type AnthropicContentBlock =
|
||||
@@ -42,6 +44,16 @@ interface AnthropicMessage {
|
||||
content?: string | AnthropicContentBlock[];
|
||||
}
|
||||
|
||||
interface AnthropicOutputConfig {
|
||||
effort?: 'low' | 'medium' | 'high' | 'max' | string;
|
||||
}
|
||||
|
||||
interface AnthropicToolChoice {
|
||||
type?: 'auto' | 'any' | 'tool' | 'none' | string;
|
||||
name?: string;
|
||||
disable_parallel_tool_use?: boolean;
|
||||
}
|
||||
|
||||
interface AnthropicProxyRequestShape {
|
||||
model?: unknown;
|
||||
system?: unknown;
|
||||
@@ -52,8 +64,10 @@ interface AnthropicProxyRequestShape {
|
||||
stop_sequences?: unknown;
|
||||
metadata?: unknown;
|
||||
tools?: unknown;
|
||||
tool_choice?: AnthropicToolChoice;
|
||||
stream?: unknown;
|
||||
thinking?: AnthropicThinking;
|
||||
output_config?: AnthropicOutputConfig;
|
||||
}
|
||||
|
||||
interface OpenAITextPart {
|
||||
@@ -100,6 +114,17 @@ export interface ProxyOpenAIRequest {
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}>;
|
||||
tool_choice?:
|
||||
| 'auto'
|
||||
| 'none'
|
||||
| 'required'
|
||||
| {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
parallel_tool_calls?: boolean;
|
||||
messages: OpenAIMessage[];
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
@@ -108,7 +133,6 @@ export interface ProxyOpenAIRequest {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]';
|
||||
const TOOL_USE_ARGUMENTS_FALLBACK = '{}';
|
||||
|
||||
function assertObject(value: unknown, label: string): Record<string, unknown> {
|
||||
@@ -168,17 +192,49 @@ function flattenTextContent(content: unknown, label: string): string {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function toToolResultContent(content: unknown, label: string): string {
|
||||
/**
|
||||
* Convert tool_result content to OpenAI-compatible format.
|
||||
* Handles strings, arrays with text/image blocks, and error prefixing.
|
||||
* Ported from openclaude's convertToolResultContent.
|
||||
*/
|
||||
function convertToolResultContent(content: unknown, isError: boolean, label: string): string {
|
||||
if (content === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
return isError ? `Error: ${content}` : content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return flattenTextContent(content, label);
|
||||
if (!Array.isArray(content)) {
|
||||
const text = safeJsonStringify(content, '[unserializable content]');
|
||||
return isError ? `Error: ${text}` : text;
|
||||
}
|
||||
return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const [index, block] of content.entries()) {
|
||||
const parsed = assertObject(block, `${label}[${index}]`);
|
||||
|
||||
if (parsed.type === 'text' && typeof parsed.text === 'string') {
|
||||
parts.push(parsed.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.type === 'image') {
|
||||
throw new Error(`${label}[${index}].type "image" is not supported in tool_result content`);
|
||||
}
|
||||
|
||||
if (typeof parsed.text === 'string') {
|
||||
parts.push(parsed.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`);
|
||||
}
|
||||
|
||||
const text = parts.join('\n');
|
||||
if (!text) {
|
||||
return isError ? 'Error:' : '';
|
||||
}
|
||||
return isError ? `Error: ${text}` : text;
|
||||
}
|
||||
|
||||
function createFallbackToolId(messageIndex: number, blockIndex: number): string {
|
||||
@@ -187,16 +243,27 @@ function createFallbackToolId(messageIndex: number, blockIndex: number): string
|
||||
|
||||
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`);
|
||||
if (!source) {
|
||||
throw new Error(`${label}.source is missing`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${source.media_type};base64,${source.data}`,
|
||||
},
|
||||
};
|
||||
if (source.type === 'url' && source.url) {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: { url: source.url },
|
||||
};
|
||||
}
|
||||
|
||||
if (source.type === 'base64' && source.media_type && source.data) {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${source.media_type};base64,${source.data}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`${label}.source must be a base64 or url image payload`);
|
||||
}
|
||||
|
||||
function isImageBlock(block: AnthropicContentBlock): block is AnthropicImageBlock {
|
||||
@@ -234,30 +301,85 @@ function transformTools(value: unknown): ProxyOpenAIRequest['tools'] {
|
||||
(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<string, unknown>)
|
||||
: { type: 'object', properties: {} },
|
||||
},
|
||||
}));
|
||||
.map((entry) => {
|
||||
const rawSchema =
|
||||
typeof entry.input_schema === 'object' && entry.input_schema !== null
|
||||
? (entry.input_schema as Record<string, unknown>)
|
||||
: { type: 'object', properties: {} };
|
||||
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: typeof entry.name === 'string' ? entry.name : 'tool',
|
||||
...(typeof entry.description === 'string' ? { description: entry.description } : {}),
|
||||
parameters: rawSchema,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
}
|
||||
|
||||
function transformToolChoice(
|
||||
value: AnthropicToolChoice | undefined,
|
||||
hasTools: boolean
|
||||
): Pick<ProxyOpenAIRequest, 'tool_choice' | 'parallel_tool_calls'> {
|
||||
if (!value) {
|
||||
return hasTools ? { tool_choice: 'auto' } : {};
|
||||
}
|
||||
|
||||
if (!hasTools) {
|
||||
throw new Error('tool_choice requires tools');
|
||||
}
|
||||
|
||||
const parallelToolCalls =
|
||||
value.disable_parallel_tool_use === true ? { parallel_tool_calls: false } : {};
|
||||
|
||||
switch (value.type) {
|
||||
case undefined:
|
||||
case 'auto':
|
||||
return { tool_choice: 'auto', ...parallelToolCalls };
|
||||
case 'none':
|
||||
return { tool_choice: 'none' };
|
||||
case 'any':
|
||||
return { tool_choice: 'required', ...parallelToolCalls };
|
||||
case 'tool':
|
||||
if (typeof value.name !== 'string' || value.name.trim().length === 0) {
|
||||
throw new Error('tool_choice.name must be a non-empty string when type is "tool"');
|
||||
}
|
||||
return {
|
||||
tool_choice: {
|
||||
type: 'function',
|
||||
function: { name: value.name.trim() },
|
||||
},
|
||||
...parallelToolCalls,
|
||||
};
|
||||
default:
|
||||
throw new Error('tool_choice.type must be "auto", "any", "tool", or "none"');
|
||||
}
|
||||
}
|
||||
|
||||
function mapThinkingToReasoning(
|
||||
thinking: AnthropicThinking | undefined
|
||||
thinking: AnthropicThinking | undefined,
|
||||
outputConfig: AnthropicOutputConfig | undefined
|
||||
): Pick<ProxyOpenAIRequest, 'reasoning' | 'reasoning_effort'> {
|
||||
if (!thinking || thinking.type === 'disabled') {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (thinking.type === 'adaptive') {
|
||||
const effort = toOpenAIEffort(resolveOutputConfigEffort(outputConfig) ?? 'high');
|
||||
return {
|
||||
reasoning_effort: effort,
|
||||
reasoning: {
|
||||
enabled: true,
|
||||
effort,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (thinking.type !== 'enabled') {
|
||||
throw new Error('thinking.type must be "enabled" or "disabled"');
|
||||
throw new Error('thinking.type must be "enabled", "adaptive", or "disabled"');
|
||||
}
|
||||
|
||||
const effort =
|
||||
@@ -274,12 +396,37 @@ function mapThinkingToReasoning(
|
||||
};
|
||||
}
|
||||
|
||||
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']);
|
||||
|
||||
function resolveOutputConfigEffort(
|
||||
outputConfig: AnthropicOutputConfig | undefined
|
||||
): string | undefined {
|
||||
if (!outputConfig || typeof outputConfig.effort !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = outputConfig.effort.trim().toLowerCase();
|
||||
return VALID_EFFORT_LEVELS.has(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Anthropic effort levels to OpenAI-compatible reasoning_effort.
|
||||
* Anthropic's `max` has no standard OpenAI equivalent — most providers
|
||||
* only accept low/medium/high and reject unknown values with a 400.
|
||||
* Ported from openclaude's standardEffortToOpenAI() which maps max -> xhigh
|
||||
* for Codex; for generic OpenAI-compat providers we clamp to high.
|
||||
*/
|
||||
function toOpenAIEffort(effort: string): string {
|
||||
return effort === 'max' ? 'high' : effort;
|
||||
}
|
||||
|
||||
function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
if (!Array.isArray(messagesValue)) {
|
||||
throw new Error('messages must be an array');
|
||||
}
|
||||
|
||||
const translatedMessages: OpenAIMessage[] = [];
|
||||
let pendingToolUseIds: Set<string> | null = null;
|
||||
let hasPendingToolUseIds = false;
|
||||
|
||||
messagesValue.forEach((message, messageIndex) => {
|
||||
const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage;
|
||||
@@ -288,8 +435,19 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`);
|
||||
}
|
||||
|
||||
if (pendingToolUseIds && pendingToolUseIds.size > 0 && role !== 'user') {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].role must be "user" with tool_result blocks after assistant tool_use`
|
||||
);
|
||||
}
|
||||
|
||||
const content = parsedMessage.content;
|
||||
if (typeof content === 'string') {
|
||||
if (pendingToolUseIds && pendingToolUseIds.size > 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content must start with tool_result blocks for pending tool_use ids`
|
||||
);
|
||||
}
|
||||
translatedMessages.push({ role, content });
|
||||
return;
|
||||
}
|
||||
@@ -298,10 +456,135 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
throw new Error(`messages[${messageIndex}].content must be a string or array`);
|
||||
}
|
||||
|
||||
const userParts: OpenAIContentPart[] = [];
|
||||
if (role === 'user') {
|
||||
const userParts: OpenAIContentPart[] = [];
|
||||
const followUpParts: OpenAIContentPart[] = [];
|
||||
const resolvedToolUseIds = new Set<string>();
|
||||
|
||||
const handleUserPart = (
|
||||
part: OpenAIContentPart,
|
||||
blockIndex: number,
|
||||
kind: 'text' | 'image'
|
||||
) => {
|
||||
if (!pendingToolUseIds || pendingToolUseIds.size === 0) {
|
||||
userParts.push(part);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedToolUseIds.size === 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] ${kind} is not allowed before tool_result blocks for pending tool_use ids`
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedToolUseIds.size !== pendingToolUseIds.size) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] ${kind} is not allowed between tool_result blocks for pending tool_use ids`
|
||||
);
|
||||
}
|
||||
|
||||
followUpParts.push(part);
|
||||
};
|
||||
|
||||
content.forEach((block, blockIndex) => {
|
||||
const parsed = assertObject(
|
||||
block,
|
||||
`messages[${messageIndex}].content[${blockIndex}]`
|
||||
) as AnthropicContentBlock;
|
||||
|
||||
if (parsed.type === 'thinking' || parsed.type === 'redacted_thinking') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'text') {
|
||||
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
||||
handleUserPart({ type: 'text', text }, blockIndex, 'text');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isImageBlock(parsed)) {
|
||||
handleUserPart(
|
||||
toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`),
|
||||
blockIndex,
|
||||
'image'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isToolResultBlock(parsed)) {
|
||||
if (!pendingToolUseIds || pendingToolUseIds.size === 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] tool_result requires a preceding assistant tool_use`
|
||||
);
|
||||
}
|
||||
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`
|
||||
);
|
||||
}
|
||||
if (!pendingToolUseIds.has(parsed.tool_use_id)) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}].tool_use_id "${parsed.tool_use_id}" does not match a pending tool_use`
|
||||
);
|
||||
}
|
||||
if (resolvedToolUseIds.has(parsed.tool_use_id)) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}].tool_use_id "${parsed.tool_use_id}" is duplicated`
|
||||
);
|
||||
}
|
||||
resolvedToolUseIds.add(parsed.tool_use_id);
|
||||
translatedMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: parsed.tool_use_id,
|
||||
content: convertToolResultContent(
|
||||
parsed.content,
|
||||
parsed.is_error === true,
|
||||
`messages[${messageIndex}].content[${blockIndex}].content`
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isToolUseBlock(parsed)) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}].type "${String(parsed.type)}" is not supported`
|
||||
);
|
||||
});
|
||||
|
||||
if (resolvedToolUseIds.size > 0) {
|
||||
if (resolvedToolUseIds.size !== pendingToolUseIds?.size) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content must provide tool_result blocks for all pending tool_use ids`
|
||||
);
|
||||
}
|
||||
pendingToolUseIds = null;
|
||||
hasPendingToolUseIds = false;
|
||||
}
|
||||
|
||||
if (pendingToolUseIds && pendingToolUseIds.size > 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content must include tool_result blocks for pending tool_use ids`
|
||||
);
|
||||
}
|
||||
|
||||
if (userParts.length > 0) {
|
||||
flushUserContent(translatedMessages, userParts);
|
||||
}
|
||||
|
||||
if (followUpParts.length > 0) {
|
||||
flushUserContent(translatedMessages, followUpParts);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Assistant role
|
||||
const assistantTextParts: string[] = [];
|
||||
const toolCalls: NonNullable<OpenAIMessage['tool_calls']> = [];
|
||||
let sawToolResult = false;
|
||||
|
||||
content.forEach((block, blockIndex) => {
|
||||
const parsed = assertObject(
|
||||
@@ -309,32 +592,17 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
`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);
|
||||
}
|
||||
if (parsed.type === 'thinking' || parsed.type === 'redacted_thinking') {
|
||||
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}]`));
|
||||
if (parsed.type === 'text') {
|
||||
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
||||
assistantTextParts.push(text);
|
||||
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
|
||||
@@ -349,28 +617,16 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isImageBlock(parsed)) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] image requires user role`
|
||||
);
|
||||
}
|
||||
|
||||
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}] tool_result requires user role`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
@@ -378,26 +634,72 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
);
|
||||
});
|
||||
|
||||
if (role === 'assistant') {
|
||||
translatedMessages.push({
|
||||
role: 'assistant',
|
||||
content: assistantTextParts.join('\n'),
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
});
|
||||
if (assistantTextParts.length === 0 && toolCalls.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (userParts.length > 0 || !sawToolResult) {
|
||||
flushUserContent(translatedMessages, userParts);
|
||||
}
|
||||
pendingToolUseIds =
|
||||
toolCalls.length > 0 ? new Set(toolCalls.map((toolCall) => toolCall.id)) : null;
|
||||
hasPendingToolUseIds = toolCalls.length > 0;
|
||||
|
||||
translatedMessages.push({
|
||||
role: 'assistant',
|
||||
content: assistantTextParts.join('\n'),
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
if (hasPendingToolUseIds) {
|
||||
throw new Error('messages must provide tool_result blocks for the latest assistant tool_use');
|
||||
}
|
||||
|
||||
return translatedMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce consecutive messages of the same role.
|
||||
* OpenAI/vLLM/Ollama/Mistral require strict user<->assistant alternation.
|
||||
* Multiple consecutive tool messages are allowed (assistant -> tool* -> user).
|
||||
* Ported from openclaude's coalescing pass.
|
||||
*/
|
||||
function coalesceMessages(messages: OpenAIMessage[]): OpenAIMessage[] {
|
||||
const coalesced: OpenAIMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const prev = coalesced[coalesced.length - 1];
|
||||
|
||||
if (prev && prev.role === msg.role && msg.role !== 'tool' && msg.role !== 'system') {
|
||||
const prevContent = prev.content;
|
||||
const curContent = msg.content;
|
||||
|
||||
if (typeof prevContent === 'string' && typeof curContent === 'string') {
|
||||
prev.content = prevContent + (prevContent && curContent ? '\n' : '') + curContent;
|
||||
} else {
|
||||
const toArray = (
|
||||
c: string | OpenAIContentPart[] | null | undefined
|
||||
): OpenAIContentPart[] => {
|
||||
if (!c) return [];
|
||||
if (typeof c === 'string') return c ? [{ type: 'text', text: c }] : [];
|
||||
return c;
|
||||
};
|
||||
prev.content = [...toArray(prevContent), ...toArray(curContent)];
|
||||
}
|
||||
|
||||
if (msg.tool_calls?.length) {
|
||||
prev.tool_calls = [...(prev.tool_calls ?? []), ...msg.tool_calls];
|
||||
}
|
||||
} else {
|
||||
coalesced.push({ ...msg });
|
||||
}
|
||||
}
|
||||
|
||||
return coalesced;
|
||||
}
|
||||
|
||||
export class ProxyRequestTransformer {
|
||||
transform(raw: unknown): ProxyOpenAIRequest {
|
||||
const source = assertObject(raw || {}, 'request') as AnthropicProxyRequestShape;
|
||||
const tools = transformTools(source.tools);
|
||||
const messages = transformMessages(source.messages);
|
||||
const system = source.system;
|
||||
const allMessages =
|
||||
@@ -414,14 +716,15 @@ export class ProxyRequestTransformer {
|
||||
? source.model.trim()
|
||||
: undefined,
|
||||
stream: source.stream === true,
|
||||
messages: allMessages,
|
||||
messages: coalesceMessages(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),
|
||||
tools,
|
||||
...transformToolChoice(source.tool_choice, tools !== undefined),
|
||||
...mapThinkingToReasoning(source.thinking, source.output_config),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,12 @@ import { spawn, ChildProcess } from 'child_process';
|
||||
import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter';
|
||||
import { detectClaudeCli, getClaudeCliInfo } from '../utils/claude-detector';
|
||||
import type { ProfileType } from '../types/profile';
|
||||
import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from '../utils/shell-executor';
|
||||
import {
|
||||
escapeShellArg,
|
||||
getWindowsEscapedCommandShell,
|
||||
stripAnthropicEnv,
|
||||
stripClaudeCodeEnv,
|
||||
} from '../utils/shell-executor';
|
||||
import { ErrorManager } from '../utils/error-manager';
|
||||
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||
import { appendBrowserToolArgs } from '../utils/browser';
|
||||
@@ -111,7 +116,7 @@ export class ClaudeAdapter implements TargetAdapter {
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,12 @@ import type { ProfileType } from '../types/profile';
|
||||
import { runCleanup } from '../errors';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||
import { escapeShellArg, stripAnthropicEnv, stripCodexSessionEnv } from '../utils/shell-executor';
|
||||
import {
|
||||
escapeShellArg,
|
||||
getWindowsEscapedCommandShell,
|
||||
stripAnthropicEnv,
|
||||
stripCodexSessionEnv,
|
||||
} from '../utils/shell-executor';
|
||||
import type {
|
||||
TargetAdapter,
|
||||
TargetBinaryInfo,
|
||||
@@ -316,7 +321,7 @@ export class CodexAdapter implements TargetAdapter {
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
env: launchEnv,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as childProcess from 'child_process';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { escapeShellArg } from '../utils/shell-executor';
|
||||
import { escapeShellArg, getWindowsEscapedCommandShell } from '../utils/shell-executor';
|
||||
import type { TargetBinaryInfo } from './target-adapter';
|
||||
|
||||
const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides';
|
||||
@@ -53,12 +53,14 @@ function runCodexProbe(codexPath: string, args: string[]): string | undefined {
|
||||
|
||||
if (needsShell) {
|
||||
const cmdString = [codexPath, ...args].map(escapeShellArg).join(' ');
|
||||
return childProcess.execFileSync('cmd.exe', ['/d', '/s', '/c', cmdString], {
|
||||
const result = childProcess.spawnSync(cmdString, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
});
|
||||
return result.status === 0 ? result.stdout : undefined;
|
||||
}
|
||||
|
||||
return childProcess.execFileSync(codexPath, args, {
|
||||
|
||||
@@ -12,7 +12,11 @@ import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-d
|
||||
import type { ProfileType } from '../types/profile';
|
||||
import { upsertCcsModel } from './droid-config-manager';
|
||||
import { resolveDroidProvider } from './droid-provider';
|
||||
import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor';
|
||||
import {
|
||||
escapeShellArg,
|
||||
getWindowsEscapedCommandShell,
|
||||
stripAnthropicEnv,
|
||||
} from '../utils/shell-executor';
|
||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||
import { runCleanup } from '../errors';
|
||||
|
||||
@@ -134,7 +138,7 @@ export class DroidAdapter implements TargetAdapter {
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -29,6 +29,17 @@ const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES';
|
||||
const LEGACY_TARGET_ALIAS_ENV_VARS: Partial<Record<TargetType, string>> =
|
||||
getLegacyTargetAliasEnvVars();
|
||||
const RESERVED_BIN_NAMES = new Set<string>(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]);
|
||||
const INTERNAL_RUNTIME_ENTRY_BASENAMES = new Set([
|
||||
'droid-runtime',
|
||||
'droid-runtime.ts',
|
||||
'droid-runtime.js',
|
||||
'codex-runtime',
|
||||
'codex-runtime.ts',
|
||||
'codex-runtime.js',
|
||||
'ccsxp-runtime',
|
||||
'ccsxp-runtime.ts',
|
||||
'ccsxp-runtime.js',
|
||||
]);
|
||||
|
||||
function addAliasToMap(map: Record<string, TargetType>, alias: string, target: TargetType): void {
|
||||
const normalizedAlias = alias.trim().toLowerCase();
|
||||
@@ -102,6 +113,11 @@ function resolveEntrypointTarget(): TargetType | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entryScript = path.basename(process.argv[1] || '');
|
||||
if (!INTERNAL_RUNTIME_ENTRY_BASENAMES.has(entryScript)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTarget = rawTarget.trim().toLowerCase();
|
||||
return isRuntimeTargetType(normalizedTarget) ? normalizedTarget : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { BrowserToolPolicy } from '../../config/unified-config-types';
|
||||
|
||||
export type BrowserLaunchOverride = 'force-enable' | 'force-disable';
|
||||
|
||||
export interface BrowserLaunchFlagResolution {
|
||||
override?: BrowserLaunchOverride;
|
||||
argsWithoutFlags: string[];
|
||||
}
|
||||
|
||||
export interface ResolvedBrowserExposure {
|
||||
enabled: boolean;
|
||||
policy: BrowserToolPolicy;
|
||||
override?: BrowserLaunchOverride;
|
||||
exposeByDefault: boolean;
|
||||
exposeForLaunch: boolean;
|
||||
}
|
||||
|
||||
const ENABLE_BROWSER_FLAG = '--browser';
|
||||
const DISABLE_BROWSER_FLAG = '--no-browser';
|
||||
|
||||
export function resolveBrowserLaunchFlagResolution(args: string[]): BrowserLaunchFlagResolution {
|
||||
let override: BrowserLaunchOverride | undefined;
|
||||
const argsWithoutFlags: string[] = [];
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === '--') {
|
||||
argsWithoutFlags.push(...args.slice(index));
|
||||
break;
|
||||
}
|
||||
|
||||
if (arg === ENABLE_BROWSER_FLAG) {
|
||||
if (override === 'force-disable') {
|
||||
throw new Error('Use either `--browser` or `--no-browser`, not both.');
|
||||
}
|
||||
override = 'force-enable';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === DISABLE_BROWSER_FLAG) {
|
||||
if (override === 'force-enable') {
|
||||
throw new Error('Use either `--browser` or `--no-browser`, not both.');
|
||||
}
|
||||
override = 'force-disable';
|
||||
continue;
|
||||
}
|
||||
|
||||
argsWithoutFlags.push(arg);
|
||||
}
|
||||
|
||||
return {
|
||||
override,
|
||||
argsWithoutFlags,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBrowserExposure(
|
||||
config: { enabled: boolean; policy: BrowserToolPolicy },
|
||||
override?: BrowserLaunchOverride
|
||||
): ResolvedBrowserExposure {
|
||||
const exposeByDefault = config.enabled && config.policy === 'auto';
|
||||
const exposeForLaunch =
|
||||
config.enabled &&
|
||||
(override === 'force-enable' || (override !== 'force-disable' && exposeByDefault));
|
||||
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
policy: config.policy,
|
||||
override,
|
||||
exposeByDefault,
|
||||
exposeForLaunch,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeBrowserPolicy(policy: BrowserToolPolicy): string {
|
||||
return policy === 'manual' ? 'manual' : 'auto';
|
||||
}
|
||||
|
||||
export function describeDefaultBrowserExposure(policy: BrowserToolPolicy): string {
|
||||
return policy === 'manual' ? 'hidden until `--browser`' : 'auto-exposed';
|
||||
}
|
||||
|
||||
export function getBlockedBrowserOverrideWarning(
|
||||
laneLabel: string,
|
||||
exposure: ResolvedBrowserExposure
|
||||
): string | undefined {
|
||||
if (exposure.override === 'force-enable' && !exposure.enabled) {
|
||||
return `Browser tooling was requested with \`--browser\`, but ${laneLabel} is disabled in CCS config.`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
import type { BrowserConfig } from '../../config/unified-config-types';
|
||||
import { getCcsDir, getCcsPathDisplay } from '../config-manager';
|
||||
import { expandPath } from '../helpers';
|
||||
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
|
||||
import { getNodePlatformKey } from './platform';
|
||||
|
||||
export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
|
||||
|
||||
@@ -11,10 +14,33 @@ export interface EffectiveClaudeBrowserAttachConfig {
|
||||
overrideActive: boolean;
|
||||
userDataDir: string;
|
||||
devtoolsPort: number;
|
||||
evalMode: BrowserEvalMode;
|
||||
hasExplicitDevtoolsPort: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserLaunchCommands {
|
||||
darwin: string;
|
||||
linux: string;
|
||||
win32: string;
|
||||
}
|
||||
|
||||
export interface BrowserAttachRuntimeResolution {
|
||||
runtimeEnv?: BrowserRuntimeEnv;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface ManagedBrowserAttachBootstrap {
|
||||
usesManagedDefaultDir: boolean;
|
||||
createdProfileDir: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedBrowserAttachNotReadyMessage {
|
||||
state: 'path_missing' | 'browser_not_running' | 'endpoint_unreachable';
|
||||
title: string;
|
||||
detail: string;
|
||||
nextStep: string;
|
||||
warning: string;
|
||||
}
|
||||
|
||||
export function getRecommendedBrowserUserDataDir(): string {
|
||||
return path.join(getCcsDir(), 'browser', 'chrome-user-data');
|
||||
}
|
||||
@@ -23,6 +49,153 @@ export function resolveBrowserUserDataDir(value?: string): string | undefined {
|
||||
return value?.trim() ? expandPath(value) : undefined;
|
||||
}
|
||||
|
||||
export function buildBrowserLaunchCommands(
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function isManagedClaudeBrowserAttachConfig(
|
||||
config: EffectiveClaudeBrowserAttachConfig
|
||||
): boolean {
|
||||
return (
|
||||
config.source === 'config' &&
|
||||
path.resolve(config.userDataDir) === path.resolve(getRecommendedBrowserUserDataDir())
|
||||
);
|
||||
}
|
||||
|
||||
function buildCurrentPlatformLaunchCommand(userDataDir: string, devtoolsPort: number): string {
|
||||
return buildBrowserLaunchCommands(userDataDir, devtoolsPort)[getNodePlatformKey()];
|
||||
}
|
||||
|
||||
function buildManagedBrowserActionLines(): string[] {
|
||||
return [
|
||||
'Run `ccs browser setup` to configure and start the managed browser session.',
|
||||
'Diagnose only: `ccs browser doctor`.',
|
||||
];
|
||||
}
|
||||
|
||||
export function buildManagedBrowserAttachSetupOptions(
|
||||
_config: EffectiveClaudeBrowserAttachConfig
|
||||
): string[] {
|
||||
return buildManagedBrowserActionLines();
|
||||
}
|
||||
|
||||
function buildManagedBrowserAttachWarning(_config: EffectiveClaudeBrowserAttachConfig): string {
|
||||
return [
|
||||
'Claude Browser Attach is not ready yet.',
|
||||
` Managed user-data dir: ${getCcsPathDisplay('browser', 'chrome-user-data')}`,
|
||||
' CCS will continue without browser tools for this launch.',
|
||||
'',
|
||||
...buildManagedBrowserActionLines().map((line) => ` ${line}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function ensureManagedBrowserUserDataDir(
|
||||
config: EffectiveClaudeBrowserAttachConfig
|
||||
): ManagedBrowserAttachBootstrap {
|
||||
if (!isManagedClaudeBrowserAttachConfig(config)) {
|
||||
return {
|
||||
usesManagedDefaultDir: false,
|
||||
createdProfileDir: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
fs.statSync(config.userDataDir);
|
||||
return {
|
||||
usesManagedDefaultDir: true,
|
||||
createdProfileDir: false,
|
||||
};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code && code !== 'ENOENT') {
|
||||
return {
|
||||
usesManagedDefaultDir: true,
|
||||
createdProfileDir: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(config.userDataDir, { recursive: true, mode: 0o700 });
|
||||
return {
|
||||
usesManagedDefaultDir: true,
|
||||
createdProfileDir: true,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
usesManagedDefaultDir: true,
|
||||
createdProfileDir: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function describeManagedBrowserAttachNotReady(
|
||||
config: EffectiveClaudeBrowserAttachConfig,
|
||||
errorMessage: string,
|
||||
options: {
|
||||
createdProfileDir?: boolean;
|
||||
launchCommand?: string;
|
||||
} = {}
|
||||
): ManagedBrowserAttachNotReadyMessage | undefined {
|
||||
if (!isManagedClaudeBrowserAttachConfig(config)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const launchCommand =
|
||||
options.launchCommand ??
|
||||
buildCurrentPlatformLaunchCommand(
|
||||
getCcsPathDisplay('browser', 'chrome-user-data'),
|
||||
config.devtoolsPort
|
||||
);
|
||||
const nextStep = [
|
||||
...buildManagedBrowserActionLines(),
|
||||
`Manual launch (${getNodePlatformKey()}): ${launchCommand}`,
|
||||
].join('\n');
|
||||
|
||||
if (errorMessage.includes('Chrome reuse metadata')) {
|
||||
const summary = options.createdProfileDir
|
||||
? 'CCS created the managed browser profile directory, but the attach session is not running yet.'
|
||||
: 'No running attach-mode Chrome session is using the managed browser profile yet.';
|
||||
return {
|
||||
state: 'browser_not_running',
|
||||
title: 'Claude Browser Attach is not ready yet.',
|
||||
detail: `${summary} Diagnostic: ${errorMessage}`,
|
||||
nextStep,
|
||||
warning: buildManagedBrowserAttachWarning(config),
|
||||
};
|
||||
}
|
||||
|
||||
if (errorMessage.includes('Chrome DevTools endpoint')) {
|
||||
return {
|
||||
state: 'endpoint_unreachable',
|
||||
title: 'Claude Browser Attach could not reach the managed Chrome session.',
|
||||
detail: `CCS found the managed browser profile, but the DevTools endpoint did not answer successfully. Diagnostic: ${errorMessage}`,
|
||||
nextStep,
|
||||
warning: buildManagedBrowserAttachWarning(config),
|
||||
};
|
||||
}
|
||||
|
||||
if (errorMessage.includes('Chrome profile directory is invalid')) {
|
||||
return {
|
||||
state: 'path_missing',
|
||||
title: 'Claude Browser Attach could not initialize the managed profile.',
|
||||
detail: `CCS could not initialize the managed browser profile directory. Diagnostic: ${errorMessage}`,
|
||||
nextStep,
|
||||
warning: buildManagedBrowserAttachWarning(config),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getBrowserAttachOverride(env: NodeJS.ProcessEnv = process.env): {
|
||||
userDataDir?: string;
|
||||
devtoolsPort?: number;
|
||||
@@ -65,7 +238,6 @@ export function getEffectiveClaudeBrowserAttachConfig(
|
||||
overrideActive: true,
|
||||
userDataDir: override.userDataDir,
|
||||
devtoolsPort: override.devtoolsPort ?? configPort,
|
||||
evalMode: config.claude.eval_mode,
|
||||
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
|
||||
};
|
||||
}
|
||||
@@ -76,14 +248,46 @@ export function getEffectiveClaudeBrowserAttachConfig(
|
||||
overrideActive: false,
|
||||
userDataDir: configUserDataDir,
|
||||
devtoolsPort: configPort,
|
||||
evalMode: config.claude.eval_mode,
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveOptionalBrowserAttachRuntime(
|
||||
config: EffectiveClaudeBrowserAttachConfig
|
||||
): Promise<BrowserAttachRuntimeResolution> {
|
||||
if (!config.enabled) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const bootstrap = ensureManagedBrowserUserDataDir(config);
|
||||
if (bootstrap.createdProfileDir) {
|
||||
return {
|
||||
warning: buildManagedBrowserAttachWarning(config),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
runtimeEnv: await resolveBrowserRuntimeEnv({
|
||||
profileDir: config.userDataDir,
|
||||
devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const managedDefaultMessage = describeManagedBrowserAttachNotReady(config, message, {
|
||||
createdProfileDir: bootstrap.createdProfileDir,
|
||||
});
|
||||
if (managedDefaultMessage) {
|
||||
return {
|
||||
warning: managedDefaultMessage.warning,
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDevtoolsPort(value?: string): number | undefined {
|
||||
if (!value?.trim() || !/^\d+$/.test(value.trim())) {
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import type { BrowserConfig } from '../../config/unified-config-types';
|
||||
import { getNodePlatformKey } from './platform';
|
||||
import { type BrowserStatusPayload, getBrowserStatus } from './browser-status';
|
||||
import { ensureBrowserMcp } from './mcp-installer';
|
||||
import {
|
||||
ensureManagedBrowserUserDataDir,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
getRecommendedBrowserUserDataDir,
|
||||
isManagedClaudeBrowserAttachConfig,
|
||||
} from './browser-settings';
|
||||
|
||||
export interface BrowserSetupResult {
|
||||
configUpdated: boolean;
|
||||
createdUserDataDir: boolean;
|
||||
mcpReady: boolean;
|
||||
overrideActive: boolean;
|
||||
ready: boolean;
|
||||
launchCommand: string;
|
||||
status: BrowserStatusPayload;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface BrowserSetupDeps {
|
||||
getBrowserConfig: typeof getBrowserConfig;
|
||||
mutateUnifiedConfig: typeof mutateUnifiedConfig;
|
||||
ensureBrowserMcp: typeof ensureBrowserMcp;
|
||||
getBrowserStatus: typeof getBrowserStatus;
|
||||
}
|
||||
|
||||
const defaultBrowserSetupDeps: BrowserSetupDeps = {
|
||||
getBrowserConfig,
|
||||
mutateUnifiedConfig,
|
||||
ensureBrowserMcp,
|
||||
getBrowserStatus,
|
||||
};
|
||||
|
||||
export async function runBrowserSetup(
|
||||
deps: BrowserSetupDeps = defaultBrowserSetupDeps
|
||||
): Promise<BrowserSetupResult> {
|
||||
const initialConfig = deps.getBrowserConfig();
|
||||
const configUpdated = persistBrowserSetupConfig(deps, initialConfig);
|
||||
const persistedConfig = deps.getBrowserConfig();
|
||||
const effectiveConfig = getEffectiveClaudeBrowserAttachConfig(persistedConfig);
|
||||
const createdUserDataDir = isManagedClaudeBrowserAttachConfig(effectiveConfig)
|
||||
? ensureManagedBrowserUserDataDir(effectiveConfig).createdProfileDir
|
||||
: false;
|
||||
const mcpReady = deps.ensureBrowserMcp();
|
||||
const status = await deps.getBrowserStatus();
|
||||
const notes: string[] = [];
|
||||
|
||||
if (effectiveConfig.overrideActive) {
|
||||
notes.push(
|
||||
`Current session is using ${effectiveConfig.source}; remove that override if you want saved browser settings to take effect in future shells.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!mcpReady) {
|
||||
notes.push('CCS could not fully prepare the local browser MCP runtime.');
|
||||
}
|
||||
|
||||
if (!isManagedClaudeBrowserAttachConfig(effectiveConfig)) {
|
||||
notes.push(
|
||||
'Setup did not create the current browser user-data dir because it is not the CCS-managed default path.'
|
||||
);
|
||||
}
|
||||
|
||||
const platform = getNodePlatformKey();
|
||||
return {
|
||||
configUpdated,
|
||||
createdUserDataDir,
|
||||
mcpReady,
|
||||
overrideActive: effectiveConfig.overrideActive,
|
||||
ready: status.claude.state === 'ready' && mcpReady,
|
||||
launchCommand: status.claude.launchCommands[platform],
|
||||
status,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
function persistBrowserSetupConfig(deps: BrowserSetupDeps, currentConfig: BrowserConfig): boolean {
|
||||
const before = JSON.stringify(currentConfig);
|
||||
|
||||
deps.mutateUnifiedConfig((config) => {
|
||||
const existingBrowser = config.browser ?? currentConfig;
|
||||
const currentUserDataDir = existingBrowser.claude.user_data_dir?.trim();
|
||||
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: existingBrowser.claude.policy,
|
||||
user_data_dir: currentUserDataDir || getRecommendedBrowserUserDataDir(),
|
||||
devtools_port: currentConfig.claude.devtools_port,
|
||||
},
|
||||
codex: {
|
||||
enabled: existingBrowser.codex.enabled,
|
||||
policy: existingBrowser.codex.policy,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return JSON.stringify(deps.getBrowserConfig()) !== before;
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
import { getBrowserConfig } from '../../config/unified-config-loader';
|
||||
import * as path from 'path';
|
||||
import type { BrowserConfig, BrowserToolPolicy } from '../../config/unified-config-types';
|
||||
import { getBrowserConfig, loadUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { getCcsPathDisplay } from '../config-manager';
|
||||
import { getCodexBinaryInfo } from '../../targets/codex-detector';
|
||||
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
|
||||
import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer';
|
||||
import { getNodePlatformKey } from './platform';
|
||||
import {
|
||||
buildBrowserLaunchCommands,
|
||||
buildManagedBrowserAttachSetupOptions,
|
||||
describeManagedBrowserAttachNotReady,
|
||||
ensureManagedBrowserUserDataDir,
|
||||
type BrowserLaunchCommands,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
getRecommendedBrowserUserDataDir,
|
||||
} from './browser-settings';
|
||||
|
||||
export interface BrowserLaunchCommands {
|
||||
darwin: string;
|
||||
linux: string;
|
||||
win32: string;
|
||||
}
|
||||
|
||||
export interface ClaudeBrowserStatus {
|
||||
enabled: boolean;
|
||||
policy: BrowserToolPolicy;
|
||||
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
|
||||
overrideActive: boolean;
|
||||
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
|
||||
@@ -25,7 +28,6 @@ export interface ClaudeBrowserStatus {
|
||||
effectiveUserDataDir: string;
|
||||
recommendedUserDataDir: string;
|
||||
devtoolsPort: number;
|
||||
evalMode: 'disabled' | 'readonly' | 'readwrite';
|
||||
managedMcpServerName: string;
|
||||
managedMcpServerPath: string;
|
||||
launchCommands: BrowserLaunchCommands;
|
||||
@@ -34,12 +36,12 @@ export interface ClaudeBrowserStatus {
|
||||
|
||||
export interface CodexBrowserStatus {
|
||||
enabled: boolean;
|
||||
policy: BrowserToolPolicy;
|
||||
state: 'disabled' | 'enabled' | 'unsupported_build';
|
||||
title: string;
|
||||
detail: string;
|
||||
nextStep: string;
|
||||
serverName: string;
|
||||
evalMode: 'disabled' | 'readonly' | 'readwrite';
|
||||
supportsConfigOverrides: boolean;
|
||||
binaryPath: string | null;
|
||||
version?: string;
|
||||
@@ -51,26 +53,68 @@ export interface BrowserStatusPayload {
|
||||
}
|
||||
|
||||
export async function getBrowserStatus(): Promise<BrowserStatusPayload> {
|
||||
const browserConfig = getBrowserConfig();
|
||||
const browserConfig = getUserFacingBrowserConfig();
|
||||
return {
|
||||
claude: await buildClaudeBrowserStatus(browserConfig),
|
||||
codex: buildCodexBrowserStatus(browserConfig),
|
||||
};
|
||||
}
|
||||
|
||||
type PersistedBrowserConfig = {
|
||||
claude?: Partial<BrowserConfig['claude']>;
|
||||
codex?: Partial<BrowserConfig['codex']>;
|
||||
};
|
||||
|
||||
function resolveSafeBrowserPolicy(policy: BrowserToolPolicy | undefined): BrowserToolPolicy {
|
||||
return policy === 'auto' || policy === 'manual' ? policy : 'manual';
|
||||
}
|
||||
|
||||
export function getUserFacingBrowserConfig(): BrowserConfig {
|
||||
const canonical = getBrowserConfig();
|
||||
const persisted = loadUnifiedConfig()?.browser as PersistedBrowserConfig | undefined;
|
||||
|
||||
if (!persisted) {
|
||||
return {
|
||||
claude: {
|
||||
...canonical.claude,
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
},
|
||||
codex: {
|
||||
...canonical.codex,
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
claude: {
|
||||
...canonical.claude,
|
||||
enabled: persisted.claude?.enabled ?? false,
|
||||
policy: resolveSafeBrowserPolicy(persisted.claude?.policy),
|
||||
},
|
||||
codex: {
|
||||
...canonical.codex,
|
||||
enabled: persisted.codex?.enabled ?? false,
|
||||
policy: resolveSafeBrowserPolicy(persisted.codex?.policy),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildClaudeBrowserStatus(
|
||||
browserConfig = getBrowserConfig()
|
||||
browserConfig = getUserFacingBrowserConfig()
|
||||
): Promise<ClaudeBrowserStatus> {
|
||||
const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig);
|
||||
const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort);
|
||||
const launchCommands = buildBrowserLaunchCommands(effective.userDataDir, effective.devtoolsPort);
|
||||
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
|
||||
enabled: effective.enabled,
|
||||
policy: browserConfig.claude.policy,
|
||||
source: effective.source,
|
||||
overrideActive: effective.overrideActive,
|
||||
effectiveUserDataDir: effective.userDataDir,
|
||||
recommendedUserDataDir: getRecommendedBrowserUserDataDir(),
|
||||
devtoolsPort: effective.devtoolsPort,
|
||||
evalMode: effective.evalMode,
|
||||
managedMcpServerName: getBrowserMcpServerName(),
|
||||
managedMcpServerPath: getBrowserMcpServerPath(),
|
||||
launchCommands,
|
||||
@@ -82,17 +126,37 @@ async function buildClaudeBrowserStatus(
|
||||
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`.',
|
||||
'CCS keeps Claude Browser Attach off by default and will not provision the managed browser MCP runtime until this lane is enabled.',
|
||||
nextStep: `Enable Claude Browser Attach in Settings > Browser or in ${getCcsPathDisplay('config.yaml')}, then run \`ccs browser setup\` when you are ready to opt in.`,
|
||||
};
|
||||
}
|
||||
|
||||
const managedBootstrap = ensureManagedBrowserUserDataDir(effective);
|
||||
|
||||
if (managedBootstrap.createdProfileDir) {
|
||||
const managedMessage = describeManagedBrowserAttachNotReady(
|
||||
effective,
|
||||
`Chrome reuse metadata not found: ${path.join(effective.userDataDir, 'DevToolsActivePort')}`,
|
||||
{
|
||||
createdProfileDir: true,
|
||||
launchCommand: launchCommands[getNodePlatformKey()],
|
||||
}
|
||||
);
|
||||
if (managedMessage) {
|
||||
return {
|
||||
...base,
|
||||
state: managedMessage.state,
|
||||
title: managedMessage.title,
|
||||
detail: managedMessage.detail,
|
||||
nextStep: managedMessage.nextStep,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const runtimeEnv = await resolveBrowserRuntimeEnv({
|
||||
profileDir: effective.userDataDir,
|
||||
devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined,
|
||||
evalMode: effective.evalMode,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -100,12 +164,31 @@ async function buildClaudeBrowserStatus(
|
||||
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.',
|
||||
browserConfig.claude.policy === 'manual'
|
||||
? 'CCS can reach the configured Chrome DevTools endpoint, and the lane stays hidden until a launch uses `--browser`.'
|
||||
: 'CCS can reach the configured Chrome DevTools endpoint for the current attach session.',
|
||||
nextStep:
|
||||
browserConfig.claude.policy === 'manual'
|
||||
? 'Launch a Claude-target CCS session with `--browser` to use the managed browser MCP runtime.'
|
||||
: 'Launch a Claude-target CCS session to use the managed browser MCP runtime.',
|
||||
runtimeEnv,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
const managedMessage = describeManagedBrowserAttachNotReady(effective, message, {
|
||||
createdProfileDir: managedBootstrap.createdProfileDir,
|
||||
launchCommand: launchCommands[getNodePlatformKey()],
|
||||
});
|
||||
if (managedMessage) {
|
||||
return {
|
||||
...base,
|
||||
state: managedMessage.state,
|
||||
title: managedMessage.title,
|
||||
detail: managedMessage.detail,
|
||||
nextStep: managedMessage.nextStep,
|
||||
};
|
||||
}
|
||||
|
||||
if (message.includes('Chrome profile directory is invalid')) {
|
||||
return {
|
||||
...base,
|
||||
@@ -136,17 +219,18 @@ async function buildClaudeBrowserStatus(
|
||||
}
|
||||
}
|
||||
|
||||
function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrowserStatus {
|
||||
function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()): CodexBrowserStatus {
|
||||
if (!browserConfig.codex.enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
policy: browserConfig.codex.policy,
|
||||
state: 'disabled',
|
||||
title: 'Codex Browser Tools are disabled.',
|
||||
detail: 'CCS will not inject Playwright MCP browser tooling into Codex-target launches.',
|
||||
detail:
|
||||
'CCS keeps Codex Browser Tools off by default and will not inject Playwright MCP browser tooling until this lane is enabled.',
|
||||
nextStep:
|
||||
'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.',
|
||||
'Enable Codex Browser Tools in Settings > Browser when you want browser access on Codex-target launches.',
|
||||
serverName: 'ccs_browser',
|
||||
evalMode: browserConfig.codex.eval_mode,
|
||||
supportsConfigOverrides: false,
|
||||
binaryPath: null,
|
||||
};
|
||||
@@ -157,6 +241,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows
|
||||
if (!binaryInfo || !supportsConfigOverrides) {
|
||||
return {
|
||||
enabled: true,
|
||||
policy: browserConfig.codex.policy,
|
||||
state: 'unsupported_build',
|
||||
title: 'Codex Browser Tools need a Codex build with --config override support.',
|
||||
detail: binaryInfo
|
||||
@@ -164,7 +249,6 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows
|
||||
: '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',
|
||||
evalMode: browserConfig.codex.eval_mode,
|
||||
supportsConfigOverrides,
|
||||
binaryPath: binaryInfo?.path ?? null,
|
||||
version: binaryInfo?.version,
|
||||
@@ -173,23 +257,31 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
policy: browserConfig.codex.policy,
|
||||
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.',
|
||||
detail:
|
||||
browserConfig.codex.policy === 'manual'
|
||||
? 'CCS can inject the managed Playwright MCP overrides when a Codex-target launch opts in with `--browser`.'
|
||||
: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.',
|
||||
nextStep:
|
||||
browserConfig.codex.policy === 'manual'
|
||||
? 'Use `--browser` on a Codex-target CCS launch to access browser tools.'
|
||||
: 'Use a Codex-target CCS launch to access browser tools.',
|
||||
serverName: 'ccs_browser',
|
||||
evalMode: browserConfig.codex.eval_mode,
|
||||
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}`,
|
||||
};
|
||||
export function getManagedBrowserSetupHint(): string {
|
||||
return buildManagedBrowserAttachSetupOptions({
|
||||
enabled: true,
|
||||
source: 'config',
|
||||
overrideActive: false,
|
||||
userDataDir: getRecommendedBrowserUserDataDir(),
|
||||
devtoolsPort: 9222,
|
||||
hasExplicitDevtoolsPort: true,
|
||||
}).join('\n');
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { BrowserEvalMode } from '../../config/unified-config-types';
|
||||
import { expandPath } from '../helpers';
|
||||
|
||||
export interface BrowserReuseOptions {
|
||||
profileDir?: string;
|
||||
devtoolsPort?: string;
|
||||
evalMode?: BrowserEvalMode;
|
||||
}
|
||||
|
||||
export interface BrowserRuntimeEnv {
|
||||
[key: string]: string;
|
||||
CCS_BROWSER_USER_DATA_DIR: string;
|
||||
CCS_BROWSER_DEVTOOLS_HOST: string;
|
||||
CCS_BROWSER_DEVTOOLS_PORT: string;
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: string;
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: string;
|
||||
CCS_BROWSER_EVAL_MODE?: string;
|
||||
}
|
||||
|
||||
const DEVTOOLS_HOST = '127.0.0.1';
|
||||
@@ -103,7 +101,6 @@ export async function resolveBrowserRuntimeEnv(
|
||||
CCS_BROWSER_DEVTOOLS_PORT: port,
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: httpUrl,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: websocketUrl,
|
||||
...(options.evalMode ? { CCS_BROWSER_EVAL_MODE: options.evalMode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,32 @@ export {
|
||||
} from './mcp-installer';
|
||||
|
||||
export { appendBrowserToolArgs } from './claude-tool-args';
|
||||
export {
|
||||
describeBrowserPolicy,
|
||||
describeDefaultBrowserExposure,
|
||||
getBlockedBrowserOverrideWarning,
|
||||
resolveBrowserExposure,
|
||||
resolveBrowserLaunchFlagResolution,
|
||||
} from './browser-policy';
|
||||
export type { BrowserLaunchOverride, BrowserLaunchFlagResolution } from './browser-policy';
|
||||
|
||||
export {
|
||||
buildBrowserLaunchCommands,
|
||||
buildManagedBrowserAttachSetupOptions,
|
||||
describeManagedBrowserAttachNotReady,
|
||||
ensureManagedBrowserUserDataDir,
|
||||
getRecommendedBrowserUserDataDir,
|
||||
getBrowserAttachOverride,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
isManagedClaudeBrowserAttachConfig,
|
||||
resolveOptionalBrowserAttachRuntime,
|
||||
} from './browser-settings';
|
||||
export type {
|
||||
BrowserLaunchCommands,
|
||||
BrowserAttachRuntimeResolution,
|
||||
EffectiveClaudeBrowserAttachConfig,
|
||||
ManagedBrowserAttachBootstrap,
|
||||
ManagedBrowserAttachNotReadyMessage,
|
||||
} from './browser-settings';
|
||||
|
||||
export {
|
||||
@@ -30,9 +51,12 @@ export {
|
||||
} from './chrome-reuse';
|
||||
export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse';
|
||||
|
||||
export { getBrowserStatus } from './browser-status';
|
||||
export { getBrowserStatus, getManagedBrowserSetupHint } from './browser-status';
|
||||
export type {
|
||||
BrowserStatusPayload,
|
||||
ClaudeBrowserStatus,
|
||||
CodexBrowserStatus,
|
||||
} from './browser-status';
|
||||
|
||||
export { runBrowserSetup } from './browser-setup';
|
||||
export type { BrowserSetupResult } from './browser-setup';
|
||||
@@ -6,7 +6,11 @@
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess, SpawnOptions } from 'child_process';
|
||||
import { escapeShellArg, stripClaudeCodeEnv } from './shell-executor';
|
||||
import {
|
||||
escapeShellArg,
|
||||
getWindowsEscapedCommandShell,
|
||||
stripClaudeCodeEnv,
|
||||
} from './shell-executor';
|
||||
import { getClaudeCliInfo } from './claude-detector';
|
||||
import { ErrorManager } from './error-manager';
|
||||
|
||||
@@ -56,7 +60,7 @@ export function spawnClaude(options: SpawnClaudeOptions = {}): SpawnClaudeResult
|
||||
child = spawn(cmdString, {
|
||||
stdio,
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
env: mergedEnv,
|
||||
cwd,
|
||||
});
|
||||
|
||||
@@ -107,8 +107,22 @@ export function getCcsDirSource(): [string, string] {
|
||||
* Keeps the default path concise while preserving explicit overrides.
|
||||
*/
|
||||
export function getCcsDirDisplay(): string {
|
||||
return getCcsPathDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a CCS path as a user-facing display path.
|
||||
* Keeps the default location concise while preserving explicit overrides.
|
||||
*/
|
||||
export function getCcsPathDisplay(...segments: string[]): string {
|
||||
const [source, dir] = getCcsDirSource();
|
||||
return source === 'default' ? '~/.ccs' : dir;
|
||||
if (source === 'default') {
|
||||
return process.platform === 'win32'
|
||||
? path.win32.join('%USERPROFILE%\\.ccs', ...segments)
|
||||
: path.posix.join('~/.ccs', ...segments.map((segment) => segment.replace(/\\/g, '/')));
|
||||
}
|
||||
|
||||
return path.join(dir, ...segments);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Cross-platform shell execution utilities for CCS.
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync, ChildProcess } from 'child_process';
|
||||
import { spawn, spawnSync, ChildProcess, type SpawnOptions } from 'child_process';
|
||||
import { ErrorManager } from './error-manager';
|
||||
import { getWebSearchHookEnv } from './websearch-manager';
|
||||
import { wireChildProcessSignals } from './signal-forwarder';
|
||||
@@ -107,6 +107,20 @@ export function escapeShellArg(arg: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the shell that matches escapeShellArg() quoting semantics.
|
||||
*
|
||||
* On Windows, prefer ComSpec over a bare `cmd.exe` so escaped wrapper launches
|
||||
* keep the same shell contract without depending on PATH lookup.
|
||||
*/
|
||||
export function getWindowsEscapedCommandShell(): SpawnOptions['shell'] {
|
||||
if (process.platform !== 'win32') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return process.env.ComSpec || process.env.COMSPEC || 'cmd.exe';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Claude CLI with unified spawn logic
|
||||
*/
|
||||
@@ -182,7 +196,7 @@ export function execClaude(
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
shell: getWindowsEscapedCommandShell(),
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -228,6 +228,13 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
|
||||
cacheCreationPerMillion: 6.25,
|
||||
cacheReadPerMillion: 0.5,
|
||||
},
|
||||
// Claude 4.7 Opus ($5/$25)
|
||||
'claude-opus-4-7': {
|
||||
inputPerMillion: 5.0,
|
||||
outputPerMillion: 25.0,
|
||||
cacheCreationPerMillion: 6.25,
|
||||
cacheReadPerMillion: 0.5,
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI Models - Source: better-ccusage
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import type { BrowserConfig } from '../../config/unified-config-types';
|
||||
import { getBrowserStatus } from '../../utils/browser';
|
||||
import { getUserFacingBrowserConfig } from '../../utils/browser/browser-status';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
@@ -10,24 +12,28 @@ const BROWSER_LOCAL_ACCESS_ERROR =
|
||||
interface BrowserRouteBody {
|
||||
claude?: {
|
||||
enabled?: boolean;
|
||||
policy?: 'auto' | 'manual';
|
||||
userDataDir?: string;
|
||||
devtoolsPort?: number;
|
||||
evalMode?: 'disabled' | 'readonly' | 'readwrite';
|
||||
};
|
||||
codex?: {
|
||||
enabled?: boolean;
|
||||
evalMode?: 'disabled' | 'readonly' | 'readwrite';
|
||||
policy?: 'auto' | 'manual';
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isValidBrowserPolicy(value: string): value is 'auto' | 'manual' {
|
||||
return value === 'auto' || value === 'manual';
|
||||
}
|
||||
|
||||
function isValidDevtoolsPort(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 1 && value <= 65535;
|
||||
}
|
||||
|
||||
function isValidEvalMode(value: string): value is 'disabled' | 'readonly' | 'readwrite' {
|
||||
return value === 'disabled' || value === 'readonly' || value === 'readwrite';
|
||||
}
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) {
|
||||
next();
|
||||
@@ -36,7 +42,7 @@ router.use((req: Request, res: Response, next) => {
|
||||
|
||||
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const config = getBrowserConfig();
|
||||
const config = getUserFacingBrowserConfig();
|
||||
const status = await getBrowserStatus();
|
||||
res.json({
|
||||
config: toBrowserRouteConfig(config),
|
||||
@@ -56,29 +62,60 @@ router.get('/status', async (_req: Request, res: Response): Promise<void> => {
|
||||
});
|
||||
|
||||
router.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
if (
|
||||
req.body === null ||
|
||||
req.body === undefined ||
|
||||
typeof req.body !== 'object' ||
|
||||
Array.isArray(req.body)
|
||||
) {
|
||||
if (!isPlainObject(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))) {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const rootKeys = Object.keys(body);
|
||||
const unknownRootKeys = rootKeys.filter((key) => key !== 'claude' && key !== 'codex');
|
||||
if (unknownRootKeys.length > 0) {
|
||||
res.status(400).json({
|
||||
error: `Unknown browser config field(s): ${unknownRootKeys.join(', ')}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { claude, codex } = body as BrowserRouteBody;
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'claude') && !isPlainObject(claude)) {
|
||||
res.status(400).json({ error: 'Invalid value for claude. Must be an object.' });
|
||||
return;
|
||||
}
|
||||
if (codex && (typeof codex !== 'object' || Array.isArray(codex))) {
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'codex') && !isPlainObject(codex)) {
|
||||
res.status(400).json({ error: 'Invalid value for codex. Must be an object.' });
|
||||
return;
|
||||
}
|
||||
const unknownClaudeKeys = Object.keys(claude ?? {}).filter(
|
||||
(key) =>
|
||||
key !== 'enabled' && key !== 'policy' && key !== 'userDataDir' && key !== 'devtoolsPort'
|
||||
);
|
||||
if (unknownClaudeKeys.length > 0) {
|
||||
res.status(400).json({
|
||||
error: `Unknown claude browser field(s): ${unknownClaudeKeys.join(', ')}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const unknownCodexKeys = Object.keys(codex ?? {}).filter(
|
||||
(key) => key !== 'enabled' && key !== 'policy'
|
||||
);
|
||||
if (unknownCodexKeys.length > 0) {
|
||||
res.status(400).json({
|
||||
error: `Unknown codex browser field(s): ${unknownCodexKeys.join(', ')}.`,
|
||||
});
|
||||
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?.policy !== undefined &&
|
||||
(typeof claude.policy !== 'string' || !isValidBrowserPolicy(claude.policy))
|
||||
) {
|
||||
res.status(400).json({ error: 'Invalid value for claude.policy. Must be auto or manual.' });
|
||||
return;
|
||||
}
|
||||
if (claude?.userDataDir !== undefined && typeof claude.userDataDir !== 'string') {
|
||||
res.status(400).json({ error: 'Invalid value for claude.userDataDir. Must be a string.' });
|
||||
return;
|
||||
@@ -92,43 +129,38 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (claude?.evalMode !== undefined && !isValidEvalMode(claude.evalMode)) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' });
|
||||
return;
|
||||
}
|
||||
if (codex?.evalMode !== undefined && !isValidEvalMode(codex.evalMode)) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.',
|
||||
});
|
||||
if (
|
||||
codex?.policy !== undefined &&
|
||||
(typeof codex.policy !== 'string' || !isValidBrowserPolicy(codex.policy))
|
||||
) {
|
||||
res.status(400).json({ error: 'Invalid value for codex.policy. Must be auto or manual.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const current = getBrowserConfig();
|
||||
const current = getUserFacingBrowserConfig();
|
||||
const nextClaudeUserDataDir =
|
||||
claude?.userDataDir === undefined ? current.claude.user_data_dir : claude.userDataDir.trim();
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: claude?.enabled ?? current.claude.enabled,
|
||||
policy: claude?.policy ?? current.claude.policy,
|
||||
user_data_dir: nextClaudeUserDataDir,
|
||||
devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port,
|
||||
eval_mode: claude?.evalMode ?? current.claude.eval_mode,
|
||||
},
|
||||
codex: {
|
||||
enabled: codex?.enabled ?? current.codex.enabled,
|
||||
eval_mode: codex?.evalMode ?? current.codex.eval_mode,
|
||||
policy: codex?.policy ?? current.codex.policy,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const config = getBrowserConfig();
|
||||
const config = getUserFacingBrowserConfig();
|
||||
const status = await getBrowserStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -142,17 +174,17 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
}
|
||||
});
|
||||
|
||||
function toBrowserRouteConfig(config: ReturnType<typeof getBrowserConfig>) {
|
||||
function toBrowserRouteConfig(config: BrowserConfig) {
|
||||
return {
|
||||
claude: {
|
||||
enabled: config.claude.enabled,
|
||||
policy: config.claude.policy,
|
||||
userDataDir: config.claude.user_data_dir,
|
||||
devtoolsPort: config.claude.devtools_port,
|
||||
evalMode: config.claude.eval_mode,
|
||||
},
|
||||
codex: {
|
||||
enabled: config.codex.enabled,
|
||||
evalMode: config.codex.eval_mode,
|
||||
policy: config.codex.policy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ function runCli(args: string[], extraEnv: Record<string, string> = {}) {
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.CCS_E2E_SKIP_BUILD === '1') {
|
||||
expect(fs.existsSync(DIST_ENTRY)).toBe(true);
|
||||
return;
|
||||
}
|
||||
const result = spawnSync(process.execPath, ['run', 'build'], {
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
@@ -45,6 +49,41 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('proxy command e2e', () => {
|
||||
it('shows help for subcommand help flags without executing the subcommand', () => {
|
||||
const help = runCli(['proxy', 'stop', '--help']);
|
||||
expect(help.status).toBe(0);
|
||||
expect(help.stdout).toContain('Usage: ccs proxy <start|stop|status|activate> [profile] [options]');
|
||||
expect(help.stdout).toContain('stop [profile] Stop the running proxy (or all proxies when omitted)');
|
||||
});
|
||||
|
||||
it('shows the last-known proxy state when no proxy is currently running', async () => {
|
||||
const stalePort = await getPort();
|
||||
const proxyDir = path.join(tempDir, '.ccs', 'proxy');
|
||||
fs.mkdirSync(proxyDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(proxyDir, 'stale.session.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profileName: 'stale',
|
||||
settingsPath: path.join(tempDir, '.ccs', 'stale.settings.json'),
|
||||
host: '127.0.0.1',
|
||||
port: stalePort,
|
||||
baseUrl: 'http://127.0.0.1:11434',
|
||||
authToken: 'deadbeef',
|
||||
model: 'qwen3-coder',
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const status = runCli(['proxy', 'status']);
|
||||
expect(status.status).toBe(0);
|
||||
expect(status.stdout).toContain(`Proxy is not running (last known port ${stalePort})`);
|
||||
expect(status.stdout).toContain('Profile: stale');
|
||||
});
|
||||
|
||||
it('starts, reports status, activates, and stops via the built CLI', async () => {
|
||||
const port = await getPort();
|
||||
const ccsDir = path.join(tempDir, '.ccs');
|
||||
@@ -107,4 +146,50 @@ describe('proxy command e2e', () => {
|
||||
const stopped = runCli(['proxy', 'stop']);
|
||||
expect(stopped.status).toBe(0);
|
||||
}, 35000);
|
||||
|
||||
it('requires an explicit profile when activating with multiple running proxies', async () => {
|
||||
const firstPort = await getPort();
|
||||
const ccsDir = path.join(tempDir, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const firstSettingsPath = path.join(ccsDir, 'ccg.settings.json');
|
||||
const secondSettingsPath = path.join(ccsDir, 'ccgm.settings.json');
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify({ profiles: { ccg: firstSettingsPath, ccgm: secondSettingsPath } }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
firstSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-ccg',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
secondSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-ccgm',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(runCli(['proxy', 'start', 'ccg', '--port', String(firstPort)]).status).toBe(0);
|
||||
const secondPort = await getPort();
|
||||
expect(runCli(['proxy', 'start', 'ccgm', '--port', String(secondPort)]).status).toBe(0);
|
||||
|
||||
const activate = runCli(['proxy', 'activate', '--shell', 'bash']);
|
||||
expect(activate.status).toBe(1);
|
||||
expect(activate.stderr).toContain(
|
||||
'Multiple proxies are running. Specify a profile: ccs proxy activate <profile>'
|
||||
);
|
||||
}, 35000);
|
||||
});
|
||||
@@ -5,10 +5,18 @@ import * as path from 'path';
|
||||
import getPort from 'get-port';
|
||||
import {
|
||||
getOpenAICompatProxyStatus,
|
||||
listOpenAICompatProxyStatuses,
|
||||
startOpenAICompatProxy,
|
||||
stopOpenAICompatProxy,
|
||||
} from '../../../src/proxy/proxy-daemon';
|
||||
import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
|
||||
import {
|
||||
getLegacyOpenAICompatProxyPidPath,
|
||||
getLegacyOpenAICompatProxySessionPath,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from '../../../src/proxy/proxy-daemon-paths';
|
||||
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
@@ -75,12 +83,22 @@ describe('openai proxy daemon lifecycle', () => {
|
||||
).json()) as { data?: Array<{ id: string }> };
|
||||
expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']);
|
||||
|
||||
const headRoot = await fetch(`http://127.0.0.1:${port}/`, { method: 'HEAD' });
|
||||
expect(headRoot.status).toBe(200);
|
||||
expect(headRoot.headers.get('content-type')).toBe('application/json');
|
||||
expect(await headRoot.text()).toBe('');
|
||||
|
||||
const headHealth = await fetch(`http://127.0.0.1:${port}/health`, { method: 'HEAD' });
|
||||
expect(headHealth.status).toBe(200);
|
||||
expect(headHealth.headers.get('content-type')).toBe('application/json');
|
||||
expect(await headHealth.text()).toBe('');
|
||||
|
||||
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 () => {
|
||||
it('allows different profiles to run on different ports', async () => {
|
||||
const firstPort = await getPort();
|
||||
const firstSettingsPath = path.join(tempDir, 'hf.settings.json');
|
||||
fs.writeFileSync(
|
||||
@@ -108,7 +126,20 @@ describe('openai proxy daemon lifecycle', () => {
|
||||
const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
|
||||
const secondProfile = resolveOpenAICompatProfileConfig('openai', path.join(tempDir, 'openai.settings.json'), {
|
||||
const secondPort = await getPort();
|
||||
const secondSettingsPath = path.join(tempDir, 'openai.settings.json');
|
||||
fs.writeFileSync(
|
||||
secondSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-openai',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const secondProfile = resolveOpenAICompatProfileConfig('openai', secondSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-openai',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
@@ -117,11 +148,514 @@ describe('openai proxy daemon lifecycle', () => {
|
||||
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 secondStart = await startOpenAICompatProxy(secondProfile, { port: secondPort });
|
||||
expect(secondStart.success).toBe(true);
|
||||
expect(secondStart.port).toBe(secondPort);
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${firstPort}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
|
||||
const secondHealth = await fetch(`http://127.0.0.1:${secondPort}/health`);
|
||||
expect(secondHealth.status).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps a legacy singleton daemon visible across upgrade', async () => {
|
||||
const port = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'legacy.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-legacy',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('legacy', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-legacy',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected a legacy OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile, { port });
|
||||
expect(started.success).toBe(true);
|
||||
expect(started.pid).toBeDefined();
|
||||
|
||||
const proxyDir = path.dirname(getLegacyOpenAICompatProxyPidPath());
|
||||
fs.writeFileSync(getLegacyOpenAICompatProxyPidPath(), String(started.pid), 'utf8');
|
||||
fs.writeFileSync(
|
||||
getLegacyOpenAICompatProxySessionPath(),
|
||||
JSON.stringify(
|
||||
{
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken: started.authToken,
|
||||
model: profile.model,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.rmSync(path.join(proxyDir, 'legacy.daemon.pid'), { force: true });
|
||||
fs.rmSync(path.join(proxyDir, 'legacy.session.json'), { force: true });
|
||||
|
||||
const status = await getOpenAICompatProxyStatus('legacy');
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.port).toBe(port);
|
||||
|
||||
const restarted = await startOpenAICompatProxy(profile);
|
||||
expect(restarted.success).toBe(true);
|
||||
expect(restarted.alreadyRunning).toBe(true);
|
||||
expect(restarted.port).toBe(port);
|
||||
|
||||
const stopped = await stopOpenAICompatProxy('legacy');
|
||||
expect(stopped.success).toBe(true);
|
||||
expect(fs.existsSync(getLegacyOpenAICompatProxyPidPath())).toBe(false);
|
||||
expect(fs.existsSync(getLegacyOpenAICompatProxySessionPath())).toBe(false);
|
||||
}, 35000);
|
||||
|
||||
it('fails when an explicit port is already occupied', async () => {
|
||||
const occupiedPort = await getPort();
|
||||
const server = Bun.serve({
|
||||
port: occupiedPort,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: () => new Response('busy'),
|
||||
});
|
||||
|
||||
try {
|
||||
const settingsPath = path.join(tempDir, 'occupied-explicit.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-explicit',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('explicit', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-explicit',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected an explicit-port OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile, { port: occupiedPort });
|
||||
expect(started.success).toBe(false);
|
||||
expect(started.port).toBe(occupiedPort);
|
||||
expect(started.error).toContain(`Requested proxy port ${occupiedPort} is already in use`);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails when a configured profile port is already occupied', async () => {
|
||||
const occupiedPort = await getPort();
|
||||
const server = Bun.serve({
|
||||
port: occupiedPort,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: () => new Response('busy'),
|
||||
});
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.proxy = {
|
||||
...(config.proxy ?? {}),
|
||||
profile_ports: { mapped: occupiedPort },
|
||||
};
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tempDir, 'occupied-mapped.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-mapped',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('mapped', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-mapped',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected a mapped-port OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile);
|
||||
expect(started.success).toBe(false);
|
||||
expect(started.port).toBe(occupiedPort);
|
||||
expect(started.error).toContain(`Requested proxy port ${occupiedPort} is already in use`);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the existing proxy running if replacement startup fails', async () => {
|
||||
const firstPort = await getPort();
|
||||
const occupiedPort = await getPort();
|
||||
const busyServer = Bun.serve({
|
||||
port: occupiedPort,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: () => new Response('busy'),
|
||||
});
|
||||
|
||||
try {
|
||||
const settingsPath = path.join(tempDir, 'rollback.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-rollback',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('rollback', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-rollback',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected a rollback OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const firstStart = await startOpenAICompatProxy(profile, { port: firstPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
|
||||
const restarted = await startOpenAICompatProxy(profile, { port: occupiedPort });
|
||||
expect(restarted.success).toBe(false);
|
||||
|
||||
const status = await getOpenAICompatProxyStatus('rollback');
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.port).toBe(firstPort);
|
||||
expect((await fetch(`http://127.0.0.1:${firstPort}/health`)).status).toBe(200);
|
||||
} finally {
|
||||
busyServer.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the last-known port even when it is outside the default fallback range', async () => {
|
||||
const preferredPort = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'outside-range.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-outside-range',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('outside-range', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-outside-range',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected an outside-range OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath('outside-range')), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxySessionPath('outside-range'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host: '127.0.0.1',
|
||||
port: preferredPort,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken: 'stale-token',
|
||||
model: profile.model,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const started = await startOpenAICompatProxy(profile);
|
||||
expect(started.success).toBe(true);
|
||||
expect(started.port).toBe(preferredPort);
|
||||
});
|
||||
|
||||
it('stops legacy daemons even when the legacy session is missing a profile name', async () => {
|
||||
const port = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'legacy-missing-profile.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-legacy-missing-profile',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('legacy-missing-profile', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-legacy-missing-profile',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected a legacy fallback OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile, { port });
|
||||
expect(started.success).toBe(true);
|
||||
expect(started.pid).toBeDefined();
|
||||
|
||||
const proxyDir = path.dirname(getLegacyOpenAICompatProxyPidPath());
|
||||
fs.writeFileSync(getLegacyOpenAICompatProxyPidPath(), String(started.pid), 'utf8');
|
||||
fs.writeFileSync(
|
||||
getLegacyOpenAICompatProxySessionPath(),
|
||||
JSON.stringify(
|
||||
{
|
||||
settingsPath: profile.settingsPath,
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken: started.authToken,
|
||||
model: profile.model,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.rmSync(path.join(proxyDir, 'legacy-missing-profile.daemon.pid'), { force: true });
|
||||
fs.rmSync(path.join(proxyDir, 'legacy-missing-profile.session.json'), { force: true });
|
||||
|
||||
const statuses = await listOpenAICompatProxyStatuses();
|
||||
expect(statuses.some((status) => status.port === port)).toBe(true);
|
||||
|
||||
const stopped = await stopOpenAICompatProxy();
|
||||
expect(stopped.success).toBe(true);
|
||||
expect(fs.existsSync(getLegacyOpenAICompatProxyPidPath())).toBe(false);
|
||||
expect(fs.existsSync(getLegacyOpenAICompatProxySessionPath())).toBe(false);
|
||||
}, 35000);
|
||||
|
||||
it('keeps the requested profile name in stopped status results', async () => {
|
||||
const status = await getOpenAICompatProxyStatus('never-started');
|
||||
expect(status.running).toBe(false);
|
||||
expect(status.profileName).toBe('never-started');
|
||||
});
|
||||
|
||||
it('does not treat another profile on the same port as already running', async () => {
|
||||
const sharedPort = await getPort();
|
||||
const firstSettingsPath = path.join(tempDir, 'profile-b.settings.json');
|
||||
fs.writeFileSync(
|
||||
firstSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const firstProfile = resolveOpenAICompatProfileConfig('profile-b', firstSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!firstProfile) {
|
||||
throw new Error('Expected first OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const firstStart = await startOpenAICompatProxy(firstProfile, { port: sharedPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
|
||||
const secondSettingsPath = path.join(tempDir, 'profile-a.settings.json');
|
||||
fs.writeFileSync(
|
||||
secondSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const secondProfile = resolveOpenAICompatProfileConfig('profile-a', secondSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!secondProfile) {
|
||||
throw new Error('Expected second OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxySessionPath('profile-a'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profileName: 'profile-a',
|
||||
settingsPath: secondProfile.settingsPath,
|
||||
host: '127.0.0.1',
|
||||
port: sharedPort,
|
||||
baseUrl: secondProfile.baseUrl,
|
||||
authToken: 'stale-token-a',
|
||||
model: secondProfile.model,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const secondStart = await startOpenAICompatProxy(secondProfile);
|
||||
expect(secondStart.success).toBe(true);
|
||||
expect(secondStart.alreadyRunning).not.toBe(true);
|
||||
expect(secondStart.authToken).not.toBe('stale-token-a');
|
||||
});
|
||||
|
||||
it('replaces pid-only proxy state before starting a new daemon', async () => {
|
||||
const firstPort = await getPort();
|
||||
const replacementPort = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'pid-only.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-pid-only',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('pid-only', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-pid-only',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected pid-only OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const firstStart = await startOpenAICompatProxy(profile, { port: firstPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
expect(firstStart.pid).toBeDefined();
|
||||
|
||||
fs.unlinkSync(getOpenAICompatProxySessionPath('pid-only'));
|
||||
|
||||
const replacement = await startOpenAICompatProxy(profile, { port: replacementPort });
|
||||
expect(replacement.success).toBe(true);
|
||||
expect(replacement.port).toBe(replacementPort);
|
||||
expect(replacement.pid).toBeDefined();
|
||||
expect(replacement.pid).not.toBe(firstStart.pid);
|
||||
|
||||
const stalePidPath = getOpenAICompatProxyPidPath('pid-only');
|
||||
expect(fs.readFileSync(stalePidPath, 'utf8').trim()).toBe(String(replacement.pid));
|
||||
expect((await fetch(`http://127.0.0.1:${replacementPort}/health`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('does not stop another profile when a stale pid file points at its daemon', async () => {
|
||||
const firstPort = await getPort();
|
||||
const secondPort = await getPort();
|
||||
const firstSettingsPath = path.join(tempDir, 'profile-b-stale-pid.settings.json');
|
||||
fs.writeFileSync(
|
||||
firstSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b-stale-pid',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const firstProfile = resolveOpenAICompatProfileConfig('profile-b-stale-pid', firstSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b-stale-pid',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!firstProfile) {
|
||||
throw new Error('Expected first OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
expect(firstStart.pid).toBeDefined();
|
||||
|
||||
const secondSettingsPath = path.join(tempDir, 'profile-a-stale-pid.settings.json');
|
||||
fs.writeFileSync(
|
||||
secondSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a-stale-pid',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const secondProfile = resolveOpenAICompatProfileConfig('profile-a-stale-pid', secondSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a-stale-pid',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!secondProfile) {
|
||||
throw new Error('Expected second OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxyPidPath('profile-a-stale-pid'),
|
||||
String(firstStart.pid),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const secondStart = await startOpenAICompatProxy(secondProfile, { port: secondPort });
|
||||
expect(secondStart.success).toBe(true);
|
||||
expect(secondStart.port).toBe(secondPort);
|
||||
expect((await fetch(`http://127.0.0.1:${firstPort}/health`)).status).toBe(200);
|
||||
expect((await fetch(`http://127.0.0.1:${secondPort}/health`)).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -224,58 +224,61 @@ describe('openai proxy message edge cases', () => {
|
||||
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(() => {});
|
||||
it.skipIf(typeof Bun !== 'undefined')(
|
||||
'aborts the upstream request when the client disconnects mid-flight (Node.js only)',
|
||||
async () => {
|
||||
await startProxyWithHandler(() => {});
|
||||
|
||||
await new Promise<void>((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',
|
||||
await new Promise<void>((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()
|
||||
);
|
||||
() => resolve()
|
||||
);
|
||||
|
||||
request.write(
|
||||
JSON.stringify({
|
||||
model: 'hf-model',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
})
|
||||
);
|
||||
request.end();
|
||||
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<void>((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'));
|
||||
}
|
||||
setTimeout(() => {
|
||||
request.socket?.destroy();
|
||||
resolve();
|
||||
}, 50);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const logPath = path.join(tempDir, '.ccs', 'logs', 'current.jsonl');
|
||||
await Promise.race([
|
||||
new Promise<void>((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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -23,10 +23,34 @@ function startMockUpstream(): Promise<void> {
|
||||
body += chunk.toString();
|
||||
}
|
||||
upstreamBody = JSON.parse(body);
|
||||
const parsed = upstreamBody as { stream?: boolean };
|
||||
const parsed = upstreamBody as {
|
||||
stream?: boolean;
|
||||
messages?: Array<{ role?: string; content?: string | Array<{ type?: string; text?: string }> }>;
|
||||
};
|
||||
|
||||
if (parsed.stream) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
|
||||
if (parsed.messages?.[0]?.content === 'interleaved tool fragments') {
|
||||
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"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"open"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]}}]}\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.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n'
|
||||
);
|
||||
@@ -116,13 +140,63 @@ describe('openai proxy messages endpoint', () => {
|
||||
|
||||
const parsedUpstream = upstreamBody as {
|
||||
messages?: Array<{ role: string; content: string }>;
|
||||
tool_choice?: unknown;
|
||||
tools?: Array<{ type: string; function: { name: string } }>;
|
||||
};
|
||||
expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' });
|
||||
expect(parsedUpstream.tool_choice).toBe('auto');
|
||||
expect(parsedUpstream.tools?.[0]?.type).toBe('function');
|
||||
expect(parsedUpstream.tools?.[0]?.function.name).toBe('search');
|
||||
});
|
||||
|
||||
it('preserves tool schemas and forwards explicit tool_choice semantics upstream', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Search docs' }] }],
|
||||
tools: [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search docs',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', pattern: '^[a-z]+$' },
|
||||
},
|
||||
required: ['q'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: {
|
||||
type: 'tool',
|
||||
name: 'search',
|
||||
disable_parallel_tool_use: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const parsedUpstream = upstreamBody as {
|
||||
tool_choice?: unknown;
|
||||
parallel_tool_calls?: boolean;
|
||||
tools?: Array<{ type: string; function: { parameters: Record<string, unknown> } }>;
|
||||
};
|
||||
|
||||
expect(parsedUpstream.tool_choice).toEqual({
|
||||
type: 'function',
|
||||
function: { name: 'search' },
|
||||
});
|
||||
expect(parsedUpstream.parallel_tool_calls).toBe(false);
|
||||
expect(parsedUpstream.tools?.[0]?.function.parameters).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', pattern: '^[a-z]+$' },
|
||||
},
|
||||
required: ['q'],
|
||||
additionalProperties: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to Anthropic JSON for non-streaming requests', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
@@ -155,6 +229,24 @@ describe('openai proxy messages endpoint', () => {
|
||||
expect(body.error?.message).toContain('Invalid JSON');
|
||||
});
|
||||
|
||||
it('returns invalid_request_error for orphan tool_result blocks', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_orphan', content: 'orphan' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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('tool_result requires a preceding assistant tool_use');
|
||||
});
|
||||
|
||||
it('rejects requests without the local proxy auth token', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, {
|
||||
method: 'POST',
|
||||
@@ -229,4 +321,208 @@ describe('openai proxy messages endpoint', () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('responds 200 to HEAD / (health probe from Claude Code)', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/`, { method: 'HEAD' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('application/json');
|
||||
expect(await response.text()).toBe('');
|
||||
});
|
||||
|
||||
it('responds 200 to HEAD /health', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/health`, { method: 'HEAD' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('application/json');
|
||||
expect(await response.text()).toBe('');
|
||||
});
|
||||
|
||||
it('still responds with body for GET /', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as { ok: boolean; service: string; endpoints: string[] };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.endpoints).toContain('/v1/messages');
|
||||
});
|
||||
|
||||
it('translates user messages with tool_result followed by text', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'user', content: 'search docs' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me search.' },
|
||||
{ type: 'tool_use', id: 'toolu_01', name: 'search', input: { q: 'docs' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'found 3 docs' },
|
||||
{ type: 'text', text: 'What should I do next?' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const parsedUpstream = upstreamBody as {
|
||||
messages?: Array<{ role: string; content: string; tool_call_id?: string }>;
|
||||
};
|
||||
const roles = parsedUpstream.messages?.map((m) => m.role);
|
||||
expect(roles).toEqual(['user', 'assistant', 'tool', 'user']);
|
||||
const toolMsg = parsedUpstream.messages?.find((m) => m.role === 'tool');
|
||||
expect(toolMsg?.tool_call_id).toBe('toolu_01');
|
||||
expect(toolMsg?.content).toBe('found 3 docs');
|
||||
const userAfterTool = parsedUpstream.messages?.filter((m) => m.role === 'user');
|
||||
expect(userAfterTool?.[1]?.content).toBe('What should I do next?');
|
||||
});
|
||||
|
||||
it('rejects text before tool_result blocks when tool results are pending', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'user', content: 'search docs' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'toolu_01', name: 'search', input: { q: 'docs' } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'found 3 docs' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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(
|
||||
'text is not allowed before tool_result blocks for pending tool_use ids'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects follow-up text between pending tool_result blocks', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'user', content: 'read both files' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'toolu_01', name: 'Read', input: { file_path: 'a.ts' } },
|
||||
{ type: 'tool_use', id: 'toolu_02', name: 'Read', input: { file_path: 'b.ts' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'content of a' },
|
||||
{ type: 'text', text: 'Now compare them' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_02', content: 'content of b' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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(
|
||||
'text is not allowed between tool_result blocks for pending tool_use ids'
|
||||
);
|
||||
});
|
||||
|
||||
it('translates parallel tool calls with streaming', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'user', content: 'read both files' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'toolu_01', name: 'Read', input: { file_path: 'a.ts' } },
|
||||
{ type: 'tool_use', id: 'toolu_02', name: 'Read', input: { file_path: 'b.ts' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'content of a' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_02', content: 'content of b' },
|
||||
{ type: 'text', text: 'Now compare them' },
|
||||
],
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'Read',
|
||||
description: 'Read a file',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { file_path: { type: 'string' } },
|
||||
required: ['file_path'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const parsedUpstream = upstreamBody as {
|
||||
messages?: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }>;
|
||||
}>;
|
||||
};
|
||||
const assistantMsg = parsedUpstream.messages?.find((m) => m.role === 'assistant');
|
||||
expect(assistantMsg?.tool_calls?.length).toBe(2);
|
||||
expect(assistantMsg?.tool_calls?.[0]?.function.name).toBe('Read');
|
||||
expect(assistantMsg?.tool_calls?.[1]?.function.name).toBe('Read');
|
||||
const toolMsgs = parsedUpstream.messages?.filter((m) => m.role === 'tool');
|
||||
expect(toolMsgs?.length).toBe(2);
|
||||
expect(toolMsgs?.[0]?.tool_call_id).toBe('toolu_01');
|
||||
expect(toolMsgs?.[1]?.tool_call_id).toBe('toolu_02');
|
||||
});
|
||||
|
||||
it('streams interleaved tool call fragments without premature block stops', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'interleaved tool fragments' }] }],
|
||||
tools: [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search docs',
|
||||
input_schema: { type: 'object', properties: { path: { type: 'string' } } },
|
||||
},
|
||||
{
|
||||
name: 'open',
|
||||
description: 'Open a file',
|
||||
input_schema: { type: 'object', properties: { path: { type: 'string' } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.match(/event: content_block_start/g)?.length).toBe(2);
|
||||
expect(body.match(/event: content_block_stop/g)?.length).toBe(2);
|
||||
|
||||
const stopIndex = body.indexOf('event: content_block_stop');
|
||||
const deltaAIndex = body.indexOf('"partial_json":"{\\"path\\":\\"a.ts\\"}"');
|
||||
const deltaBIndex = body.indexOf('"partial_json":"{\\"path\\":\\"b.ts\\"}"');
|
||||
expect(deltaAIndex).toBeGreaterThan(-1);
|
||||
expect(deltaBIndex).toBeGreaterThan(-1);
|
||||
expect(stopIndex).toBeGreaterThan(deltaBIndex);
|
||||
});
|
||||
});
|
||||
@@ -214,4 +214,75 @@ describe('openai proxy request routing', () => {
|
||||
expect(hits).toEqual(['thinker']);
|
||||
expect(bodies[0]?.body).toMatchObject({ model: 'deepseek-reasoner' });
|
||||
});
|
||||
|
||||
it('routes adaptive 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: 'adaptive' },
|
||||
output_config: { effort: 'max' },
|
||||
messages: [{ role: 'user', content: 'think adaptively' }],
|
||||
});
|
||||
|
||||
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',
|
||||
reasoning_effort: 'high',
|
||||
reasoning: { enabled: true, effort: 'high' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,24 @@ describe('Config Generator Port', function () {
|
||||
// Port should still be 8325
|
||||
assert.ok(content.includes(`port: ${variantPort}`));
|
||||
});
|
||||
|
||||
it('honors an explicit config path override', function () {
|
||||
const variantPort = 8318;
|
||||
const overrideDir = path.join(testHome, '.ccs-scope-override', 'cliproxy');
|
||||
const overrideConfigPath = path.join(overrideDir, `config-${variantPort}.yaml`);
|
||||
const overrideAuthDir = path.join(overrideDir, 'auth');
|
||||
|
||||
regenerateConfig(variantPort, {
|
||||
configPath: overrideConfigPath,
|
||||
authDir: overrideAuthDir,
|
||||
});
|
||||
|
||||
assert.ok(fs.existsSync(overrideConfigPath), 'Should create config at the override path');
|
||||
assert.ok(fs.existsSync(overrideAuthDir), 'Should create auth dir at the override path');
|
||||
|
||||
const defaultConfigPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
assert.strictEqual(fs.existsSync(defaultConfigPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConfigForPort', function () {
|
||||
|
||||
@@ -445,6 +445,38 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
|
||||
assert(newConfig.includes('port: 9999'), 'Should preserve custom port');
|
||||
});
|
||||
|
||||
it('preserves openai-compatibility connectors during regeneration', () => {
|
||||
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
|
||||
const initialConfig = `# CLIProxyAPI config generated by CCS v17
|
||||
port: 8317
|
||||
|
||||
api-keys:
|
||||
- "ccs-internal-managed"
|
||||
|
||||
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
|
||||
|
||||
openai-compatibility:
|
||||
- name: mimo
|
||||
base-url: https://api.xiaomimimo.com/v1
|
||||
api-key-entries:
|
||||
- api-key: sk-test
|
||||
models:
|
||||
- name: mimo-v2-flash
|
||||
alias: mimo-v2-flash
|
||||
`;
|
||||
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
|
||||
|
||||
regenerateConfig();
|
||||
|
||||
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||
assert(newConfig.includes('openai-compatibility:'), 'Should preserve openai-compatibility');
|
||||
assert(newConfig.includes('name: mimo'), 'Should preserve connector name');
|
||||
assert(newConfig.includes('base-url: https://api.xiaomimimo.com/v1'), 'Should preserve base URL');
|
||||
assert(newConfig.includes('alias: mimo-v2-flash'), 'Should preserve model aliases');
|
||||
});
|
||||
|
||||
it('creates fresh config when none exists', () => {
|
||||
// Ensure clean state
|
||||
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
execClaudeWithCLIProxy,
|
||||
hasGitLabTokenLoginFlag,
|
||||
readOptionValue,
|
||||
} from '../../../src/cliproxy/executor/index';
|
||||
@@ -40,3 +44,44 @@ describe('readOptionValue', () => {
|
||||
expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execClaudeWithCLIProxy browser flag validation', () => {
|
||||
let tmpHome = '';
|
||||
let fakeClaudePath = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-executor-'));
|
||||
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
|
||||
fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fs.chmodSync(fakeClaudePath, 0o755);
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('exits cleanly when conflicting browser launch flags are provided', async () => {
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await execClaudeWithCLIProxy(fakeClaudePath, 'gemini', ['--browser', '--no-browser'], {});
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith('[X] Use either `--browser` or `--no-browser`, not both.');
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,17 @@ describe('Model Catalog', () => {
|
||||
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.6');
|
||||
});
|
||||
|
||||
it('includes Claude Opus 4.7 with provider-specific settings', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const opus47 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-4-7');
|
||||
assert(opus47, 'Should include Claude Opus 4.7');
|
||||
assert.strictEqual(opus47.name, 'Claude Opus 4.7');
|
||||
// Claude provider differs from AGY: zero thinking budget is disallowed,
|
||||
// and extended (1M) context is available on the Anthropic API.
|
||||
assert.strictEqual(opus47.thinking.zeroAllowed, false);
|
||||
assert.strictEqual(opus47.extendedContext, true);
|
||||
});
|
||||
|
||||
it('retains previous 4.5 snapshot models for explicit selection', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const ids = MODEL_CATALOG.claude.models.map((m) => m.id);
|
||||
@@ -266,6 +277,13 @@ describe('Model Catalog', () => {
|
||||
});
|
||||
|
||||
describe('findModel', () => {
|
||||
it('finds Claude Opus 4.7 in claude', () => {
|
||||
const { findModel } = modelCatalog;
|
||||
const model = findModel('claude', 'claude-opus-4-7');
|
||||
assert(model, 'Should find model');
|
||||
assert.strictEqual(model.name, 'Claude Opus 4.7');
|
||||
});
|
||||
|
||||
it('finds Claude Opus 4.6 Thinking in agy', () => {
|
||||
const { findModel } = modelCatalog;
|
||||
const model = findModel('agy', 'claude-opus-4-6-thinking');
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
describe('openai-compat manager', () => {
|
||||
let testDir;
|
||||
let originalCcsHome;
|
||||
let originalCcsDir;
|
||||
let configGenerator;
|
||||
let openAICompatManager;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-compat-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_HOME = testDir;
|
||||
process.env.CCS_DIR = path.join(testDir, '.ccs');
|
||||
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/openai-compat-manager')];
|
||||
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||
|
||||
configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||
openAICompatManager = require('../../../dist/cliproxy/openai-compat-manager');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
if (testDir && fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the generated header and connector entries across regeneration', () => {
|
||||
configGenerator.regenerateConfig();
|
||||
const configPath = configGenerator.getCliproxyConfigPath();
|
||||
const initialHeader = fs.readFileSync(configPath, 'utf8').split('\n')[0];
|
||||
|
||||
openAICompatManager.addOpenAICompatProvider({
|
||||
name: 'mimo',
|
||||
baseUrl: 'https://api.xiaomimimo.com/v1',
|
||||
apiKey: 'sk-test',
|
||||
models: [{ name: 'mimo-v2-flash', alias: 'mimo-v2-flash' }],
|
||||
});
|
||||
|
||||
const afterWrite = fs.readFileSync(configPath, 'utf8');
|
||||
assert.strictEqual(afterWrite.split('\n')[0], initialHeader, 'Should preserve the generated header');
|
||||
assert(afterWrite.includes('openai-compatibility:'), 'Should write the openai-compatibility section');
|
||||
assert.strictEqual(
|
||||
configGenerator.configNeedsRegeneration(),
|
||||
false,
|
||||
'Legacy openai-compat writes should not force regeneration'
|
||||
);
|
||||
|
||||
configGenerator.regenerateConfig();
|
||||
|
||||
const afterRegen = fs.readFileSync(configPath, 'utf8');
|
||||
assert(afterRegen.includes('openai-compatibility:'), 'Connector section should survive regeneration');
|
||||
assert(afterRegen.includes('name: mimo'), 'Connector name should survive regeneration');
|
||||
assert(
|
||||
afterRegen.includes('base-url: https://api.xiaomimimo.com/v1'),
|
||||
'Connector base URL should survive regeneration'
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the openai-compatibility section cleanly when the last legacy connector is deleted', () => {
|
||||
configGenerator.regenerateConfig();
|
||||
const configPath = configGenerator.getCliproxyConfigPath();
|
||||
const initialHeader = fs.readFileSync(configPath, 'utf8').split('\n')[0];
|
||||
|
||||
openAICompatManager.addOpenAICompatProvider({
|
||||
name: 'mimo',
|
||||
baseUrl: 'https://api.xiaomimimo.com/v1',
|
||||
apiKey: 'sk-test',
|
||||
models: [{ name: 'mimo-v2-flash', alias: 'mimo-v2-flash' }],
|
||||
});
|
||||
|
||||
const removed = openAICompatManager.removeOpenAICompatProvider('mimo');
|
||||
assert.strictEqual(removed, true, 'Expected the legacy connector to be removed');
|
||||
|
||||
const afterRemove = fs.readFileSync(configPath, 'utf8');
|
||||
assert.strictEqual(
|
||||
afterRemove.split('\n')[0],
|
||||
initialHeader,
|
||||
'Should preserve the generated header after removing the last connector'
|
||||
);
|
||||
assert(
|
||||
!afterRemove.includes('openai-compatibility:'),
|
||||
'Should remove the openai-compatibility section when the last connector is deleted'
|
||||
);
|
||||
assert(!afterRemove.includes('name: mimo'), 'Should remove the deleted connector payload');
|
||||
assert.strictEqual(
|
||||
configGenerator.configNeedsRegeneration(),
|
||||
false,
|
||||
'Removing the last legacy connector should not force regeneration'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import * as path from 'path';
|
||||
describe('cliproxy routing strategy service', () => {
|
||||
let tempHome = '';
|
||||
let scopedConfigDir = '';
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let runWithScopedConfigDir: <T>(ccsDir: string, fn: () => Promise<T> | T) => Promise<T>;
|
||||
let routingTarget = {
|
||||
host: '127.0.0.1',
|
||||
@@ -18,6 +20,17 @@ describe('cliproxy routing strategy service', () => {
|
||||
beforeEach(async () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-strategy-'));
|
||||
scopedConfigDir = path.join(tempHome, '.ccs');
|
||||
routingTarget = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
};
|
||||
responseFactory = null;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_DIR = scopedConfigDir;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
({ runWithScopedConfigDir } = await import('../../../src/utils/config-manager'));
|
||||
});
|
||||
@@ -25,6 +38,18 @@ describe('cliproxy routing strategy service', () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -91,10 +116,9 @@ describe('cliproxy routing strategy service', () => {
|
||||
expect(result.applied).toBe('config-only');
|
||||
expect(result.strategy).toBe('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');
|
||||
const { loadUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
const persisted = loadUnifiedConfig();
|
||||
expect(persisted?.cliproxy?.routing?.strategy).toBe('fill-first');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, describe, expect, test, spyOn } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, test, spyOn } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import * as browserUtils from '../../../src/utils/browser';
|
||||
import { handleBrowserCommand } from '../../../src/commands/browser-command';
|
||||
import { getBrowserConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
@@ -20,7 +24,22 @@ function currentPlatform(): 'darwin' | 'linux' | 'win32' {
|
||||
}
|
||||
|
||||
describe('browser command', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-command-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
@@ -28,6 +47,7 @@ describe('browser command', () => {
|
||||
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
source: 'config',
|
||||
overrideActive: false,
|
||||
state: 'ready',
|
||||
@@ -37,7 +57,6 @@ describe('browser command', () => {
|
||||
effectiveUserDataDir: '/tmp/browser-profile',
|
||||
recommendedUserDataDir: '/tmp/browser-profile',
|
||||
devtoolsPort: 9222,
|
||||
evalMode: 'readonly',
|
||||
managedMcpServerName: 'ccs-browser',
|
||||
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
|
||||
launchCommands: {
|
||||
@@ -51,17 +70,16 @@ describe('browser command', () => {
|
||||
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',
|
||||
CCS_BROWSER_EVAL_MODE: 'readonly',
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
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',
|
||||
evalMode: 'readonly',
|
||||
supportsConfigOverrides: true,
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
version: 'codex-cli 0.120.0',
|
||||
@@ -76,9 +94,15 @@ describe('browser command', () => {
|
||||
expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
rendered.includes(
|
||||
'New installs, plus upgrades without saved browser settings, keep both lanes off by default'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(rendered.includes('Managed MCP: ccs-browser')).toBe(true);
|
||||
expect(rendered.includes('Managed server: ccs_browser')).toBe(true);
|
||||
expect(rendered.includes('browser_eval access: readonly')).toBe(true);
|
||||
expect(rendered.includes('Policy: manual')).toBe(true);
|
||||
expect(rendered.includes('Default launch behavior: hidden until `--browser`')).toBe(true);
|
||||
expect(rendered.includes('DevTools endpoint: http://127.0.0.1:9222')).toBe(true);
|
||||
} finally {
|
||||
statusSpy.mockRestore();
|
||||
@@ -95,6 +119,7 @@ describe('browser command', () => {
|
||||
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
source: 'CCS_BROWSER_PROFILE_DIR',
|
||||
overrideActive: true,
|
||||
state: 'browser_not_running',
|
||||
@@ -104,19 +129,18 @@ describe('browser command', () => {
|
||||
effectiveUserDataDir: '/tmp/browser-profile',
|
||||
recommendedUserDataDir: '/tmp/browser-profile',
|
||||
devtoolsPort: 9444,
|
||||
evalMode: 'readwrite',
|
||||
managedMcpServerName: 'ccs-browser',
|
||||
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
|
||||
launchCommands,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
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',
|
||||
evalMode: 'disabled',
|
||||
supportsConfigOverrides: false,
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
version: 'codex-cli 0.70.0',
|
||||
@@ -127,6 +151,7 @@ describe('browser command', () => {
|
||||
const rendered = await renderLines(['doctor']);
|
||||
|
||||
expect(rendered.includes('Result: action required')).toBe(true);
|
||||
expect(rendered.includes('Default launch behavior: hidden until `--browser`')).toBe(true);
|
||||
expect(rendered.includes('Source: CCS_BROWSER_PROFILE_DIR (env override active)')).toBe(
|
||||
true
|
||||
);
|
||||
@@ -146,6 +171,7 @@ describe('browser command', () => {
|
||||
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'auto',
|
||||
source: 'config',
|
||||
overrideActive: false,
|
||||
state: 'disabled',
|
||||
@@ -156,7 +182,6 @@ describe('browser command', () => {
|
||||
effectiveUserDataDir: '/tmp/browser-profile',
|
||||
recommendedUserDataDir: '/tmp/browser-profile',
|
||||
devtoolsPort: 9222,
|
||||
evalMode: 'readonly',
|
||||
managedMcpServerName: 'ccs-browser',
|
||||
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
|
||||
launchCommands: {
|
||||
@@ -169,12 +194,12 @@ describe('browser command', () => {
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
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',
|
||||
evalMode: 'readonly',
|
||||
supportsConfigOverrides: false,
|
||||
binaryPath: null,
|
||||
},
|
||||
@@ -190,4 +215,129 @@ describe('browser command', () => {
|
||||
statusSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('setup runs the browser setup flow and prints remediation results', async () => {
|
||||
const setupSpy = spyOn(browserUtils, 'runBrowserSetup').mockResolvedValue({
|
||||
configUpdated: true,
|
||||
createdUserDataDir: true,
|
||||
mcpReady: true,
|
||||
overrideActive: false,
|
||||
ready: true,
|
||||
launchCommand: 'open -na "Google Chrome" --args --remote-debugging-port=9222',
|
||||
status: {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
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,
|
||||
policy: 'auto',
|
||||
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',
|
||||
},
|
||||
},
|
||||
notes: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const rendered = await renderLines(['setup']);
|
||||
|
||||
expect(rendered.includes('ccs browser setup')).toBe(true);
|
||||
expect(rendered.includes('Result: ready')).toBe(true);
|
||||
expect(rendered.includes('Config updated: yes')).toBe(true);
|
||||
expect(process.exitCode).toBe(0);
|
||||
} finally {
|
||||
setupSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('doctor rejects --fix and points users to setup', async () => {
|
||||
const rendered = await renderLines(['doctor', '--fix']);
|
||||
|
||||
expect(rendered.includes('`ccs browser doctor` is read-only.')).toBe(true);
|
||||
expect(rendered.includes('Run `ccs browser setup` for the browser remediation flow.')).toBe(
|
||||
true
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test('policy shows and updates the saved browser exposure mode', async () => {
|
||||
const rendered = await renderLines(['policy', '--all', 'manual']);
|
||||
|
||||
expect(rendered.includes('ccs browser policy')).toBe(true);
|
||||
expect(
|
||||
rendered.includes(
|
||||
'New installs and upgrades without saved browser settings: both lanes start disabled and manual.'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(rendered.includes('Default launch behavior: hidden until `--browser`')).toBe(true);
|
||||
expect(getBrowserConfig().claude.policy).toBe('manual');
|
||||
expect(getBrowserConfig().codex.policy).toBe('manual');
|
||||
});
|
||||
|
||||
test('policy shows safe default-off browser settings on a fresh install', async () => {
|
||||
const rendered = await renderLines(['policy']);
|
||||
|
||||
expect(rendered.includes('ccs browser policy')).toBe(true);
|
||||
expect(
|
||||
rendered.includes(
|
||||
'New installs and upgrades without saved browser settings: both lanes start disabled and manual.'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(rendered.includes('Claude Browser Attach')).toBe(true);
|
||||
expect(rendered.includes('Codex Browser Tools')).toBe(true);
|
||||
expect(rendered.includes('Enabled: no')).toBe(true);
|
||||
expect(rendered.includes('Policy: manual')).toBe(true);
|
||||
expect(rendered.includes('Default launch behavior: hidden until `--browser`')).toBe(true);
|
||||
});
|
||||
|
||||
test('enable updates a single browser lane', async () => {
|
||||
const rendered = await renderLines(['enable', 'codex']);
|
||||
expect(rendered.includes('Updated codex browser lane.')).toBe(true);
|
||||
expect(getBrowserConfig().codex.enabled).toBe(true);
|
||||
expect(getBrowserConfig().codex.policy).toBe('manual');
|
||||
});
|
||||
|
||||
test('literal browser help still renders the help page', async () => {
|
||||
const rendered = await renderLines(['help']);
|
||||
|
||||
expect(rendered.includes('CCS Browser Help')).toBe(true);
|
||||
expect(rendered.includes('ccs browser setup')).toBe(true);
|
||||
expect(rendered.includes('ccs browser policy')).toBe(true);
|
||||
expect(rendered.includes('--browser')).toBe(true);
|
||||
expect(
|
||||
rendered.includes(
|
||||
'New installs and upgrades without saved browser settings keep both lanes off by default'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -140,6 +140,28 @@ describe('completion backend', () => {
|
||||
expect(suggestionValues(['cliproxy'])).toEqual(expect.arrayContaining(['remove', '--backend']));
|
||||
});
|
||||
|
||||
test('suggests browser subcommands and fix/setup flags', () => {
|
||||
expect(suggestionValues(['browser'])).toEqual(
|
||||
expect.arrayContaining(['setup', 'status', 'doctor', 'policy', 'enable', 'disable'])
|
||||
);
|
||||
expect(suggestionValues(['browser', 'setup'])).toEqual(expect.arrayContaining(['--help', '-h']));
|
||||
expect(suggestionValues(['browser', 'doctor'])).toEqual(
|
||||
expect.arrayContaining(['--help', '-h'])
|
||||
);
|
||||
expect(suggestionValues(['browser', 'enable'])).toEqual(
|
||||
expect.arrayContaining(['claude', 'codex', 'all'])
|
||||
);
|
||||
expect(suggestionValues(['browser', 'policy'])).toEqual(
|
||||
expect.arrayContaining(['--all', '--claude', '--codex'])
|
||||
);
|
||||
expect(suggestionValues(['browser', 'policy', '--all'])).toEqual(
|
||||
expect.arrayContaining(['auto', 'manual'])
|
||||
);
|
||||
expect(suggestionValues(['codex'])).toEqual(
|
||||
expect.arrayContaining(['--browser', '--no-browser'])
|
||||
);
|
||||
});
|
||||
|
||||
test('treats cursor as a provider shortcut in completion', () => {
|
||||
const values = suggestionValues(['cursor']);
|
||||
expect(values).toEqual(
|
||||
|
||||
@@ -78,8 +78,11 @@ describe('help command parity', () => {
|
||||
expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe(
|
||||
true
|
||||
);
|
||||
expect(rendered.includes('ccs browser setup')).toBe(true);
|
||||
expect(rendered.includes('ccs browser status')).toBe(true);
|
||||
expect(rendered.includes('ccs browser doctor')).toBe(true);
|
||||
expect(rendered.includes('ccs browser policy')).toBe(true);
|
||||
expect(rendered.includes('--browser')).toBe(true);
|
||||
});
|
||||
|
||||
test('completion topic documents install and verification paths', async () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { findPositionalArg } from '../../../src/commands/proxy-command';
|
||||
|
||||
describe('findPositionalArg', () => {
|
||||
it('skips option values before returning the first positional argument', () => {
|
||||
expect(findPositionalArg(['--port', '3456', 'ccg'], ['--port'])).toBe('ccg');
|
||||
});
|
||||
|
||||
it('skips flag options that do not take values', () => {
|
||||
expect(findPositionalArg(['--insecure', 'ccg'], ['--port', '--host'], ['--insecure'])).toBe(
|
||||
'ccg'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats arguments after -- as positional', () => {
|
||||
expect(findPositionalArg(['--', '--port', '3456'], ['--port'])).toBe('--port');
|
||||
});
|
||||
|
||||
it('returns undefined when -- is the final argument', () => {
|
||||
expect(findPositionalArg(['--'], ['--port'])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -177,6 +177,39 @@ describe('migration-manager legacy kimi compatibility', () => {
|
||||
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies safe browser defaults when migrating legacy config files', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profiles: {
|
||||
glm: '~/.ccs/glm.settings.json',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
fs.writeFileSync(path.join(ccsDir, 'glm.settings.json'), JSON.stringify({ env: {} }));
|
||||
|
||||
const result = await migrate(false);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const unified = loadUnifiedConfig();
|
||||
expect(unified?.browser).toEqual({
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes valid legacy shared groups and drops invalid ones during migration', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
|
||||
@@ -26,6 +26,13 @@ describe('DEFAULT_CURSOR_MODELS', () => {
|
||||
expect(providers.has('google')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps both Claude 4.6 Opus and Claude 4.7 Opus in the fallback catalog', () => {
|
||||
const ids = DEFAULT_CURSOR_MODELS.map((model) => model.id);
|
||||
expect(ids).toContain('claude-4.6-opus');
|
||||
expect(ids).toContain('claude-4.7-opus');
|
||||
expect(ids).toContain('claude-4.7-opus-fast-mode');
|
||||
});
|
||||
|
||||
it('has exactly one default model', () => {
|
||||
const defaults = DEFAULT_CURSOR_MODELS.filter((m) => m.isDefault);
|
||||
expect(defaults).toHaveLength(1);
|
||||
@@ -88,6 +95,7 @@ describe('detectProvider', () => {
|
||||
it('detects anthropic models', () => {
|
||||
expect(detectProvider('claude-4.5-sonnet')).toBe('anthropic');
|
||||
expect(detectProvider('claude-4.6-opus')).toBe('anthropic');
|
||||
expect(detectProvider('claude-4.7-opus')).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('detects openai models', () => {
|
||||
|
||||
@@ -465,6 +465,7 @@ global.fetch = async (url) => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_WEBSEARCH_ENABLED: '1',
|
||||
CCS_WEBSEARCH_SKIP: '0',
|
||||
CCS_WEBSEARCH_BRAVE: '0',
|
||||
|
||||
@@ -148,6 +148,18 @@ describe('model-pricing', () => {
|
||||
expect(opus46.inputPerMillion).toBe(5.0);
|
||||
expect(opus46.outputPerMillion).toBe(25.0);
|
||||
});
|
||||
|
||||
it('should return correct pricing for Claude Opus 4.7', () => {
|
||||
const opus47 = getModelPricing('claude-opus-4-7');
|
||||
expect(opus47.inputPerMillion).toBe(5.0);
|
||||
expect(opus47.outputPerMillion).toBe(25.0);
|
||||
});
|
||||
|
||||
it('should match date-stamped Claude Opus 4.7 to correct pricing', () => {
|
||||
const opus47dated = getModelPricing('claude-opus-4-7-20260401');
|
||||
expect(opus47dated.inputPerMillion).toBe(5.0);
|
||||
expect(opus47dated.outputPerMillion).toBe(25.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateCost', () => {
|
||||
@@ -218,6 +230,28 @@ describe('model-pricing', () => {
|
||||
const cost = calculateCost(usage, 'claude-opus-4-6');
|
||||
expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5
|
||||
});
|
||||
|
||||
it('should calculate Claude Opus 4.7 cost including cache token rates', () => {
|
||||
const usage: TokenUsage = {
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
cacheCreationTokens: 1_000_000,
|
||||
cacheReadTokens: 1_000_000,
|
||||
};
|
||||
const cost = calculateCost(usage, 'claude-opus-4-7');
|
||||
expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5
|
||||
});
|
||||
|
||||
it('should calculate Claude Opus 4.7 cache cost consistently across repeat lookups', () => {
|
||||
const usage: TokenUsage = {
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
cacheCreationTokens: 1_000_000,
|
||||
cacheReadTokens: 1_000_000,
|
||||
};
|
||||
const cost = calculateCost(usage, 'claude-opus-4-7');
|
||||
expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKnownModels', () => {
|
||||
|
||||
@@ -31,17 +31,15 @@ describe('attachDisconnectAbortHandlers', () => {
|
||||
);
|
||||
|
||||
expect(req.listenerCount('aborted')).toBe(1);
|
||||
expect(req.listenerCount('close')).toBe(1);
|
||||
expect(req.listenerCount('close')).toBe(0);
|
||||
expect(req.socket.listenerCount('close')).toBe(1);
|
||||
expect(res.listenerCount('close')).toBe(1);
|
||||
expect(res.listenerCount('close')).toBe(0);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -60,9 +58,9 @@ describe('attachDisconnectAbortHandlers', () => {
|
||||
}
|
||||
);
|
||||
|
||||
req.emit('close');
|
||||
req.emit('aborted');
|
||||
req.socket.emit('close');
|
||||
res.emit('close');
|
||||
res.socket.emit('close');
|
||||
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(disconnectCount).toBe(1);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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 {
|
||||
getLegacyOpenAICompatProxySessionPath,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from '../../../src/proxy/proxy-daemon-paths';
|
||||
import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-state-'));
|
||||
process.env.CCS_HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('listOpenAICompatProxyProfileNames', () => {
|
||||
it('ignores the legacy singleton session file', () => {
|
||||
fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true });
|
||||
fs.writeFileSync(getLegacyOpenAICompatProxySessionPath(), '{}\n', 'utf8');
|
||||
fs.writeFileSync(getOpenAICompatProxySessionPath('ccg'), '{}\n', 'utf8');
|
||||
|
||||
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
||||
});
|
||||
|
||||
it('skips malformed percent-encoded profile keys', () => {
|
||||
fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true });
|
||||
fs.writeFileSync(path.join(path.dirname(getLegacyOpenAICompatProxySessionPath()), '%E0%A4%.session.json'), '{}\n', 'utf8');
|
||||
fs.writeFileSync(getOpenAICompatProxySessionPath('ccg'), '{}\n', 'utf8');
|
||||
|
||||
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
||||
});
|
||||
|
||||
it('includes pid-only profile state in the discovered profile list', () => {
|
||||
fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true });
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath('ccg'), '123\n', 'utf8');
|
||||
|
||||
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
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 { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
import { resolveOpenAICompatProxyPreferredPort } from '../../../src/proxy/proxy-port-resolver';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-config-'));
|
||||
process.env.CCS_HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveOpenAICompatProxyPreferredPort', () => {
|
||||
it('returns the configured profile-scoped port when present', () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.proxy = {
|
||||
...(config.proxy ?? {}),
|
||||
port: 3456,
|
||||
profile_ports: { ccgm: 3461 },
|
||||
};
|
||||
});
|
||||
|
||||
expect(resolveOpenAICompatProxyPreferredPort('ccgm')).toBe(3461);
|
||||
});
|
||||
|
||||
it('falls back to the shared default port when no profile mapping exists', () => {
|
||||
expect(resolveOpenAICompatProxyPreferredPort('ccg')).toBe(3456);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { ProxyRequestTransformer } from '../../../../src/proxy/transformers/request-transformer';
|
||||
|
||||
describe('ProxyRequestTransformer regressions', () => {
|
||||
it('drops assistant messages that only contain stripped thinking blocks', () => {
|
||||
const result = new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', text: 'internal' },
|
||||
{ type: 'redacted_thinking', text: 'hidden' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps adaptive thinking through output_config effort for OpenAI-compatible upstreams', () => {
|
||||
const result = new ProxyRequestTransformer().transform({
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'max' },
|
||||
});
|
||||
|
||||
expect(result.reasoning_effort).toBe('high');
|
||||
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
|
||||
});
|
||||
|
||||
it('rejects unsupported thinking types instead of silently dropping them', () => {
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
thinking: { type: 'typo' },
|
||||
})
|
||||
).toThrow('thinking.type must be "enabled", "adaptive", or "disabled"');
|
||||
});
|
||||
|
||||
it('keeps Anthropic role validation for tool_use, image, and tool_result blocks', () => {
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [{ role: 'user', content: [{ type: 'tool_use', name: 'search', input: {} }] }],
|
||||
})
|
||||
).toThrow('tool_use requires assistant role');
|
||||
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'url', url: 'https://example.com/image.png' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('image requires user role');
|
||||
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'nope' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('tool_result requires user role');
|
||||
});
|
||||
|
||||
it('rejects orphaned, incomplete, or mixed-order tool_result blocks', () => {
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'orphan' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('tool_result requires a preceding assistant tool_use');
|
||||
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'docs' } },
|
||||
{ type: 'tool_use', id: 'toolu_2', name: 'open', input: { url: 'https://example.com' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'partial' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('must provide tool_result blocks for all pending tool_use ids');
|
||||
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Here you go' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'result' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('text is not allowed before tool_result blocks for pending tool_use ids');
|
||||
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'result' },
|
||||
{ type: 'text', text: 'follow-up' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
).not.toThrow();
|
||||
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'docs' } },
|
||||
{ type: 'tool_use', id: 'toolu_2', name: 'open', input: { url: 'https://example.com' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'partial' },
|
||||
{ type: 'text', text: 'follow-up' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_2', content: 'done' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('text is not allowed between tool_result blocks for pending tool_use ids');
|
||||
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'plain follow-up',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('must start with tool_result blocks for pending tool_use ids');
|
||||
});
|
||||
|
||||
it('rejects tool_result content that cannot be represented as OpenAI tool text', () => {
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_1',
|
||||
content: [{ type: 'image', source: { type: 'url', url: 'https://example.com/error.png' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('type "image" is not supported in tool_result content');
|
||||
});
|
||||
|
||||
it('rejects unsupported assistant blocks instead of silently dropping them', () => {
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'server_tool_use', id: 'srv_1' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('type "server_tool_use" is not supported');
|
||||
});
|
||||
|
||||
it('translates url images and tool_choice while coalescing repeated turns', () => {
|
||||
const result = new ProxyRequestTransformer().transform({
|
||||
tool_choice: {
|
||||
type: 'tool',
|
||||
name: 'vision',
|
||||
disable_parallel_tool_use: true,
|
||||
},
|
||||
tools: [{ name: 'vision', description: 'Inspect image', input_schema: { type: 'object' } }],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'image', source: { type: 'url', url: 'https://example.com/cat.png' } }],
|
||||
},
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Describe it' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Checking' }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_1',
|
||||
is_error: true,
|
||||
content: [{ type: 'text', text: 'fetch failed' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.tool_choice).toEqual({
|
||||
type: 'function',
|
||||
function: { name: 'vision' },
|
||||
});
|
||||
expect(result.parallel_tool_calls).toBe(false);
|
||||
|
||||
expect(result.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/cat.png' } },
|
||||
{ type: 'text', text: 'Describe it' },
|
||||
],
|
||||
});
|
||||
expect(result.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'Checking',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'toolu_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'vision',
|
||||
arguments: '{"detail":"high"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.messages[2]).toEqual({
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_1',
|
||||
content: 'Error: fetch failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults tools to auto tool_choice when none is specified', () => {
|
||||
const result = new ProxyRequestTransformer().transform({
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }],
|
||||
});
|
||||
|
||||
expect(result.tool_choice).toBe('auto');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
function resolvePath(relativePath: string) {
|
||||
return path.resolve(import.meta.dir, relativePath);
|
||||
}
|
||||
|
||||
describe('dev release workflow', () => {
|
||||
test('uses PAT_TOKEN for protected dev branch release pushes', () => {
|
||||
const workflowPath = resolvePath('../../../../.github/workflows/dev-release.yml');
|
||||
|
||||
expect(fs.existsSync(workflowPath)).toBe(true);
|
||||
|
||||
const workflow = fs.readFileSync(workflowPath, 'utf8');
|
||||
const checkoutSection = workflow.slice(
|
||||
workflow.indexOf('- name: Checkout'),
|
||||
workflow.indexOf('- name: Setup Bun')
|
||||
);
|
||||
const releaseSection = workflow.slice(
|
||||
workflow.indexOf('- name: Release'),
|
||||
workflow.indexOf('- name: Notify Discord')
|
||||
);
|
||||
|
||||
expect(workflow).toContain('name: Dev Release');
|
||||
expect(workflow).toContain('branches: [dev]');
|
||||
expect(checkoutSection).toContain("token: ${{ secrets.PAT_TOKEN }}");
|
||||
expect(checkoutSection).not.toContain('token: ${{ github.token }}');
|
||||
expect(releaseSection).toContain('GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}');
|
||||
expect(releaseSection).toContain('GH_TOKEN: ${{ secrets.PAT_TOKEN }}');
|
||||
expect(releaseSection).not.toContain('GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}');
|
||||
expect(releaseSection).not.toContain('GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as childProcess from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { CodexAdapter } from '../../../src/targets/codex-adapter';
|
||||
import { buildCodexBrowserMcpOverrides } from '../../../src/utils/browser-codex-overrides';
|
||||
import * as signalForwarder from '../../../src/utils/signal-forwarder';
|
||||
|
||||
function createMockChild(): EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
exitCode: number | null;
|
||||
killed: boolean;
|
||||
pid: number;
|
||||
unref: () => EventEmitter;
|
||||
kill: () => boolean;
|
||||
} {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
exitCode: number | null;
|
||||
killed: boolean;
|
||||
pid: number;
|
||||
unref: () => EventEmitter;
|
||||
kill: () => boolean;
|
||||
};
|
||||
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.exitCode = null;
|
||||
child.killed = false;
|
||||
child.pid = process.pid;
|
||||
child.unref = () => child;
|
||||
child.kill = () => {
|
||||
child.killed = true;
|
||||
child.exitCode = 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
describe('codex-adapter exec', () => {
|
||||
const originalPlatform = process.platform;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-adapter-exec-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('launches Windows cmd wrappers via cmd.exe when runtime overrides include browser MCP args', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
const fakeCodex = path.join(tmpDir, 'codex.cmd');
|
||||
fs.writeFileSync(fakeCodex, '');
|
||||
|
||||
const spawnSpy = spyOn(childProcess, 'spawn').mockImplementation(
|
||||
() => createMockChild() as unknown as ReturnType<typeof childProcess.spawn>
|
||||
);
|
||||
const signalSpy = spyOn(signalForwarder, 'wireChildProcessSignals').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
|
||||
try {
|
||||
const adapter = new CodexAdapter();
|
||||
const binaryInfo = {
|
||||
path: fakeCodex,
|
||||
needsShell: true,
|
||||
features: ['config-overrides'],
|
||||
};
|
||||
const args = adapter.buildArgs('default', ['--version'], {
|
||||
profileType: 'default',
|
||||
creds: {
|
||||
profile: 'default',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
runtimeConfigOverrides: buildCodexBrowserMcpOverrides(),
|
||||
},
|
||||
binaryInfo,
|
||||
});
|
||||
|
||||
adapter.exec(args, {}, { binaryInfo });
|
||||
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
||||
const [command, options] = spawnSpy.mock.calls[0] as [
|
||||
string,
|
||||
Record<string, unknown> | undefined,
|
||||
];
|
||||
expect(options?.shell).toBe('cmd.exe');
|
||||
expect(command).toContain(fakeCodex);
|
||||
expect(command).toContain('mcp_servers.ccs_browser.args=');
|
||||
expect(command).toContain('@playwright/mcp@0.0.70');
|
||||
} finally {
|
||||
spawnSpy.mockRestore();
|
||||
signalSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -58,19 +58,35 @@ describe('codex-detector', () => {
|
||||
process.env.CCS_CODEX_PATH = fakeCodex;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
|
||||
return String(command).includes('cmd.exe') && Array.isArray(args) && args.join(' ').includes('--help')
|
||||
? 'Codex CLI\n -c, --config <key=value>\n'
|
||||
: 'codex-cli 0.118.0-alpha.3';
|
||||
const spawnSyncSpy = spyOn(childProcess, 'spawnSync').mockImplementation((command) => {
|
||||
const commandString = String(command);
|
||||
return {
|
||||
pid: 123,
|
||||
output: ['', '', ''],
|
||||
stdout: commandString.includes('--help')
|
||||
? 'Codex CLI\n -c, --config <key=value>\n'
|
||||
: 'codex-cli 0.118.0-alpha.3',
|
||||
stderr: '',
|
||||
status: 0,
|
||||
signal: null,
|
||||
} as unknown as ReturnType<typeof childProcess.spawnSync>;
|
||||
});
|
||||
|
||||
const info = getCodexBinaryInfo();
|
||||
const calls = spawnSyncSpy.mock.calls;
|
||||
const cmdWrapperProbeCall = calls.find(([command]) => {
|
||||
return String(command).includes(fakeCodex);
|
||||
});
|
||||
|
||||
expect(execFileSyncSpy).toHaveBeenCalled();
|
||||
expect(spawnSyncSpy).toHaveBeenCalled();
|
||||
expect(cmdWrapperProbeCall).toBeDefined();
|
||||
expect((cmdWrapperProbeCall?.[1] as Record<string, unknown> | undefined)?.shell).toBe(
|
||||
'cmd.exe'
|
||||
);
|
||||
expect(info?.needsShell).toBe(true);
|
||||
expect(info?.features).toContain('config-overrides');
|
||||
|
||||
execFileSyncSpy.mockRestore();
|
||||
spawnSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps the cmd wrapper when Windows PATH exposes codex.cmd and a sibling ps1 also exists', () => {
|
||||
|
||||
@@ -163,7 +163,7 @@ process.exit(0);
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('injects browser MCP runtime overrides for native Codex default launches', () => {
|
||||
it('keeps browser MCP runtime overrides off for untouched Codex launches', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
|
||||
@@ -174,24 +174,75 @@ process.exit(0);
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_THINKING: '8192',
|
||||
CCS_BROWSER_USER_DATA_DIR: '',
|
||||
CCS_BROWSER_PROFILE_DIR: '',
|
||||
CCS_BROWSER_DEVTOOLS_HOST: '',
|
||||
CCS_BROWSER_DEVTOOLS_PORT: '',
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: '',
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: '',
|
||||
CCS_BROWSER_EVAL_MODE: '',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
||||
expect(calls).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
[
|
||||
'-c',
|
||||
`mcp_servers.ccs_browser.command=${JSON.stringify('npx')}`,
|
||||
'-c',
|
||||
`mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.enabled=true',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.tool_timeout_sec=30',
|
||||
'fix failing tests',
|
||||
],
|
||||
]);
|
||||
expect(calls).toEqual([['fix failing tests']]);
|
||||
});
|
||||
|
||||
it('injects browser MCP runtime overrides when Codex browser policy is explicitly auto-enabled', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
CCS_THINKING: '8192',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
||||
expect(calls).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
[
|
||||
'-c',
|
||||
`mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`,
|
||||
'-c',
|
||||
`mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.enabled=true',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.tool_timeout_sec=30',
|
||||
'fix failing tests',
|
||||
],
|
||||
]);
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('skips Codex browser MCP overrides when browser tooling is disabled in config', () => {
|
||||
@@ -205,13 +256,13 @@ process.exit(0);
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'auto',
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
eval_mode: 'readonly',
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
eval_mode: 'readonly',
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -237,7 +288,115 @@ process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => {
|
||||
it('keeps Codex browser MCP overrides off by default when policy is manual', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'auto',
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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('forces Codex browser MCP overrides on for one launch when --browser is passed', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'auto',
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const result = runCcs(['default', '--target', 'codex', '--browser', '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[1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
'mcp_servers.ccs_browser.enabled=true',
|
||||
'fix failing tests',
|
||||
])
|
||||
);
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('suppresses Codex browser MCP overrides for one launch when --no-browser is passed', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['default', '--target', 'codex', '--no-browser', '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']]);
|
||||
});
|
||||
|
||||
it('keeps browser MCP runtime overrides off when CCS_THINKING is ignored for native Codex default mode', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
|
||||
@@ -252,20 +411,7 @@ process.exit(0);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
||||
expect(calls).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
[
|
||||
'-c',
|
||||
`mcp_servers.ccs_browser.command=${JSON.stringify('npx')}`,
|
||||
'-c',
|
||||
`mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.enabled=true',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.tool_timeout_sec=30',
|
||||
'fix failing tests',
|
||||
],
|
||||
]);
|
||||
expect(calls).toEqual([['fix failing tests']]);
|
||||
});
|
||||
|
||||
for (const versionFlag of ['--version', '-v']) {
|
||||
@@ -288,6 +434,24 @@ process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
it('strips browser launch flags before native codex passthrough diagnostics', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCodexAlias(['--version', '--browser'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('codex-cli 9.9.9-test');
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]);
|
||||
});
|
||||
|
||||
for (const helpFlag of ['--help', '-h']) {
|
||||
it(`passes ccsx ${helpFlag} straight through to the native Codex binary`, () => {
|
||||
if (process.platform === 'win32') return;
|
||||
@@ -362,14 +526,6 @@ process.exit(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
[
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.command="npx"',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.enabled=true',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.tool_timeout_sec=30',
|
||||
'-c',
|
||||
'model_reasoning_effort="high"',
|
||||
'fix failing tests',
|
||||
@@ -564,14 +720,6 @@ process.exit(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['-c', 'model="gpt-5"', '--version'],
|
||||
[
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.command="npx"',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.enabled=true',
|
||||
'-c',
|
||||
'mcp_servers.ccs_browser.tool_timeout_sec=30',
|
||||
'-c',
|
||||
'model_reasoning_effort="high"',
|
||||
'fix failing tests',
|
||||
|
||||
@@ -114,7 +114,7 @@ exit 0
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('launches Codex bridge settings profiles and injects runtime overrides', () => {
|
||||
it('launches Codex bridge settings profiles without browser overrides unless the lane is enabled', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['codex-api', '--target', 'codex', '--effort', 'high', 'smoke'], baseEnv);
|
||||
@@ -126,8 +126,8 @@ exit 0
|
||||
expect(argsLog).toContain('model_provider="ccs_runtime"');
|
||||
expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"');
|
||||
expect(argsLog).toContain('model_reasoning_effort="high"');
|
||||
expect(argsLog).toContain('mcp_servers.ccs_browser.command=');
|
||||
expect(argsLog).toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]');
|
||||
expect(argsLog).not.toContain('mcp_servers.ccs_browser.command=');
|
||||
expect(argsLog).not.toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]');
|
||||
expect(argsLog).toContain('smoke');
|
||||
expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token');
|
||||
});
|
||||
|
||||
@@ -81,6 +81,18 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||
};
|
||||
}
|
||||
|
||||
function reserveClosedPort(): number {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response('ok');
|
||||
},
|
||||
});
|
||||
const { port } = server;
|
||||
server.stop(true);
|
||||
return port;
|
||||
}
|
||||
|
||||
describe('default profile browser launch', () => {
|
||||
let tmpHome = '';
|
||||
let fakeClaudePath = '';
|
||||
@@ -111,7 +123,6 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
||||
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
|
||||
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
|
||||
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
|
||||
printf "evalMode=%s\n" "$CCS_BROWSER_EVAL_MODE"
|
||||
} > "${claudeEnvLogPath}"
|
||||
exit 0
|
||||
`,
|
||||
@@ -126,14 +137,14 @@ exit 0
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CLAUDE_PATH: fakeClaudePath,
|
||||
CCS_DEBUG: '1',
|
||||
CCS_BROWSER_USER_DATA_DIR: '',
|
||||
CCS_BROWSER_PROFILE_DIR: '',
|
||||
CCS_BROWSER_DEVTOOLS_HOST: '',
|
||||
CCS_BROWSER_DEVTOOLS_PORT: '',
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: '',
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: '',
|
||||
CCS_BROWSER_EVAL_MODE: '',
|
||||
};
|
||||
delete baseEnv.CCS_BROWSER_USER_DATA_DIR;
|
||||
delete baseEnv.CCS_BROWSER_PROFILE_DIR;
|
||||
delete baseEnv.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
delete baseEnv.CCS_BROWSER_DEVTOOLS_HOST;
|
||||
delete baseEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||
delete baseEnv.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||
delete baseEnv.CCS_BROWSER_EVAL_MODE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -157,7 +168,7 @@ exit 0
|
||||
fs.writeFileSync(delayedPortFile, '43123', 'utf8');
|
||||
}, 50);
|
||||
|
||||
expect(waitForMockDevtoolsPort(delayedPortFile, 500)).resolves.toBe('43123');
|
||||
await expect(waitForMockDevtoolsPort(delayedPortFile, 500)).resolves.toBe('43123');
|
||||
});
|
||||
|
||||
it('ignores stale default Chrome DevTools metadata unless browser reuse is explicitly configured', () => {
|
||||
@@ -194,7 +205,7 @@ exit 0
|
||||
expect(launchedEnv).not.toContain('devtools/browser/stale-default');
|
||||
});
|
||||
|
||||
it('passes browser runtime env through default Claude launches when reuse is configured', async () => {
|
||||
it('does not auto-enable browser runtime for default Claude launches from env overrides alone', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js');
|
||||
@@ -245,15 +256,106 @@ server.listen(0, '127.0.0.1', () => {
|
||||
expect(result.stderr).not.toContain('Chrome reuse metadata not found');
|
||||
expect(result.status).toBe(0);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).toContain('--append-system-prompt');
|
||||
expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET);
|
||||
expect(launchedArgs).not.toContain('--append-system-prompt');
|
||||
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
|
||||
expect(launchedEnv).toContain(`port=${port}`);
|
||||
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`);
|
||||
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target');
|
||||
expect(launchedEnv).toContain('evalMode=readonly');
|
||||
expect(launchedEnv).not.toContain(`userDataDir=${browserProfileDir}`);
|
||||
expect(launchedEnv).not.toContain(`port=${port}`);
|
||||
expect(launchedEnv).not.toContain(`httpUrl=http://127.0.0.1:${port}`);
|
||||
expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target');
|
||||
});
|
||||
|
||||
it('skips managed browser attach when the default CCS browser profile directory is missing', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
user_data_dir: '',
|
||||
devtools_port: 43123,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const result = runCcs(['default', 'smoke'], {
|
||||
...baseEnv,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('Claude Browser Attach is not ready yet.');
|
||||
expect(result.stderr).toContain('ccs browser setup');
|
||||
expect(result.stderr).toContain('Diagnose only: `ccs browser doctor`.');
|
||||
expect(result.stderr).toContain('continue without browser tools');
|
||||
expect(fs.existsSync(path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
|
||||
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
expect(launchedEnv).toContain('userDataDir=');
|
||||
expect(launchedEnv).not.toContain('.ccs/browser/chrome-user-data');
|
||||
expect(launchedEnv).not.toContain('ws://127.0.0.1/devtools/browser/');
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('skips managed browser attach when the managed profile exists but no browser session is running', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
const unreachablePort = reserveClosedPort();
|
||||
const managedProfileDir = path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data');
|
||||
fs.mkdirSync(managedProfileDir, { recursive: true });
|
||||
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
user_data_dir: '',
|
||||
devtools_port: unreachablePort,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const result = runCcs(['default', 'smoke'], {
|
||||
...baseEnv,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses config-backed browser attach settings when env overrides are absent', async () => {
|
||||
@@ -305,13 +407,13 @@ server.listen(0, '127.0.0.1', () => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
user_data_dir: browserProfileDir,
|
||||
devtools_port: Number.parseInt(port, 10),
|
||||
eval_mode: 'readwrite',
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
eval_mode: 'readonly',
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -328,7 +430,88 @@ server.listen(0, '127.0.0.1', () => {
|
||||
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
|
||||
expect(launchedEnv).toContain(`port=${port}`);
|
||||
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-target');
|
||||
expect(launchedEnv).toContain('evalMode=readwrite');
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Claude browser attach hidden under manual policy until --browser is passed', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server-manual-default.js');
|
||||
const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port-manual-default.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/manual-default-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/manual-default-target`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
user_data_dir: browserProfileDir,
|
||||
devtools_port: Number.parseInt(port, 10),
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenResult = runCcs(['default', 'smoke'], {
|
||||
...baseEnv,
|
||||
});
|
||||
expect(hiddenResult.status).toBe(0);
|
||||
expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||
expect(fs.readFileSync(claudeEnvLogPath, 'utf8')).not.toContain(browserProfileDir);
|
||||
|
||||
const forcedResult = runCcs(['default', '--browser', 'smoke'], {
|
||||
...baseEnv,
|
||||
});
|
||||
expect(forcedResult.status).toBe(0);
|
||||
expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain(BROWSER_PROMPT_SNIPPET);
|
||||
expect(fs.readFileSync(claudeEnvLogPath, 'utf8')).toContain(browserProfileDir);
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from 'bun:test';
|
||||
import { request as httpRequest } from 'http';
|
||||
import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
@@ -6,6 +7,7 @@ import * as path from 'path';
|
||||
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
|
||||
setDefaultTimeout(30000);
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
@@ -28,6 +30,52 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||
};
|
||||
}
|
||||
|
||||
function reserveClosedPort(): number {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response('ok');
|
||||
},
|
||||
});
|
||||
const { port } = server;
|
||||
server.stop(true);
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForDevtoolsVersionEndpoint(port: string, timeoutMs = 5000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: Number.parseInt(port, 10),
|
||||
path: '/json/version',
|
||||
method: 'GET',
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Unexpected status: ${res.statusCode ?? 'unknown'}`));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for mock DevTools endpoint to become ready');
|
||||
}
|
||||
|
||||
describe('settings profile browser launch', () => {
|
||||
let tmpHome = '';
|
||||
let ccsDir = '';
|
||||
@@ -87,7 +135,6 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
||||
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
|
||||
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
|
||||
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
|
||||
printf "evalMode=%s\n" "$CCS_BROWSER_EVAL_MODE"
|
||||
} > "${claudeEnvLogPath}"
|
||||
exit 0
|
||||
`,
|
||||
@@ -102,14 +149,14 @@ exit 0
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CLAUDE_PATH: fakeClaudePath,
|
||||
CCS_DEBUG: '1',
|
||||
CCS_BROWSER_USER_DATA_DIR: '',
|
||||
CCS_BROWSER_PROFILE_DIR: '',
|
||||
CCS_BROWSER_DEVTOOLS_HOST: '',
|
||||
CCS_BROWSER_DEVTOOLS_PORT: '',
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: '',
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: '',
|
||||
CCS_BROWSER_EVAL_MODE: '',
|
||||
};
|
||||
delete baseEnv.CCS_BROWSER_USER_DATA_DIR;
|
||||
delete baseEnv.CCS_BROWSER_PROFILE_DIR;
|
||||
delete baseEnv.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
delete baseEnv.CCS_BROWSER_DEVTOOLS_HOST;
|
||||
delete baseEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||
delete baseEnv.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||
delete baseEnv.CCS_BROWSER_EVAL_MODE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -124,7 +171,7 @@ exit 0
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fails before Claude launch when browser reuse cannot resolve DevToolsActivePort', () => {
|
||||
it('does not block settings-profile launches when browser reuse cannot resolve DevToolsActivePort and the lane stays default-off', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.mkdirSync(browserProfileDir, { recursive: true });
|
||||
@@ -134,12 +181,12 @@ exit 0
|
||||
CCS_BROWSER_PROFILE_DIR: browserProfileDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('DevToolsActivePort');
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(false);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toContain('DevToolsActivePort');
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('passes browser reuse env and steering prompt into the Claude launch', async () => {
|
||||
it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js');
|
||||
@@ -178,6 +225,7 @@ server.listen(0, '127.0.0.1', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const port = fs.readFileSync(mockServerPortPath, 'utf8').trim();
|
||||
await waitForDevtoolsVersionEndpoint(port);
|
||||
|
||||
fs.mkdirSync(browserProfileDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -196,15 +244,106 @@ server.listen(0, '127.0.0.1', () => {
|
||||
expect(result.stderr).not.toContain('Chrome reuse metadata not found');
|
||||
expect(result.status).toBe(0);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).toContain('--append-system-prompt');
|
||||
expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET);
|
||||
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
|
||||
expect(launchedEnv).toContain(`port=${port}`);
|
||||
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`);
|
||||
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target');
|
||||
expect(launchedEnv).toContain('evalMode=readonly');
|
||||
expect(launchedEnv).not.toContain(`userDataDir=${browserProfileDir}`);
|
||||
expect(launchedEnv).not.toContain(`port=${port}`);
|
||||
expect(launchedEnv).not.toContain(`httpUrl=http://127.0.0.1:${port}`);
|
||||
expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target');
|
||||
});
|
||||
|
||||
it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
user_data_dir: '',
|
||||
devtools_port: 43123,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], {
|
||||
...baseEnv,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('Claude Browser Attach is not ready yet.');
|
||||
expect(result.stderr).toContain('ccs browser setup');
|
||||
expect(result.stderr).toContain('Diagnose only: `ccs browser doctor`.');
|
||||
expect(result.stderr).toContain('continue without browser tools');
|
||||
expect(fs.existsSync(path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
|
||||
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
expect(launchedEnv).toContain('userDataDir=');
|
||||
expect(launchedEnv).not.toContain('.ccs/browser/chrome-user-data');
|
||||
expect(launchedEnv).not.toContain('ws://127.0.0.1/devtools/browser/');
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('skips managed browser attach for settings-profile launches when no managed browser session is running', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
const unreachablePort = reserveClosedPort();
|
||||
fs.mkdirSync(path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
user_data_dir: '',
|
||||
devtools_port: unreachablePort,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], {
|
||||
...baseEnv,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses config-backed browser attach settings for settings-profile launches', async () => {
|
||||
@@ -250,6 +389,7 @@ server.listen(0, '127.0.0.1', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const port = fs.readFileSync(mockServerPortPath, 'utf8').trim();
|
||||
await waitForDevtoolsVersionEndpoint(port);
|
||||
|
||||
fs.mkdirSync(browserProfileDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -262,13 +402,13 @@ server.listen(0, '127.0.0.1', () => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
user_data_dir: browserProfileDir,
|
||||
devtools_port: Number.parseInt(port, 10),
|
||||
eval_mode: 'disabled',
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
eval_mode: 'readonly',
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -285,7 +425,95 @@ server.listen(0, '127.0.0.1', () => {
|
||||
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
|
||||
expect(launchedEnv).toContain(`port=${port}`);
|
||||
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-settings-target');
|
||||
expect(launchedEnv).toContain('evalMode=disabled');
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps settings-profile Claude attach hidden under manual policy until --browser is passed', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server-manual-settings.js');
|
||||
const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port-manual-settings.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/manual-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();
|
||||
await waitForDevtoolsVersionEndpoint(port);
|
||||
|
||||
fs.mkdirSync(browserProfileDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(browserProfileDir, 'DevToolsActivePort'),
|
||||
`${port}\n/devtools/browser/manual-settings-target`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
user_data_dir: browserProfileDir,
|
||||
devtools_port: Number.parseInt(port, 10),
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hiddenResult = runCcs(['glm', 'smoke'], {
|
||||
...baseEnv,
|
||||
});
|
||||
expect(hiddenResult.status).toBe(0);
|
||||
expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||
expect(fs.readFileSync(claudeEnvLogPath, 'utf8')).not.toContain(browserProfileDir);
|
||||
|
||||
const forcedResult = runCcs(['glm', '--browser', 'smoke'], {
|
||||
...baseEnv,
|
||||
});
|
||||
expect(forcedResult.status).toBe(0);
|
||||
expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain(BROWSER_PROMPT_SNIPPET);
|
||||
expect(fs.readFileSync(claudeEnvLogPath, 'utf8')).toContain(browserProfileDir);
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
|
||||
@@ -158,16 +158,22 @@ describe('resolveTargetType', () => {
|
||||
|
||||
it('should detect internal entry target for dedicated package bin entrypoints', () => {
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid';
|
||||
process.argv = ['node', 'ccs'];
|
||||
process.argv = ['node', '/usr/local/lib/ccs/dist/bin/droid-runtime.js'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should detect internal entry target for codex runtime bins', () => {
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||
process.argv = ['node', 'ccs'];
|
||||
process.argv = ['node', '/usr/local/lib/ccs/dist/bin/codex-runtime.js'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should ignore leaked internal entry target env on the main ccs entrypoint', () => {
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||
process.argv = ['node', 'ccs.js'];
|
||||
expect(resolveTargetType([])).toBe('claude');
|
||||
});
|
||||
|
||||
it('should normalize argv[0] and custom aliases case-insensitively', () => {
|
||||
process.env.CCS_DROID_ALIASES = 'DroidCaps';
|
||||
process.argv = ['node', 'DROIDCAPS'];
|
||||
@@ -242,7 +248,7 @@ describe('resolveTargetType', () => {
|
||||
|
||||
it('should prioritize internal entry target over profile config', () => {
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid';
|
||||
process.argv = ['node', 'ccs'];
|
||||
process.argv = ['node', '/usr/local/lib/ccs/dist/bin/droid-runtime.js'];
|
||||
expect(resolveTargetType([], { target: 'claude' })).toBe('droid');
|
||||
});
|
||||
|
||||
|
||||
@@ -117,6 +117,22 @@ describe('unified-config-types', () => {
|
||||
expect(config.cliproxy.providers).toContain('gemini');
|
||||
expect(config.cliproxy.providers).toContain('codex');
|
||||
});
|
||||
|
||||
it('should default browser automation to disabled and manual for both lanes', () => {
|
||||
const config = createEmptyUnifiedConfig();
|
||||
expect(config.browser).toEqual({
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnifiedConfig', () => {
|
||||
@@ -337,3 +353,85 @@ describe('official-channels-config', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser-config', () => {
|
||||
it('fills in safe browser defaults when the browser section is missing on older configs', () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-defaults-home-'));
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
['version: 12', 'websearch:', ' enabled: false', ''].join('\n')
|
||||
);
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.browser).toMatchObject({
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves explicit browser enablement while defaulting missing policies to manual', () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-policy-home-'));
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'browser:',
|
||||
' claude:',
|
||||
' enabled: true',
|
||||
' user_data_dir: "/tmp/claude-browser"',
|
||||
' devtools_port: 9333',
|
||||
' codex:',
|
||||
' enabled: true',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.browser).toMatchObject({
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
user_data_dir: '/tmp/claude-browser',
|
||||
devtools_port: 9333,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
resolveBrowserExposure,
|
||||
resolveBrowserLaunchFlagResolution,
|
||||
} from '../../../../src/utils/browser/browser-policy';
|
||||
|
||||
describe('browser policy', () => {
|
||||
it('strips browser launch flags and records the override', () => {
|
||||
expect(resolveBrowserLaunchFlagResolution(['glm', '--browser', 'check app'])).toEqual({
|
||||
override: 'force-enable',
|
||||
argsWithoutFlags: ['glm', 'check app'],
|
||||
});
|
||||
expect(resolveBrowserLaunchFlagResolution(['glm', '--no-browser', 'check app'])).toEqual({
|
||||
override: 'force-disable',
|
||||
argsWithoutFlags: ['glm', 'check app'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects conflicting browser launch flags', () => {
|
||||
expect(() => resolveBrowserLaunchFlagResolution(['--browser', '--no-browser'])).toThrow(
|
||||
'Use either `--browser` or `--no-browser`, not both.'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops parsing browser launch flags after the option terminator', () => {
|
||||
expect(resolveBrowserLaunchFlagResolution(['glm', '--', '--browser', 'literal'])).toEqual({
|
||||
override: undefined,
|
||||
argsWithoutFlags: ['glm', '--', '--browser', 'literal'],
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves browser exposure from saved policy and one-run overrides', () => {
|
||||
expect(resolveBrowserExposure({ enabled: true, policy: 'auto' })).toMatchObject({
|
||||
exposeByDefault: true,
|
||||
exposeForLaunch: true,
|
||||
});
|
||||
expect(resolveBrowserExposure({ enabled: true, policy: 'manual' })).toMatchObject({
|
||||
exposeByDefault: false,
|
||||
exposeForLaunch: false,
|
||||
});
|
||||
expect(
|
||||
resolveBrowserExposure({ enabled: true, policy: 'manual' }, 'force-enable')
|
||||
).toMatchObject({
|
||||
exposeByDefault: false,
|
||||
exposeForLaunch: true,
|
||||
});
|
||||
expect(
|
||||
resolveBrowserExposure({ enabled: true, policy: 'auto' }, 'force-disable')
|
||||
).toMatchObject({
|
||||
exposeByDefault: true,
|
||||
exposeForLaunch: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { UnifiedConfig } from '../../../../src/config/unified-config-types';
|
||||
import { runBrowserSetup, type BrowserSetupDeps } from '../../../../src/utils/browser/browser-setup';
|
||||
|
||||
function createUnifiedConfig(userDataDir: string): UnifiedConfig {
|
||||
return {
|
||||
version: 12,
|
||||
default: undefined,
|
||||
profiles: {},
|
||||
profile_targets: {},
|
||||
copilot: {
|
||||
enabled: false,
|
||||
prompt: '',
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
auto_install: false,
|
||||
},
|
||||
cursor: {
|
||||
enabled: false,
|
||||
model: '',
|
||||
port: 3891,
|
||||
daemon_mode: false,
|
||||
auth: {
|
||||
token: '',
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
enabled: false,
|
||||
providers: {},
|
||||
},
|
||||
browser: {
|
||||
claude: {
|
||||
enabled: false,
|
||||
user_data_dir: userDataDir,
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
image_analysis: {
|
||||
enabled: false,
|
||||
providers: {},
|
||||
},
|
||||
global_env: {},
|
||||
cliproxy_server: {
|
||||
mode: 'local',
|
||||
remote: {
|
||||
enabled: false,
|
||||
host: '',
|
||||
port: 0,
|
||||
protocol: 'http',
|
||||
auth_token: '',
|
||||
management_key: '',
|
||||
},
|
||||
local: {
|
||||
port: 8085,
|
||||
auto_start: true,
|
||||
},
|
||||
},
|
||||
cliproxy_safety: {
|
||||
concurrent_limit: 1,
|
||||
cooldown_seconds: 0,
|
||||
shared_responsibility: false,
|
||||
},
|
||||
quota_management: {
|
||||
enabled: false,
|
||||
},
|
||||
thinking: {
|
||||
mode: 'auto',
|
||||
show_warnings: true,
|
||||
},
|
||||
official_channels: {
|
||||
enabled: false,
|
||||
selected: [],
|
||||
unattended: false,
|
||||
},
|
||||
dashboard_auth: {
|
||||
enabled: false,
|
||||
users: [],
|
||||
session_secret: '',
|
||||
},
|
||||
logging: {
|
||||
enabled: true,
|
||||
profile_starts: true,
|
||||
delegation_calls: true,
|
||||
proxy_requests: false,
|
||||
quota_polls: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('browser setup', () => {
|
||||
let tempDir = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('enables Claude browser attach and prepares the managed default path', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-setup-'));
|
||||
const config = createUnifiedConfig(join(tempDir, 'browser-profile'));
|
||||
config.browser.claude.user_data_dir = '';
|
||||
|
||||
const deps: BrowserSetupDeps = {
|
||||
getBrowserConfig: () => config.browser,
|
||||
mutateUnifiedConfig: (mutator) => {
|
||||
mutator(config);
|
||||
return config;
|
||||
},
|
||||
ensureBrowserMcp: () => true,
|
||||
getBrowserStatus: async () =>
|
||||
({
|
||||
claude: {
|
||||
enabled: true,
|
||||
source: 'config',
|
||||
overrideActive: false,
|
||||
state: 'ready',
|
||||
title: 'Claude Browser Attach is ready.',
|
||||
detail: 'ready',
|
||||
nextStep: 'Launch Claude.',
|
||||
effectiveUserDataDir: config.browser.claude.user_data_dir,
|
||||
recommendedUserDataDir: config.browser.claude.user_data_dir,
|
||||
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',
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
state: 'enabled',
|
||||
title: 'Codex Browser Tools are enabled.',
|
||||
detail: 'ready',
|
||||
nextStep: 'Use Codex.',
|
||||
serverName: 'ccs_browser',
|
||||
supportsConfigOverrides: true,
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
},
|
||||
}) as Awaited<ReturnType<BrowserSetupDeps['getBrowserStatus']>>,
|
||||
};
|
||||
|
||||
const result = await runBrowserSetup(deps);
|
||||
|
||||
expect(result.configUpdated).toBe(true);
|
||||
expect(result.createdUserDataDir).toBe(true);
|
||||
expect(result.ready).toBe(true);
|
||||
expect(config.browser.claude.enabled).toBe(true);
|
||||
expect(config.browser.claude.user_data_dir).not.toBe('');
|
||||
});
|
||||
|
||||
it('does not create a custom override path during setup', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-setup-'));
|
||||
const config = createUnifiedConfig(join(tempDir, 'browser-profile'));
|
||||
config.browser.claude.enabled = true;
|
||||
|
||||
const deps: BrowserSetupDeps = {
|
||||
getBrowserConfig: () => config.browser,
|
||||
mutateUnifiedConfig: (mutator) => {
|
||||
mutator(config);
|
||||
return config;
|
||||
},
|
||||
ensureBrowserMcp: () => true,
|
||||
getBrowserStatus: async () =>
|
||||
({
|
||||
claude: {
|
||||
enabled: true,
|
||||
source: 'CCS_BROWSER_USER_DATA_DIR',
|
||||
overrideActive: true,
|
||||
state: 'browser_not_running',
|
||||
title: 'Claude Browser Attach could not find a running browser session.',
|
||||
detail: 'override active',
|
||||
nextStep: 'Run `ccs browser setup` to configure and start the managed browser session.',
|
||||
effectiveUserDataDir: config.browser.claude.user_data_dir,
|
||||
recommendedUserDataDir: config.browser.claude.user_data_dir,
|
||||
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',
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
state: 'enabled',
|
||||
title: 'Codex Browser Tools are enabled.',
|
||||
detail: 'ready',
|
||||
nextStep: 'Use Codex.',
|
||||
serverName: 'ccs_browser',
|
||||
supportsConfigOverrides: true,
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
},
|
||||
}) as Awaited<ReturnType<BrowserSetupDeps['getBrowserStatus']>>,
|
||||
};
|
||||
|
||||
const result = await runBrowserSetup(deps);
|
||||
|
||||
expect(result.createdUserDataDir).toBe(false);
|
||||
expect(result.overrideActive).toBe(false);
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.notes[0]).toContain('did not create the current browser user-data dir');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mutateUnifiedConfig } from '../../../../src/config/unified-config-loader';
|
||||
import {
|
||||
getBrowserConfig,
|
||||
mutateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
} from '../../../../src/config/unified-config-loader';
|
||||
import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types';
|
||||
import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse';
|
||||
import { getBrowserStatus } from '../../../../src/utils/browser/browser-status';
|
||||
import {
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
resolveOptionalBrowserAttachRuntime,
|
||||
} from '../../../../src/utils/browser/browser-settings';
|
||||
import * as codexDetector from '../../../../src/targets/codex-detector';
|
||||
|
||||
describe('browser status', () => {
|
||||
@@ -55,7 +64,7 @@ describe('browser status', () => {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns a disabled Claude lane with the recommended managed user-data dir by default', async () => {
|
||||
it('returns disabled/manual browser lanes with the recommended managed user-data dir by default', async () => {
|
||||
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
|
||||
path: '/usr/local/bin/codex',
|
||||
needsShell: false,
|
||||
@@ -69,21 +78,110 @@ describe('browser status', () => {
|
||||
expect(status.claude).toMatchObject({
|
||||
enabled: false,
|
||||
state: 'disabled',
|
||||
policy: 'manual',
|
||||
source: 'config',
|
||||
effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
||||
devtoolsPort: 9222,
|
||||
evalMode: 'readonly',
|
||||
managedMcpServerName: 'ccs-browser',
|
||||
});
|
||||
expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222');
|
||||
expect(status.claude.detail).toContain('off by default');
|
||||
expect(status.codex).toMatchObject({
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
state: 'disabled',
|
||||
serverName: 'ccs_browser',
|
||||
supportsConfigOverrides: false,
|
||||
});
|
||||
expect(status.codex.detail).toContain('off by default');
|
||||
expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(false);
|
||||
} finally {
|
||||
codexSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves missing saved browser policies to manual while preserving explicit enabled values', async () => {
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
user_data_dir: '/tmp/explicit-claude',
|
||||
devtools_port: 9333,
|
||||
} as typeof config.browser.claude,
|
||||
codex: {
|
||||
enabled: true,
|
||||
} as typeof config.browser.codex,
|
||||
};
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue(
|
||||
new Error('Chrome reuse metadata not found: /tmp/explicit-claude/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).toMatchObject({
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
effectiveUserDataDir: '/tmp/explicit-claude',
|
||||
devtoolsPort: 9333,
|
||||
});
|
||||
expect(status.codex).toMatchObject({
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
state: 'enabled',
|
||||
serverName: 'ccs_browser',
|
||||
evalMode: 'readonly',
|
||||
supportsConfigOverrides: true,
|
||||
});
|
||||
} finally {
|
||||
runtimeSpy.mockRestore();
|
||||
codexSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('bootstraps the managed default browser profile dir before reporting attach readiness', async () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue(
|
||||
new Error(
|
||||
`Chrome reuse metadata not found: ${join(tempHome, '.ccs', 'browser', 'chrome-user-data', '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.title).toBe('Claude Browser Attach is not ready yet.');
|
||||
expect(status.claude.detail).toContain('created the managed browser profile directory');
|
||||
expect(status.claude.nextStep).toContain('ccs browser setup');
|
||||
expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
|
||||
} finally {
|
||||
runtimeSpy.mockRestore();
|
||||
codexSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -93,13 +191,13 @@ describe('browser status', () => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
user_data_dir: '/config-browser',
|
||||
devtools_port: 9333,
|
||||
eval_mode: 'readwrite',
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
eval_mode: 'disabled',
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -112,7 +210,6 @@ describe('browser status', () => {
|
||||
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',
|
||||
CCS_BROWSER_EVAL_MODE: 'readwrite',
|
||||
});
|
||||
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
|
||||
path: '/usr/local/bin/codex',
|
||||
@@ -130,29 +227,70 @@ describe('browser status', () => {
|
||||
source: 'CCS_BROWSER_USER_DATA_DIR',
|
||||
effectiveUserDataDir: '/env-browser',
|
||||
devtoolsPort: 9444,
|
||||
evalMode: 'readwrite',
|
||||
});
|
||||
expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('9444');
|
||||
expect(status.claude.runtimeEnv?.CCS_BROWSER_EVAL_MODE).toBe('readwrite');
|
||||
expect(status.codex.evalMode).toBe('disabled');
|
||||
} finally {
|
||||
runtimeSpy.mockRestore();
|
||||
codexSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports browser_not_running when attach metadata is missing', async () => {
|
||||
it('returns a short managed attach warning when the managed browser dir is missing', async () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
user_data_dir: '/tmp/browser-profile',
|
||||
policy: 'auto',
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
eval_mode: 'readonly',
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
eval_mode: 'readonly',
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const resolution = await resolveOptionalBrowserAttachRuntime(
|
||||
getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
|
||||
);
|
||||
|
||||
expect(resolution.runtimeEnv).toBeUndefined();
|
||||
expect(resolution.warning).toContain('Claude Browser Attach is not ready yet.');
|
||||
expect(resolution.warning).toContain('ccs browser setup');
|
||||
expect(resolution.warning).toContain('ccs browser doctor');
|
||||
});
|
||||
|
||||
it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => {
|
||||
const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data');
|
||||
mkdirSync(managedDir, { recursive: true });
|
||||
|
||||
const resolution = await resolveOptionalBrowserAttachRuntime({
|
||||
enabled: true,
|
||||
source: 'config',
|
||||
overrideActive: false,
|
||||
userDataDir: managedDir,
|
||||
devtoolsPort: 43123,
|
||||
hasExplicitDevtoolsPort: true,
|
||||
});
|
||||
|
||||
expect(resolution.runtimeEnv).toBeUndefined();
|
||||
expect(resolution.warning).toContain('Claude Browser Attach is not ready yet.');
|
||||
expect(resolution.warning).toContain('ccs browser setup');
|
||||
});
|
||||
|
||||
it('reports browser_not_running when attach metadata is missing for a custom path', async () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
user_data_dir: '/tmp/browser-profile',
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -188,7 +326,6 @@ describe('browser status', () => {
|
||||
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',
|
||||
CCS_BROWSER_EVAL_MODE: 'readonly',
|
||||
});
|
||||
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
|
||||
path: '/usr/local/bin/codex',
|
||||
@@ -203,10 +340,8 @@ describe('browser status', () => {
|
||||
expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({
|
||||
profileDir: '/legacy-browser',
|
||||
devtoolsPort: undefined,
|
||||
evalMode: 'readonly',
|
||||
});
|
||||
expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123');
|
||||
expect(status.claude.runtimeEnv?.CCS_BROWSER_EVAL_MODE).toBe('readonly');
|
||||
} finally {
|
||||
runtimeSpy.mockRestore();
|
||||
codexSpy.mockRestore();
|
||||
@@ -218,13 +353,13 @@ describe('browser status', () => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'auto',
|
||||
user_data_dir: '/tmp/config-browser',
|
||||
devtools_port: 9222,
|
||||
eval_mode: 'disabled',
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
eval_mode: 'readwrite',
|
||||
policy: 'auto',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -235,7 +370,6 @@ describe('browser status', () => {
|
||||
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',
|
||||
CCS_BROWSER_EVAL_MODE: 'disabled',
|
||||
});
|
||||
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
|
||||
path: '/usr/local/bin/codex',
|
||||
@@ -250,7 +384,6 @@ describe('browser status', () => {
|
||||
expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({
|
||||
profileDir: '/tmp/config-browser',
|
||||
devtoolsPort: '9222',
|
||||
evalMode: 'disabled',
|
||||
});
|
||||
} finally {
|
||||
runtimeSpy.mockRestore();
|
||||
|
||||
@@ -266,6 +266,7 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
|
||||
expect(spawnCalls[0].options?.shell).toBe('cmd.exe');
|
||||
});
|
||||
|
||||
it('execClaude sets DISABLE_AUTOUPDATER=1 when preferences.auto_update is false', () => {
|
||||
|
||||
@@ -81,6 +81,56 @@ describe('escapeShellArg', () => {
|
||||
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
|
||||
expect(escapeShellArg('hello!')).toBe('"hello^^!"');
|
||||
});
|
||||
|
||||
it('prefers ComSpec when resolving the escaped command shell', async () => {
|
||||
const originalComSpec = process.env.ComSpec;
|
||||
const originalCOMSPEC = process.env.COMSPEC;
|
||||
|
||||
try {
|
||||
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe';
|
||||
delete process.env.COMSPEC;
|
||||
const { getWindowsEscapedCommandShell } = await import(
|
||||
'../../../src/utils/shell-executor'
|
||||
);
|
||||
expect(getWindowsEscapedCommandShell()).toBe('C:\\Windows\\System32\\cmd.exe');
|
||||
} finally {
|
||||
if (originalComSpec === undefined) delete process.env.ComSpec;
|
||||
else process.env.ComSpec = originalComSpec;
|
||||
if (originalCOMSPEC === undefined) delete process.env.COMSPEC;
|
||||
else process.env.COMSPEC = originalCOMSPEC;
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to cmd.exe when ComSpec is unavailable', async () => {
|
||||
const originalComSpec = process.env.ComSpec;
|
||||
const originalCOMSPEC = process.env.COMSPEC;
|
||||
|
||||
try {
|
||||
delete process.env.ComSpec;
|
||||
delete process.env.COMSPEC;
|
||||
const { getWindowsEscapedCommandShell } = await import(
|
||||
'../../../src/utils/shell-executor'
|
||||
);
|
||||
expect(getWindowsEscapedCommandShell()).toBe('cmd.exe');
|
||||
} finally {
|
||||
if (originalComSpec === undefined) delete process.env.ComSpec;
|
||||
else process.env.ComSpec = originalComSpec;
|
||||
if (originalCOMSPEC === undefined) delete process.env.COMSPEC;
|
||||
else process.env.COMSPEC = originalCOMSPEC;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWindowsEscapedCommandShell', () => {
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('returns shell=true outside Windows if called defensively', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
const { getWindowsEscapedCommandShell } = await import('../../../src/utils/shell-executor');
|
||||
expect(getWindowsEscapedCommandShell()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import type { Server } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -15,8 +15,8 @@ describe('browser routes', () => {
|
||||
let originalDashboardAuthEnabled: string | undefined;
|
||||
let originalBrowserUserDataDir: string | undefined;
|
||||
let originalBrowserProfileDir: string | undefined;
|
||||
let originalBrowserDevtoolsPort: string | undefined;
|
||||
let originalBrowserDevtoolsHost: string | undefined;
|
||||
let originalBrowserDevtoolsPort: string | undefined;
|
||||
let originalBrowserDevtoolsHttpUrl: string | undefined;
|
||||
let originalBrowserDevtoolsWsUrl: string | undefined;
|
||||
let originalBrowserEvalMode: string | undefined;
|
||||
@@ -63,8 +63,8 @@ describe('browser routes', () => {
|
||||
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||
originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR;
|
||||
originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST;
|
||||
originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
originalBrowserDevtoolsHttpUrl = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||
originalBrowserDevtoolsWsUrl = process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||
originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE;
|
||||
@@ -72,8 +72,8 @@ describe('browser routes', () => {
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
|
||||
delete process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||
delete process.env.CCS_BROWSER_PROFILE_DIR;
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_HOST;
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||
delete process.env.CCS_BROWSER_EVAL_MODE;
|
||||
@@ -98,37 +98,31 @@ describe('browser routes', () => {
|
||||
} 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;
|
||||
}
|
||||
|
||||
if (originalBrowserDevtoolsHost !== undefined) {
|
||||
process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost;
|
||||
} else {
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_HOST;
|
||||
}
|
||||
|
||||
if (originalBrowserDevtoolsPort !== undefined) {
|
||||
process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort;
|
||||
} else {
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
}
|
||||
if (originalBrowserDevtoolsHttpUrl !== undefined) {
|
||||
process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL = originalBrowserDevtoolsHttpUrl;
|
||||
} else {
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||
}
|
||||
|
||||
if (originalBrowserDevtoolsWsUrl !== undefined) {
|
||||
process.env.CCS_BROWSER_DEVTOOLS_WS_URL = originalBrowserDevtoolsWsUrl;
|
||||
} else {
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||
}
|
||||
|
||||
if (originalBrowserEvalMode !== undefined) {
|
||||
process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode;
|
||||
} else {
|
||||
@@ -156,23 +150,27 @@ describe('browser routes', () => {
|
||||
expect(payload.config).toMatchObject({
|
||||
claude: {
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
||||
devtoolsPort: 9222,
|
||||
evalMode: 'readonly',
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
evalMode: 'readonly',
|
||||
enabled: false,
|
||||
policy: 'manual',
|
||||
},
|
||||
});
|
||||
expect(payload.status.claude).toMatchObject({
|
||||
state: 'disabled',
|
||||
policy: 'manual',
|
||||
managedMcpServerName: 'ccs-browser',
|
||||
});
|
||||
expect(payload.status.codex).toMatchObject({
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
state: 'disabled',
|
||||
policy: 'manual',
|
||||
serverName: 'ccs_browser',
|
||||
});
|
||||
expect(payload.status.codex.detail).toContain('off by default');
|
||||
});
|
||||
|
||||
it('updates the saved browser config through the dashboard route', async () => {
|
||||
@@ -182,13 +180,13 @@ describe('browser routes', () => {
|
||||
body: JSON.stringify({
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
userDataDir: '/tmp/ccs-browser',
|
||||
devtoolsPort: 9333,
|
||||
evalMode: 'readwrite',
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
evalMode: 'disabled',
|
||||
policy: 'manual',
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -198,13 +196,13 @@ describe('browser routes', () => {
|
||||
expect(payload.browser.config).toMatchObject({
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
userDataDir: '/tmp/ccs-browser',
|
||||
devtoolsPort: 9333,
|
||||
evalMode: 'readwrite',
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
evalMode: 'disabled',
|
||||
policy: 'manual',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -212,13 +210,13 @@ describe('browser routes', () => {
|
||||
expect(config.browser).toMatchObject({
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
user_data_dir: '/tmp/ccs-browser',
|
||||
devtools_port: 9333,
|
||||
eval_mode: 'readwrite',
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
eval_mode: 'disabled',
|
||||
policy: 'manual',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -230,6 +228,7 @@ describe('browser routes', () => {
|
||||
body: JSON.stringify({
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
userDataDir: '/tmp/ccs-browser-custom',
|
||||
devtoolsPort: 9333,
|
||||
},
|
||||
@@ -252,18 +251,21 @@ describe('browser routes', () => {
|
||||
const payload = await resetResponse.json();
|
||||
expect(payload.browser.config.claude).toMatchObject({
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
||||
devtoolsPort: 9333,
|
||||
evalMode: 'readonly',
|
||||
});
|
||||
expect(payload.browser.status.claude.state).toBe('browser_not_running');
|
||||
expect(payload.browser.status.claude.detail).toContain('CCS created the managed browser profile');
|
||||
expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.browser).toMatchObject({
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: 'manual',
|
||||
user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
||||
devtools_port: 9333,
|
||||
eval_mode: 'readonly',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -285,35 +287,69 @@ describe('browser routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid eval modes at the route boundary', async () => {
|
||||
const claudeResponse = await fetch(`${baseUrl}/api/browser`, {
|
||||
it('rejects null browser lane payloads instead of treating them as no-ops', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/browser`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
claude: {
|
||||
evalMode: 'dangerous',
|
||||
claude: null,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Invalid value for claude. Must be an object.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown browser config fields instead of silently ignoring them', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/browser`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
codxe: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(claudeResponse.status).toBe(400);
|
||||
expect(await claudeResponse.json()).toEqual({
|
||||
error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.',
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Unknown browser config field(s): codxe.',
|
||||
});
|
||||
});
|
||||
|
||||
const codexResponse = await fetch(`${baseUrl}/api/browser`, {
|
||||
it('rejects invalid browser policy values at the route boundary', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/browser`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
codex: {
|
||||
evalMode: 'dangerous',
|
||||
policy: 'always',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(codexResponse.status).toBe(400);
|
||||
expect(await codexResponse.json()).toEqual({
|
||||
error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.',
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Invalid value for codex.policy. Must be auto or manual.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown nested browser lane fields instead of silently ignoring them', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/browser`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
claude: {
|
||||
userDatDir: '/tmp/typo',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Unknown claude browser field(s): userDatDir.',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user