diff --git a/.github/pr-assets/issue-1058/codex-selector-variants.png b/.github/pr-assets/issue-1058/codex-selector-variants.png new file mode 100644 index 00000000..0775cae0 Binary files /dev/null and b/.github/pr-assets/issue-1058/codex-selector-variants.png differ diff --git a/.github/pr-assets/issue-1058/index.html b/.github/pr-assets/issue-1058/index.html new file mode 100644 index 00000000..77fcccae --- /dev/null +++ b/.github/pr-assets/issue-1058/index.html @@ -0,0 +1,138 @@ + + + + + + Issue 1058 UI Evidence + + + +
+
+

Issue 1058

+

Codex selector now exposes pinned effort variants

+

+ Capture environment: local dashboard at http://127.0.0.1:3001/cliproxy, + light theme, Codex provider editor, Model Config tab. +

+
+
+

What changed

+

The selector now offers real codex model variants like -high and -xhigh.

+
+
+

Why it matters

+

The dashboard can now emit the exact suffixed model IDs that runtime code already understands.

+
+
+

Review cue

+

Look at the opened model picker: suffixed entries appear as first-class options with pinned-effort badges.

+
+
+
+ +
+
+

Targeted evidence

+

Codex provider editor model picker

+

+ The open selector shows canonical rows plus generated pinned variants such as + gpt-5.3-codex-high, gpt-5.3-codex-xhigh, and + gpt-5.4-xhigh. +

+
+ Codex provider editor with the model selector opened and suffixed effort variants visible. +
+
+ + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3ed3a780..c3c6ce9d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,8 +6,9 @@ Use what applies. If you skipped something, add a short note instead of forcing it. -- [ ] `bun run validate` -- [ ] `bun run validate:ci-parity` +- [ ] `bun run format && bun run lint:fix && bun run validate` +- [ ] `bun run validate:ci-parity` before requesting review +- [ ] `bun run test:e2e` if this PR touches command routing, proxy flows, or workflow/release logic - [ ] `cd ui && bun run validate` if UI changed - [ ] Not run @@ -20,6 +21,7 @@ Check what applies. Not every item is relevant for every PR. - [ ] Relevant `--help` output updated if CLI behavior changed - [ ] Tests added or updated if behavior changed - [ ] README or local docs updated if user-facing behavior changed +- [ ] If a check failed, the PR body explains what failed and what changed to fix it - [ ] No secrets, tokens, or private config data are included ## Docs Impact diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b45e544..3dba111b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,13 +17,17 @@ concurrency: jobs: validate: runs-on: [self-hosted, linux, x64] + env: + # Keep Bun cache isolated per job workspace so parallel self-hosted runs + # do not race on ~/.bun/install/cache and corrupt restore/install state. + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache 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: lint, cmd: 'bun run lint' } + - { name: format, cmd: 'bun run format:check' } name: ${{ matrix.check.name }} steps: @@ -40,21 +44,15 @@ jobs: with: node-version: '22' - - name: Restore bun + node_modules cache + - name: Restore bun package cache uses: actions/cache@v4 with: path: | - ~/.bun/install/cache - node_modules - ui/node_modules - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'ui/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- + ${{ env.BUN_INSTALL_CACHE_DIR }} + key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }} - name: Ensure dependencies - run: | - [ -d node_modules ] || bun install --frozen-lockfile - [ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile) + run: bash scripts/ensure-deps.sh - name: Run ${{ matrix.check.name }} run: ${{ matrix.check.cmd }} @@ -62,6 +60,8 @@ jobs: build: runs-on: [self-hosted, linux, x64] name: build + env: + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache steps: - name: Checkout code uses: actions/checkout@v4 @@ -76,21 +76,15 @@ jobs: with: node-version: '22' - - name: Restore bun + node_modules cache + - name: Restore bun package cache uses: actions/cache@v4 with: path: | - ~/.bun/install/cache - node_modules - ui/node_modules - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'ui/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- + ${{ env.BUN_INSTALL_CACHE_DIR }} + key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }} - name: Ensure dependencies - run: | - [ -d node_modules ] || bun install --frozen-lockfile - [ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile) + run: bash scripts/ensure-deps.sh - name: Build run: bun run build:all @@ -107,6 +101,8 @@ jobs: runs-on: [self-hosted, linux, x64] name: test needs: [build] + env: + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache steps: - name: Checkout code uses: actions/checkout@v4 @@ -121,21 +117,15 @@ jobs: with: node-version: '22' - - name: Restore bun + node_modules cache + - name: Restore bun package cache uses: actions/cache@v4 with: path: | - ~/.bun/install/cache - node_modules - ui/node_modules - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'ui/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- + ${{ env.BUN_INSTALL_CACHE_DIR }} + key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }} - name: Ensure dependencies - run: | - [ -d node_modules ] || bun install --frozen-lockfile - [ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile) + run: bash scripts/ensure-deps.sh - name: Download dist artifact uses: actions/download-artifact@v4 @@ -145,3 +135,8 @@ jobs: - name: Test run: bun run test:all + + - name: Test CLI e2e + env: + CCS_E2E_SKIP_BUILD: '1' + run: bun run test:e2e diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index e048677e..d3fb654c 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -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,24 +42,30 @@ 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 - - name: Validate (typecheck + lint + format + tests) + - name: Validate fast gate run: bun run validate + - name: Test slow bucket + run: bun run test:slow + + - 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: | diff --git a/.github/workflows/push-ci.yml b/.github/workflows/push-ci.yml new file mode 100644 index 00000000..8a2ab204 --- /dev/null +++ b/.github/workflows/push-ci.yml @@ -0,0 +1,166 @@ +name: Push CI + +on: + push: + branches: [dev] + +concurrency: + group: push-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: [self-hosted, linux, x64] + env: + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache + 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: 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: | + ${{ env.BUN_INSTALL_CACHE_DIR }} + key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }} + + - 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 + env: + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache + 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: | + ${{ env.BUN_INSTALL_CACHE_DIR }} + key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }} + + - name: Ensure dependencies + run: bash scripts/ensure-deps.sh + + - name: Build + run: bun run build:all + + - 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] + env: + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache + FAST_TEST_BUDGET_SECONDS: '90' + 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: | + ${{ env.BUN_INSTALL_CACHE_DIR }} + key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }} + + - 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 fast bucket with perf budget + shell: bash + run: | + set -euo pipefail + + start_epoch=$(date +%s) + test_status=0 + if bun run test:fast; then + test_status=0 + else + test_status=$? + fi + end_epoch=$(date +%s) + elapsed_seconds=$((end_epoch - start_epoch)) + + { + echo "### test:fast perf budget" + echo "" + echo "- Elapsed: ${elapsed_seconds}s" + echo "- Budget: ${FAST_TEST_BUDGET_SECONDS}s" + } >> "$GITHUB_STEP_SUMMARY" + + if (( elapsed_seconds > FAST_TEST_BUDGET_SECONDS )); then + echo "::warning::test:fast took ${elapsed_seconds}s (budget ${FAST_TEST_BUDGET_SECONDS}s) - check for undeclared slow tests in scripts/run-test-bucket.js" + echo "- Result: warning emitted. Check for undeclared slow tests in scripts/run-test-bucket.js." >> "$GITHUB_STEP_SUMMARY" + else + echo "- Result: within budget." >> "$GITHUB_STEP_SUMMARY" + fi + + exit "$test_status" + + - name: Test slow bucket + run: bun run test:slow + + - name: Test CLI e2e + env: + CCS_E2E_SKIP_BUILD: '1' + run: bun run test:e2e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b0e2220..db140f7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,16 +37,22 @@ 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 - - name: Validate (typecheck + lint + format + tests) + - name: Validate fast gate run: bun run validate + - name: Test slow bucket + run: bun run test:slow + + - name: Test CLI e2e + env: + CCS_E2E_SKIP_BUILD: '1' + run: bun run test:e2e + - name: Release id: release env: diff --git a/.gitignore b/.gitignore index 2a782db3..42ffcf79 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file +ui/coverage/ diff --git a/.husky/pre-push b/.husky/pre-push index 4dee9050..b47b1dd4 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,4 +1,10 @@ #!/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. @@ -39,9 +45,9 @@ echo " branch: $CURRENT_BRANCH" echo " base: $BASE_BRANCH" bun run typecheck -bun run lint:fix +bun run lint bun run format:check -bun run build:all +bun run test:fast git fetch origin "$BASE_BRANCH" --quiet || true DIFF_RANGE="HEAD" diff --git a/CLAUDE.md b/CLAUDE.md index 11175260..05003c5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,15 @@ gh pr checks ### 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. +### Dev Release vs Push CI + +- `CI` is the pull-request quality gate for contributor branches. +- `Push CI` is the post-merge quality signal for `dev`. +- `Dev Release` publishes the `@dev` package after `dev` changes land. +- A red `Dev Release` does **not** automatically mean contributor code failed. Check `Push CI` first. +- Verified on `2026-04-22` via `gh api repos/kaitranntt/ccs/branches/dev/protection`: `dev` currently requires `typecheck`, `lint`, `format`, `build`, and `test`, has no branch restrictions, and has no required PR-review gate. +- `dev-release.yml` currently pushes with `PAT_TOKEN` because `dev` is protected by those required status checks. Do not switch it back to `github.token` unless branch protection changes with it. + ## Core Function Multi-provider profile and runtime manager for Claude Code, Factory Droid, @@ -87,7 +96,7 @@ broader topic. | Mistake | Consequence | Correct Action | |---------|-------------|----------------| | Running `validate` without `format` first | format:check fails | Run `bun run format` BEFORE validate | -| Assuming maintainability check is always strict | PR/feature branches run warning mode by default | Use `bun run maintainability:check:strict` before merge when touching debt-sensitive code | +| Treating `Dev Release` as the contributor quality signal | Publish failures on `dev` look like broken code | Check PR `CI` on the branch and `Push CI` on `dev` first | | Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix | | Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs | | Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning | @@ -208,8 +217,8 @@ Quality gates MUST pass before pushing. **Both projects have identical workflow. # Main project (from repo root) bun run format # Step 1: Fix formatting bun run lint:fix # Step 2: Fix lint issues -bun run validate # Step 3: Full gate (typecheck + lint + format + maintainability + tests) -bun run validate:ci-parity # Step 4: full CI parity gate (build + validate + base branch check) +bun run validate # Step 3: Fast gate (typecheck + lint + format + test:fast) +bun run validate:ci-parity # Step 4: PR-CI parity gate (branch check + build + full tests + e2e) # UI project (if UI changed) cd ui @@ -221,17 +230,17 @@ bun run validate # Step 3: Final check (must pass) **WHY THIS ORDER:** - `validate` runs `format:check` which only VERIFIES—won't fix - If format:check fails, you skipped step 1 -- CI runs `validate` only (no auto-fix)—local must be clean +- `validate` now uses read-only `lint`, so autofix still belongs in step 2 +- PR CI and `validate:ci-parity` both run non-mutating checks only -### What Validate Runs +### What Each Gate Runs | Project | Command | Runs | |---------|---------|------| -| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all | +| Main | `bun run validate` | typecheck + lint + format:check + test:fast | +| Main | `bun run validate:ci-parity` | base branch check + typecheck + lint + format:check + build:all + test:all + test:e2e | | 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 | @@ -255,33 +264,19 @@ bun run validate # Step 3: Final check (must pass) ### Automatic Enforcement -- `prepublishOnly` / `prepack` runs `build:all` + `validate` + `sync-version.js` -- CI/CD runs `bun run validate` on every PR (maintainability is warning mode on PR events) +- `prepack` runs `build:all` +- PR `CI` runs `typecheck`, `lint`, `format`, `build`, `test:all`, and `test:e2e` +- `Push CI` runs the same quality suite on `dev` after merge, separate from release publishing +- `Dev Release` still runs build + fast validation + slow tests + e2e before publishing and still requires `PAT_TOKEN` to push back to protected `dev` - husky `pre-commit` runs quick lint/type/format checks - husky `pre-push` runs the full `bun run validate:ci-parity` gate on `main`/`dev`/hotfix branches -- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint:fix` + `format:check` + targeted checks based on changed files) before GitHub CI handles the full matrix +- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint` + `format:check` + `test:fast`) plus targeted checks based on changed files -### Maintainability Baseline Gate +### Maintainability Gate Status -- 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` (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) - - override commands: - - `bun run maintainability:check:strict` - - `bun run maintainability:check:warn` -- Gated metrics (must not increase vs baseline): - - `processExitReferenceCount` - - `synchronousFsApiReferenceCount` -- Informational metrics (collected but not gated): - - `largeFileCountOver350Loc` -- Baseline update policy: - 1. Prefer reducing the metric and keeping the baseline unchanged. - 2. On protected-branch integration (strict mode), if increase is intentional and accepted, run `bun run maintainability:baseline`. - 3. Commit both the code change and `docs/metrics/maintainability-baseline.json`, and state reason in PR description. +- The historical maintainability baseline gate is retired from the active CCS workflow. +- `validate`, `validate:ci-parity`, PR `CI`, `Push CI`, and release workflows do **not** invoke `maintainability:check`. +- Older roadmap references to `maintainability:baseline` / `maintainability:check` are historical context, not current repo commands. ## Critical Constraints (NEVER VIOLATE) @@ -532,17 +527,16 @@ rm -rf ~/.ccs # Clean environment 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. +Husky `pre-push` auto-runs: `typecheck + lint + format:check + test:fast` plus targeted checks 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 +- [ ] `bun run validate:ci-parity` — branch freshness + build + full non-e2e tests + e2e - [ ] `gh pr checks ` — 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` +- [ ] If touching command routing, proxy flows, workflows, or release logic: `bun run test:e2e` ### Code / Docs / Standards (verify before merge) - [ ] Conventional commit format (`feat:`, `fix:`, etc.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c283c060..b72b2b00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,14 +76,29 @@ Rules: - Treat `hotfix/*` as maintainer-only emergency flow from `main`. - Delete your branch after merge. +## CI and Release Flow + +CCS now uses three separate automation lanes: + +- `CI` runs on pull requests to `dev` and `main`. This is the review gate for contributor branches. +- `Push CI` runs after a merge lands on `dev`. This is the code-quality signal for the shared `dev` branch. +- `Dev Release` publishes the `@dev` package after `dev` changes land. It is release automation, not the primary contributor quality signal. + +If `Dev Release` is red but your PR checks were green, check `Push CI` before assuming the merged code is broken. + +If `CI` or `Push CI` stays queued for a long time, it is a maintainer infrastructure issue, not a contributor mistake. Leave a comment on your PR and a maintainer will address it. + +## AI Agent Rules + +`CONTRIBUTING.md` is the human entry point. For AI agents working in this repo, the authoritative automation and workflow rules live in [CLAUDE.md](./CLAUDE.md). + ## AI Review Lane CCS PR review no longer depends on `anthropics/claude-code-action`. The repository review lane is self-hosted PR-Agent: - The retained `.github/workflows/ai-review.yml` runs PR-Agent in GitHub Actions. -- PR-Agent reviews run on the existing self-hosted `cliproxy` runner. - Use `/review` on the PR when you need a fresh pass after follow-up commits. -- Only the trusted `/review` comment path is enabled on the privileged self-hosted runner. +- Only the trusted `/review` comment path is enabled. - Keep repository-level reviewer instructions in the root `.pr_agent.toml`. - Keep runtime wiring and defaults in `ai-review.yml`, which still maps the existing `AI_REVIEW_BASE_URL`, `AI_REVIEW_MODEL`, and `AI_REVIEW_API_KEY` integrations onto PR-Agent's `OPENAI.*` and `config.*` settings. - If you change review defaults, update the workflow or `.pr_agent.toml` alongside the contributor or architecture docs in the same PR. @@ -129,16 +144,35 @@ Use `bun run dev` from the repo root when working on the local dashboard experie ## Validation -If you can, run these before you open or update a PR: +Run this fast local gate before you open or update a PR: ```bash bun run format bun run lint:fix bun run validate +``` + +`bun run validate` is the day-to-day contributor gate. It runs: + +- `typecheck` +- `lint` +- `format:check` +- `test:fast` + +Before you ask for review, or whenever you want the closest local equivalent to PR CI, run: + +```bash bun run validate:ci-parity ``` -If you changed the dashboard: +`bun run validate:ci-parity` adds: + +- branch freshness check against `origin/dev` or `origin/main` +- `build:all` +- full non-e2e test suite via `test:all` +- `test:e2e` with `CCS_E2E_SKIP_BUILD=1` + +If you changed the dashboard, run the UI gate too: ```bash cd ui @@ -155,7 +189,17 @@ bun run test:native bun run test:e2e ``` -`bun run validate` is the main gate. It covers typechecking, linting, format checks, maintainability checks, and automated tests for the main project. +Use `bun run test:e2e` locally before review if you touch command routing, proxy flows, release automation, or workflow wiring and want to reproduce the same CLI e2e lane that PR CI runs. + +### Why Did CI Fail? + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| `format` fails in PR CI | `bun run format` was skipped locally | Run `bun run format`, recommit, push again | +| `lint` fails in PR CI | `validate` now uses read-only `lint` | Run `bun run lint:fix`, then rerun `bun run validate` | +| `test` fails in PR CI but `validate` passed | The failure is in `test:slow` or `test:e2e` | Run `bun run validate:ci-parity` locally | +| Checks stay queued for >10 min | Self-hosted runner is offline | Wait for maintainer intervention; rerunning usually does not help | +| `Dev Release` is red on `dev` after merge | Release-only failure or publish problem | Check `Push CI` first to confirm code quality | If you cannot run the full suite, that is still fine for early or docs-only PRs. Just say what you did run, or what blocked you, in the PR. diff --git a/README.md b/README.md index 4aab06f4..5a1480a0 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,8 @@ config. Deep dive: Manage OAuth-backed providers, quota visibility, and proxy-wide routing from one place. CCS now surfaces round-robin vs fill-first natively in both CLI and dashboard flows instead of hiding that -choice inside raw upstream controls. +choice inside raw upstream controls. The original CLIProxyAPI backend remains the default; the +community-maintained CLIProxyAPIPlus fork is opt-in for plus-only providers. Deep dive: [CLIProxy API](https://docs.ccs.kaitran.ca/features/proxy/cliproxy-api). @@ -177,6 +178,9 @@ ccs ollama "summarize these logs" ## Contribute And Report Safely - Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md) +- Daily local gate: `bun run format && bun run lint:fix && bun run validate` (`validate` is the fast path only) +- Before review or merge confidence: `bun run validate:ci-parity` +- If PR checks stay queued for more than 10 minutes, assume the self-hosted runner is offline and notify a maintainer instead of retrying blindly - Starter work: [good first issue](https://github.com/kaitranntt/ccs/labels/good%20first%20issue), [help wanted](https://github.com/kaitranntt/ccs/labels/help%20wanted) diff --git a/config/base-claude.settings.json b/config/base-claude.settings.json index c8b703c9..e9986c70 100644 --- a/config/base-claude.settings.json +++ b/config/base-claude.settings.json @@ -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" } diff --git a/docs/browser-automation.md b/docs/browser-automation.md index af51c053..a8ceb4ad 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -1,6 +1,6 @@ # Browser Automation -Last Updated: 2026-04-16 +Last Updated: 2026-04-19 CCS provides browser automation through two separate runtime paths: @@ -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 @@ -46,16 +48,26 @@ The Browser screen exposes two sections: - enable/disable CCS-managed browser tooling for Codex-target launches - review whether the detected Codex build supports managed browser overrides +Browser policy controls are CLI-first in this release. The dashboard remains the shared setup and +status surface, while `ccs browser policy` is the authoritative place to decide whether browser +tooling is auto-exposed or kept manual by default. Fresh installs, plus upgrades without an +existing browser section, surface both lanes as off/manual until you explicitly enable them. + ### Via CLI ```bash ccs help browser +ccs browser setup ccs browser status ccs browser doctor +ccs browser policy +ccs browser policy --all manual ``` -Use `ccs browser 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 @@ -65,17 +77,46 @@ Edit `~/.ccs/config.yaml`: browser: claude: enabled: false + policy: manual user_data_dir: "~/.ccs/browser/chrome-user-data" devtools_port: 9222 codex: - enabled: true + enabled: false + policy: manual ``` Notes: +- `claude.policy` and `codex.policy` accept `auto` or `manual` - `claude.user_data_dir` is a **Chrome user-data directory**, not a display-name browser profile - `claude.devtools_port` is the expected remote debugging port for attach mode - `codex.enabled` controls whether CCS injects browser tooling into Codex-target launches +- New installs, plus upgrades without saved browser settings, default both lanes to `enabled: false` and `policy: manual` +- `manual` keeps the lane configured but hidden until a launch explicitly opts in with `--browser` + +## Runtime Policy Controls + +CCS now separates **lane enablement** from **default exposure policy**: + +- `enabled: false` + - the lane is off; this is the default for both lanes on new installs and upgrades without saved browser settings +- `enabled: true` + `policy: auto` + - the lane is exposed automatically on matching launches +- `enabled: true` + `policy: manual` + - the lane stays configured, but CCS keeps browser tooling hidden unless the current launch uses + `--browser` + +One-run launch overrides: + +```bash +ccs browser policy --all manual +ccs glm --browser "inspect the page" +ccs glm --no-browser "summarize the docs" +ccs default --target codex --browser "use the browser tools for this run" +``` + +- `--browser` forces browser tooling on for the current launch when that lane is enabled +- `--no-browser` suppresses browser tooling for the current launch even when policy is `auto` ## Environment Variable Overrides @@ -90,6 +131,9 @@ CCS still supports environment-variable overrides for backward compatibility. 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` @@ -109,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. @@ -137,15 +198,15 @@ the remaining requirement is a running Chrome session started with `--remote-deb ### 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 @@ -155,9 +216,10 @@ automatically and now needs manual attention. CCS could not find usable DevTools attach metadata for the configured user-data directory. -1. Make sure Chrome was started with `--remote-debugging-port=` -2. Make sure it is using the same `user_data_dir` configured in CCS -3. Rerun `ccs browser doctor` +1. Run `ccs browser setup` +2. If needed, make sure Chrome was started with `--remote-debugging-port=` +3. Make sure it is using the same `user_data_dir` configured in CCS +4. Rerun `ccs browser doctor` For the CCS-managed default path, this is the normal first-run state after CCS bootstraps the directory for you. @@ -166,9 +228,10 @@ directory for you. 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 diff --git a/docs/code-standards.md b/docs/code-standards.md index 3c3b35cb..de84eebe 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -599,6 +599,7 @@ return ( bun run format bun run lint:fix bun run validate +bun run validate:ci-parity # UI project (if changed) cd ui @@ -611,7 +612,7 @@ bun run validate | Project | Command | Checks | |---------|---------|--------| -| Main | `bun run validate` | typecheck + lint + format:check + test | +| Main | `bun run validate` | typecheck + lint + format:check + test:fast | | UI | `bun run validate` | typecheck + lint + format:check | --- diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 71d2c34a..de9627cb 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -92,6 +92,20 @@ src/ │ ├── unified-config-loader.ts # Central config loader (546 lines) │ └── migration-manager.ts # Config migration logic │ +├── proxy/ # OpenAI-compatible proxy runtime +│ ├── index.ts # Barrel export +│ ├── proxy-daemon-entry.ts # Daemon entrypoint +│ ├── proxy-daemon.ts # Lifecycle, health, and port binding +│ ├── proxy-port-resolver.ts # Adaptive per-profile port selection +│ ├── request-router.ts # Request-time profile/model routing +│ ├── profile-router.ts # Profile resolution helpers +│ ├── proxy-env.ts # Local runtime env construction +│ ├── routing-config.ts # Proxy routing config parsing +│ ├── upstream-url.ts # Upstream endpoint resolution +│ ├── proxy-daemon-state.ts # Persistent running-state metadata +│ ├── server/ # HTTP server and routes +│ └── transformers/ # Request and SSE translation +│ ├── channels/ # Official Claude channel integration │ ├── official-channels-runtime.ts # Runtime gating, plugin specs, setup guidance │ └── official-channels-store.ts # Claude channel token/env storage helpers @@ -216,6 +230,7 @@ src/ | Targets | `bin/`, `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, Codex CLI, extensible) | | Auth | `auth/`, `cliproxy/auth/` | Authentication across providers | | Config | `config/`, `types/` | Configuration & type definitions | +| OpenAI Proxy | `proxy/` | Adaptive local OpenAI-compatible proxy runtime, profile routing, and SSE transforms | | Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations plus retained legacy transformer internals | | Quota | `cliproxy/quota-*.ts`, `account-manager.ts` | Hybrid quota management (v7.14) | | Remote Proxy | `cliproxy/remote-*.ts`, `proxy-config-resolver.ts` | Remote CLIProxy support (v7.1) | diff --git a/docs/openai-compatible-providers.md b/docs/openai-compatible-providers.md index 4478e58d..a874d227 100644 --- a/docs/openai-compatible-providers.md +++ b/docs/openai-compatible-providers.md @@ -32,7 +32,7 @@ When to use CCS: When you launch a compatible settings profile with the Claude target, CCS now: -1. Starts a local proxy on `127.0.0.1` +1. Starts a local proxy on `127.0.0.1` using the resolved local port for that profile 2. Accepts Anthropic `/v1/messages` traffic from Claude Code 3. Translates requests into OpenAI chat-completions format 4. Forwards them to your configured upstream provider @@ -72,9 +72,24 @@ Useful variants: ```bash ccs proxy start hf --host 127.0.0.1 +ccs proxy start hf --port 3460 +ccs proxy activate hf ccs proxy activate --fish +ccs proxy status hf +ccs proxy stop hf ``` +Port selection precedence is: + +1. CLI `--port` for an exact one-off pin +2. `proxy.profile_ports[profile]` for an exact per-profile pin +3. `proxy.port` for a shared preferred starting port +4. adaptive per-profile fallback when nothing is pinned + +Legacy shared `proxy.port: 3456` values are treated as unset so older configs +move onto the adaptive path instead of staying on the hot legacy default. If +you need an exact `3456` binding now, pin it via `--port` or `proxy.profile_ports`. + `ccs proxy activate` now prints the full local runtime contract: - `ANTHROPIC_BASE_URL` @@ -85,16 +100,28 @@ 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` +- `status` and `activate` always reflect the actual running port instead of an + assumed default -This is intentional to avoid breaking an in-flight Claude session by swapping -its upstream provider out from under it. +If you want to pin or guide ports explicitly, configure them in `~/.ccs/config.yaml`: + +```yaml +proxy: + port: 45000 + profile_ports: + hf: 3460 + openai: 3461 +``` ## Request-Time Routing @@ -288,10 +315,12 @@ That flag is respected by both: - Add `CCS_OPENAI_PROXY_INSECURE=1` to the profile settings - Restart the proxy after changing the setting -### Port conflict on `3456` +### Need to pin or verify the local port -- Start with a fixed port: `ccs proxy start hf --port 3457` -- Re-run `ccs proxy activate` after changing the port +- Check the active binding with `ccs proxy status hf` +- Pin a one-off port with `ccs proxy start hf --port 3460` +- Reserve a stable profile port with `proxy.profile_ports` +- Re-run `ccs proxy activate hf` after changing the port ### Provider returns `429` or empty upstream output diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index b9b0ba3a..a07dcae8 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-18 +Last Updated: 2026-04-21 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,11 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-24**: **#1065** Local CLIProxy Plus is available again as an explicit opt-in backend through the community-maintained `kaitranntt/CLIProxyAPIPlus` fork. CCS keeps `original` as the default backend, no longer downgrades saved `backend: plus` configs to `original`, updates Plus release lookups to the maintained fork, and documents Plus as a targeted path for plus-only providers. +- **2026-04-21**: CLIProxy quota failover now quarantines exhausted Claude and Antigravity accounts out of live rotation when a healthy fallback exists. CCS persists those quota-triggered pauses across launches, automatically resumes them after the configured cooldown window, and deliberately avoids auto-pausing the last available account so single-account setups still degrade gracefully instead of hard-locking themselves. +- **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in. +- **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser..policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config. +- **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default. - **2026-04-18**: **#1038** Legacy OpenAI-compatible provider writes no longer self-destruct on the next `ccs cliproxy restart`. CCS now preserves AI-provider-managed top-level sections such as `openai-compatibility` during CLIProxy config regeneration, and the legacy `openai-compat` manager now rewrites only its own YAML section instead of dumping the whole file and stripping the generated version header. Regression coverage now proves the legacy helper keeps the generated header intact and that OpenAI-compatible connectors survive regeneration. - **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor. - **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads. @@ -238,28 +243,13 @@ All criteria achieved: - [x] Clear domain boundaries - [x] Consistent naming conventions -## Maintainability Gate (Issue #539 Foundation) +## Historical Maintainability Gate (Retired) -- Baseline metrics artifact: `docs/metrics/maintainability-baseline.json` -- Branch-aware gate wrapper: `scripts/maintainability-check.js` -- Generate or refresh baseline: - - `bun run maintainability:baseline` - - `npm run maintainability:baseline` -- Run regression check gate: - - `bun run maintainability:check` - - `npm run maintainability:check` - - `bun run maintainability:check:strict` (force strict locally) +This section is preserved as historical context from the original Issue `#539` work. -The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable. - -Default gate behavior: -- strict mode on protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) -- warning mode on PR CI and non-protected branches (parallel PR friendly) - -The check mode supports a maintainability regression gate that blocks increases in: -- `process.exit` references -- synchronous fs API references -- TypeScript files over 350 LOC +- The maintainability baseline gate is no longer part of the active CCS workflow. +- Current contributor and CI gates are documented in `CLAUDE.md`, `CONTRIBUTING.md`, and the GitHub workflow files. +- Do not assume `maintainability:baseline` or `maintainability:check` exist unless they are reintroduced in a future follow-up. --- diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index eb19fd6f..9caf91c7 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -12,6 +12,10 @@ Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibi CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with multiple AI providers. CCS manages the binary and configuration automatically. +### Local Backend Choice + +CCS defaults to the original `router-for-me/CLIProxyAPI` backend because it is the stable MIT upstream. The `plus` backend is an explicit opt-in path that downloads the community-maintained `kaitranntt/CLIProxyAPIPlus` fork for providers that still require Plus-only support, such as Kiro, GitHub Copilot, Cursor, GitLab, CodeBuddy, and Kilo. CCS does not silently downgrade `backend: plus` to `original`; users choose that backend deliberately when they need those providers. + ``` +===========================================================================+ | CLIProxyAPI Integration | @@ -22,7 +26,7 @@ CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with | ANTHROPIC_BASE_URL = localhost:XXXX v +------------------+ - | CLIProxyAPI | Local proxy binary (CLIProxyAPIPlus for kiro/ghcp) + | CLIProxyAPI | Local proxy binary (Plus fork opt-in for plus-only providers) | (binary) | +------------------+ | @@ -73,8 +77,8 @@ CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with | Gemini | `gemini` | Authorization Code | 9876 | CLIProxyAPI | | Codex | `codex` | Authorization Code | 9876 | CLIProxyAPI | | Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI | -| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus | -| GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus | +| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork | +| GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus fork | ### Codex Duplicate-Email Account Identity @@ -257,6 +261,7 @@ async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise Select best account (not paused, not exhausted) | +---> Auto-failover to next account if current exhausted + | + +---> Temporarily pause exhausted account when fallback exists + | - move token out of live auth discovery + | - persist cooldown expiry across launches + | - auto-resume only CCS-created quota pauses CLI Commands: ccs cliproxy pause --> Set isPaused=true in account-manager diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 70a0b654..1cc92e38 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -15,9 +15,12 @@ function loadWebSocketImplementation() { } try { - const { WebSocket } = require('ws'); - if (typeof WebSocket === 'function') { - return WebSocket; + const wsModule = require('ws'); + if (typeof wsModule === 'function') { + return wsModule; + } + if (typeof wsModule?.WebSocket === 'function') { + return wsModule.WebSocket; } } catch { // Surface a dedicated error below if no implementation is available. @@ -29,6 +32,9 @@ function loadWebSocketImplementation() { } const WebSocket = loadWebSocketImplementation(); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const PROTOCOL_VERSION = '2024-11-05'; const SERVER_NAME = 'ccs-browser'; @@ -40,7 +46,43 @@ const TOOL_DOM_SNAPSHOT = 'browser_get_dom_snapshot'; const TOOL_NAVIGATE = 'browser_navigate'; const TOOL_CLICK = 'browser_click'; const TOOL_TYPE = 'browser_type'; +const TOOL_PRESS_KEY = 'browser_press_key'; +const TOOL_SCROLL = 'browser_scroll'; +const TOOL_SELECT_PAGE = 'browser_select_page'; +const TOOL_OPEN_PAGE = 'browser_open_page'; +const TOOL_CLOSE_PAGE = 'browser_close_page'; +const TOOL_ADD_INTERCEPT_RULE = 'browser_add_intercept_rule'; +const TOOL_REMOVE_INTERCEPT_RULE = 'browser_remove_intercept_rule'; +const TOOL_LIST_INTERCEPT_RULES = 'browser_list_intercept_rules'; +const TOOL_LIST_REQUESTS = 'browser_list_requests'; +const TOOL_SET_DOWNLOAD_BEHAVIOR = 'browser_set_download_behavior'; +const TOOL_LIST_DOWNLOADS = 'browser_list_downloads'; +const TOOL_CANCEL_DOWNLOAD = 'browser_cancel_download'; +const TOOL_SET_FILE_INPUT = 'browser_set_file_input'; +const TOOL_DRAG_FILES = 'browser_drag_files'; +const TOOL_DRAG_ELEMENT = 'browser_drag_element'; +const TOOL_POINTER_ACTION = 'browser_pointer_action'; +const TOOL_START_RECORDING = 'browser_start_recording'; +const TOOL_STOP_RECORDING = 'browser_stop_recording'; +const TOOL_GET_RECORDING = 'browser_get_recording'; +const TOOL_CLEAR_RECORDING = 'browser_clear_recording'; +const TOOL_START_REPLAY = 'browser_start_replay'; +const TOOL_GET_REPLAY = 'browser_get_replay'; +const TOOL_CANCEL_REPLAY = 'browser_cancel_replay'; +const TOOL_START_ORCHESTRATION = 'browser_start_orchestration'; +const TOOL_GET_ORCHESTRATION = 'browser_get_orchestration'; +const TOOL_CANCEL_ORCHESTRATION = 'browser_cancel_orchestration'; +const TOOL_EXPORT_ARTIFACT = 'browser_export_artifact'; +const TOOL_IMPORT_ARTIFACT = 'browser_import_artifact'; +const TOOL_LIST_ARTIFACTS = 'browser_list_artifacts'; +const TOOL_DELETE_ARTIFACT = 'browser_delete_artifact'; const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot'; +const TOOL_WAIT_FOR = 'browser_wait_for'; +const TOOL_EVAL = 'browser_eval'; +const TOOL_HOVER = 'browser_hover'; +const TOOL_QUERY = 'browser_query'; +const TOOL_TAKE_ELEMENT_SCREENSHOT = 'browser_take_element_screenshot'; +const TOOL_WAIT_FOR_EVENT = 'browser_wait_for_event'; const TOOL_NAMES = [ TOOL_SESSION_INFO, TOOL_URL_TITLE, @@ -49,13 +91,95 @@ const TOOL_NAMES = [ TOOL_NAVIGATE, TOOL_CLICK, TOOL_TYPE, + TOOL_PRESS_KEY, + TOOL_SCROLL, + TOOL_SELECT_PAGE, + TOOL_OPEN_PAGE, + TOOL_CLOSE_PAGE, + TOOL_ADD_INTERCEPT_RULE, + TOOL_REMOVE_INTERCEPT_RULE, + TOOL_LIST_INTERCEPT_RULES, + TOOL_LIST_REQUESTS, + TOOL_SET_DOWNLOAD_BEHAVIOR, + TOOL_LIST_DOWNLOADS, + TOOL_CANCEL_DOWNLOAD, + TOOL_SET_FILE_INPUT, + TOOL_DRAG_FILES, + TOOL_DRAG_ELEMENT, + TOOL_POINTER_ACTION, + TOOL_START_RECORDING, + TOOL_STOP_RECORDING, + TOOL_GET_RECORDING, + TOOL_CLEAR_RECORDING, + TOOL_START_REPLAY, + TOOL_GET_REPLAY, + TOOL_CANCEL_REPLAY, + TOOL_START_ORCHESTRATION, + TOOL_GET_ORCHESTRATION, + TOOL_CANCEL_ORCHESTRATION, + TOOL_EXPORT_ARTIFACT, + TOOL_IMPORT_ARTIFACT, + TOOL_LIST_ARTIFACTS, + TOOL_DELETE_ARTIFACT, TOOL_TAKE_SCREENSHOT, + TOOL_WAIT_FOR, + TOOL_EVAL, + TOOL_HOVER, + TOOL_QUERY, + TOOL_TAKE_ELEMENT_SCREENSHOT, + TOOL_WAIT_FOR_EVENT, ]; +const SUPPORTED_QUERY_FIELDS = [ + 'exists', + 'count', + 'innerText', + 'textContent', + 'boundingClientRect', + 'display', + 'visibility', + 'opacity', + 'href', + 'onclick', +]; +const DEFAULT_QUERY_FIELDS = [...SUPPORTED_QUERY_FIELDS]; +const SUPPORTED_QUERY_FIELD_SET = new Set(SUPPORTED_QUERY_FIELDS); const CDP_TIMEOUT_MS = 5000; const NAVIGATION_POLL_INTERVAL_MS = 100; +const DEFAULT_WAIT_TIMEOUT_MS = 2000; +const DEFAULT_WAIT_POLL_INTERVAL_MS = 100; +const DEFAULT_DRAG_STEPS = 5; +const MAX_POINTER_ACTIONS = 25; +const SESSION_START_SETTLE_WINDOW_MS = 250; +const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024; +const SAFE_ARTIFACT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const SESSION_CANCELED_ERROR_CODE = 'SESSION_CANCELED'; let inputBuffer = Buffer.alloc(0); let requestCounter = 0; +let selectedPageId = ''; +let messageQueue = Promise.resolve(); +let nextInterceptRuleCounter = 1; +let nextDownloadCounter = 1; +let nextRecordingCounter = 1; +let activeRecordingSession = null; +let latestRecordingSession = null; +let nextReplayCounter = 1; +let activeReplaySession = null; +let activeReplayTask = null; +let latestReplaySession = null; +let nextOrchestrationCounter = 1; +let activeOrchestrationSession = null; +let activeOrchestrationTask = null; +let latestOrchestrationSession = null; +const interceptRules = []; +const recentRequests = []; +const recentDownloads = []; +const interceptSessionsByPageId = new Map(); +let browserDownloadSession = null; +let sessionDownloadDir = ''; +const MAX_RECENT_REQUESTS = 100; +const MAX_RECENT_DOWNLOADS = 100; +const FETCH_FAIL_ERROR_REASON = 'Failed'; function addSocketListener(socket, eventName, handler) { if (typeof socket.addEventListener === 'function') { @@ -133,6 +257,21 @@ function writeError(id, code, message) { writeMessage({ jsonrpc: '2.0', id, error: { code, message } }); } +function getAvailableTools() { + const tools = getTools(); + if (getBrowserEvalMode() === 'disabled') { + return tools.filter((tool) => tool.name !== TOOL_EVAL); + } + return tools; +} + +function getAvailableToolNames() { + if (getBrowserEvalMode() === 'disabled') { + return TOOL_NAMES.filter((toolName) => toolName !== TOOL_EVAL); + } + return [...TOOL_NAMES]; +} + function getTools() { if (!shouldExposeTools()) { return []; @@ -221,7 +360,7 @@ function getTools() { { name: TOOL_CLICK, description: - 'Click the first element matching a CSS selector in the selected page using a minimal mouse event chain with click fallback. Optionally choose a page by index.', + 'Click the first element matching a CSS selector in the selected page using a minimal mouse event chain with click fallback. Optionally choose a page by index and match index.', inputSchema: { type: 'object', properties: { @@ -234,6 +373,37 @@ function getTools() { type: 'string', description: 'Required CSS selector for the element to click.', }, + nth: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based match index for selectors returning multiple elements.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + offsetX: { + type: 'number', + description: "Optional horizontal offset in CSS pixels from the target element's left edge.", + }, + offsetY: { + type: 'number', + description: "Optional vertical offset in CSS pixels from the target element's top edge.", + }, + button: { + type: 'string', + enum: ['left', 'middle', 'right'], + description: 'Optional mouse button. Defaults to left.', + }, + clickCount: { + type: 'integer', + minimum: 1, + description: 'Optional click count. Defaults to 1.', + }, }, required: ['selector'], additionalProperties: false, @@ -268,6 +438,484 @@ function getTools() { additionalProperties: false, }, }, + { + name: TOOL_PRESS_KEY, + description: + 'Press a key or key combination in the selected page using real keyboard-style events. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + key: { + type: 'string', + description: 'Required primary key to press.', + }, + modifiers: { + type: 'array', + items: { + type: 'string', + enum: ['Alt', 'Control', 'Meta', 'Shift'], + }, + description: 'Optional modifier keys such as Alt, Control, Meta, or Shift.', + }, + repeat: { + type: 'integer', + minimum: 1, + description: 'Optional repeat count. Defaults to 1.', + }, + }, + required: ['key'], + additionalProperties: false, + }, + }, + { + name: TOOL_SCROLL, + description: + 'Scroll the selected page or a matched element. Supports explicit deltas or scrolling an element into view.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Optional CSS selector for an element-scoped scroll target.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + behavior: { + type: 'string', + enum: ['into-view', 'by-offset'], + description: 'Required scroll behavior.', + }, + deltaX: { + type: 'number', + description: 'Optional horizontal scroll delta for by-offset behavior.', + }, + deltaY: { + type: 'number', + description: 'Optional vertical scroll delta for by-offset behavior.', + }, + }, + required: ['behavior'], + additionalProperties: false, + }, + }, + { + name: TOOL_SELECT_PAGE, + description: 'Select the current page target by page index or page id for subsequent browser tool calls.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_OPEN_PAGE, + description: 'Open a new browser page tab and optionally navigate it to a URL.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_CLOSE_PAGE, + description: 'Close a browser page target by page index or page id. Defaults to the selected page.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_ADD_INTERCEPT_RULE, + description: 'Add a session-local interception rule bound to a concrete page target.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + urlIncludes: { type: 'string' }, + method: { type: 'string' }, + resourceType: { type: 'string' }, + urlPattern: { type: 'string' }, + urlRegex: { type: 'string' }, + headerMatchers: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + valueIncludes: { type: 'string' }, + valueRegex: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + priority: { type: 'integer' }, + action: { type: 'string', enum: ['continue', 'fail', 'fulfill'] }, + statusCode: { type: 'integer', minimum: 100, maximum: 599 }, + responseHeaders: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + value: { type: 'string' }, + }, + required: ['name', 'value'], + additionalProperties: false, + }, + }, + body: { type: 'string' }, + contentType: { type: 'string' }, + }, + required: ['action'], + additionalProperties: false, + }, + }, + { + name: TOOL_REMOVE_INTERCEPT_RULE, + description: 'Remove a session-local interception rule by rule id.', + inputSchema: { + type: 'object', + properties: { + ruleId: { type: 'string' }, + }, + required: ['ruleId'], + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_INTERCEPT_RULES, + description: 'List current session-local interception rules.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_REQUESTS, + description: 'List recent intercepted request summaries for the current session.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + limit: { type: 'integer', minimum: 1 }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_SET_DOWNLOAD_BEHAVIOR, + description: 'Set browser-scoped download behavior for the current attach session.', + inputSchema: { + type: 'object', + properties: { + behavior: { type: 'string', enum: ['accept', 'deny'] }, + downloadPath: { type: 'string' }, + eventsEnabled: { type: 'boolean' }, + }, + required: ['behavior'], + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_DOWNLOADS, + description: 'List recent browser-scoped download summaries recorded in this MCP session.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1 }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_CANCEL_DOWNLOAD, + description: 'Cancel an in-progress download by downloadId or guid.', + inputSchema: { + type: 'object', + properties: { + downloadId: { type: 'string' }, + guid: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_SET_FILE_INPUT, + description: + 'Set local files on a file input element in the selected page. Supports selected-page routing plus same-origin frame and open shadow-root scoping.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string' }, + files: { + type: 'array', + items: { type: 'string' }, + }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + nth: { type: 'integer', minimum: 0 }, + frameSelector: { type: 'string' }, + pierceShadow: { type: 'boolean' }, + }, + required: ['selector', 'files'], + additionalProperties: false, + }, + }, + { + name: TOOL_DRAG_FILES, + description: + 'Drag one or more local files onto a matched page drop target using page-side File and DataTransfer injection.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string' }, + files: { + type: 'array', + items: { type: 'string' }, + }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + nth: { type: 'integer', minimum: 0 }, + frameSelector: { type: 'string' }, + pierceShadow: { type: 'boolean' }, + }, + required: ['selector', 'files'], + additionalProperties: false, + }, + }, + { + name: TOOL_DRAG_ELEMENT, + description: + 'Drag a matched element to another matched element or explicit coordinates using browser mouse events.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string' }, + targetSelector: { type: 'string' }, + targetX: { type: 'number' }, + targetY: { type: 'number' }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + nth: { type: 'integer', minimum: 0 }, + targetNth: { type: 'integer', minimum: 0 }, + frameSelector: { type: 'string' }, + pierceShadow: { type: 'boolean' }, + steps: { type: 'integer', minimum: 1 }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_POINTER_ACTION, + description: 'Run a limited pointer action sequence using browser mouse events.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + actions: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['move', 'down', 'up', 'pause'] }, + selector: { type: 'string' }, + nth: { type: 'integer', minimum: 0 }, + frameSelector: { type: 'string' }, + pierceShadow: { type: 'boolean' }, + x: { type: 'number' }, + y: { type: 'number' }, + button: { type: 'string', enum: ['left', 'middle', 'right'] }, + durationMs: { type: 'integer', minimum: 0 }, + }, + required: ['type'], + additionalProperties: false, + }, + }, + }, + required: ['actions'], + additionalProperties: false, + }, + }, + { + name: TOOL_START_RECORDING, + description: 'Start recording real browser interactions on the selected page and store them as structured steps.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_STOP_RECORDING, + description: 'Stop the active browser recording session and keep the recorded result in session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_GET_RECORDING, + description: 'Read the current browser recording result from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_CLEAR_RECORDING, + description: 'Clear the current browser recording result from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_START_REPLAY, + description: 'Start replaying a sequence of structured Browser MCP steps on the selected page.', + inputSchema: { + type: 'object', + properties: { + steps: { type: 'array', items: { type: 'object' } }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + required: ['steps'], + additionalProperties: false, + }, + }, + { + name: TOOL_GET_REPLAY, + description: 'Read the current replay status and result summary from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_CANCEL_REPLAY, + description: 'Cancel the active replay session and keep the summary in session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_START_ORCHESTRATION, + description: 'Start an orchestration session that runs fixed browser workflow blocks on the selected page.', + inputSchema: { + type: 'object', + properties: { + blocks: { type: 'array', items: { type: 'object' } }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + required: ['blocks'], + additionalProperties: false, + }, + }, + { + name: TOOL_GET_ORCHESTRATION, + description: 'Read the current orchestration status and result summary from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_CANCEL_ORCHESTRATION, + description: 'Cancel the active orchestration session and keep the summary in session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_EXPORT_ARTIFACT, + description: 'Export the current recording, replay, or orchestration artifact to a local JSON file.', + inputSchema: { + type: 'object', + properties: { + kind: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['kind', 'name'], + additionalProperties: false, + }, + }, + { + name: TOOL_IMPORT_ARTIFACT, + description: 'Import a recording, replay, or orchestration artifact from a local JSON file into session-local state.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_ARTIFACTS, + description: 'List locally saved Browser MCP artifacts.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_DELETE_ARTIFACT, + description: 'Delete a locally saved Browser MCP artifact by name.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, { name: TOOL_TAKE_SCREENSHOT, description: @@ -288,6 +936,202 @@ function getTools() { additionalProperties: false, }, }, + { + name: TOOL_WAIT_FOR, + description: + 'Poll until a selector-scoped or page-level condition is satisfied. Supports selector existence/visibility/text waits and page text waits.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Optional CSS selector for selector-scoped waiting.', + }, + nth: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based match index for selectors returning multiple elements.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + timeoutMs: { + type: 'integer', + minimum: 1, + description: 'Optional timeout in milliseconds.', + }, + pollIntervalMs: { + type: 'integer', + minimum: 1, + description: 'Optional polling interval in milliseconds.', + }, + condition: { + type: 'object', + description: 'Required wait condition.', + }, + }, + required: ['condition'], + additionalProperties: false, + }, + }, + { + name: TOOL_EVAL, + description: + 'Evaluate page-side JavaScript for inspection or mutation, gated by CCS_BROWSER_EVAL_MODE.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + expression: { + type: 'string', + description: 'Required JavaScript expression to evaluate in the page.', + }, + mode: { + type: 'string', + enum: ['readonly', 'readwrite'], + description: 'Optional evaluation mode. Defaults to readonly.', + }, + }, + required: ['expression'], + additionalProperties: false, + }, + }, + { + name: TOOL_HOVER, + description: + 'Move the browser mouse pointer onto the first element matching a CSS selector in the selected page to trigger hover state. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the hover target.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_QUERY, + description: + 'Return diagnostic state for selector-matched elements in the selected page. Optionally choose a page by index, zero-based match index, and a subset of fields.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the query target.', + }, + nth: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based match index for selectors returning multiple elements.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + fields: { + type: 'array', + items: { type: 'string' }, + description: 'Optional list of diagnostic fields to return.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_TAKE_ELEMENT_SCREENSHOT, + description: + 'Capture a PNG screenshot clipped to the first element matching a CSS selector in the selected page. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the screenshot target.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_WAIT_FOR_EVENT, + description: 'Wait until a page or browser event matching the requested filter is observed.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + timeoutMs: { + type: 'integer', + minimum: 1, + description: 'Optional timeout in milliseconds.', + }, + event: { + type: 'object', + description: 'Required event selector.', + }, + }, + required: ['event'], + additionalProperties: false, + }, + }, ]; } @@ -337,30 +1181,381 @@ function parsePageIndex(toolArgs) { return toolArgs.pageIndex; } +function findPageIndexById(pages, pageId) { + return pages.findIndex((page) => page.id === pageId); +} + +function resolveFallbackSelectedPageId(pages, preferredIndex = 0) { + if (pages.length === 0) { + return ''; + } + const safeIndex = Math.min(Math.max(preferredIndex, 0), pages.length - 1); + return pages[safeIndex]?.id || pages[0]?.id || ''; +} + +function parseOptionalPageId(toolArgs) { + return typeof toolArgs?.pageId === 'string' && toolArgs.pageId.trim() !== '' + ? toolArgs.pageId.trim() + : ''; +} + +function parseInterceptAction(value) { + if (value !== 'continue' && value !== 'fail' && value !== 'fulfill') { + throw new Error('action must be one of: continue, fail, fulfill'); + } + return value; +} + +function parseOptionalStatusCode(value) { + if (value === undefined) { + return 200; + } + if (!Number.isInteger(value) || value < 100 || value > 599) { + throw new Error('statusCode must be an integer between 100 and 599'); + } + return value; +} + +function parseOptionalResponseHeaders(value, contentType) { + const headers = []; + if (Array.isArray(value)) { + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + throw new Error('responseHeaders entries must be objects with name and value'); + } + const name = requireNonEmptyString(entry.name, 'responseHeaders.name'); + const headerValue = String(entry.value ?? ''); + headers.push({ name, value: headerValue }); + } + } else if (value !== undefined) { + throw new Error('responseHeaders must be an array'); + } + if (contentType && !headers.some((header) => header.name.toLowerCase() === 'content-type')) { + headers.push({ name: 'Content-Type', value: contentType }); + } + return headers; +} + +function parseOptionalBody(value) { + if (value === undefined) { + return ''; + } + if (typeof value !== 'string') { + throw new Error('body must be a string'); + } + return value; +} + +function encodeFulfillBody(body) { + return Buffer.from(body, 'utf8').toString('base64'); +} + +function parseOptionalMethod(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'method').toUpperCase(); +} + +function parseOptionalUrlIncludes(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'urlIncludes'); +} + +function parseOptionalResourceType(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'resourceType'); +} + +function parseOptionalUrlPattern(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'urlPattern'); +} + +function parseOptionalUrlRegex(value) { + if (value === undefined) { + return ''; + } + const raw = requireNonEmptyString(value, 'urlRegex'); + try { + new RegExp(raw); + } catch { + throw new Error('urlRegex must be a valid regular expression'); + } + return raw; +} + +function parseOptionalPriority(value) { + if (value === undefined) { + return 0; + } + if (!Number.isInteger(value)) { + throw new Error('priority must be an integer'); + } + return value; +} + +function parseOptionalHeaderMatchers(value) { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error('headerMatchers must be an array'); + } + return value.map((entry) => { + if (!entry || typeof entry !== 'object') { + throw new Error('headerMatchers entries must be objects'); + } + const name = requireNonEmptyString(entry.name, 'headerMatchers.name'); + const valueIncludes = + entry.valueIncludes === undefined ? '' : requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes'); + const valueRegex = + entry.valueRegex === undefined ? '' : requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex'); + if (!valueIncludes && !valueRegex) { + throw new Error('headerMatchers entry must include valueIncludes or valueRegex'); + } + if (valueRegex) { + try { + new RegExp(valueRegex); + } catch { + throw new Error('headerMatchers.valueRegex must be a valid regular expression'); + } + } + return { name, valueIncludes, valueRegex }; + }); +} + +function validateInterceptMatcherSet({ + urlIncludes, + method, + resourceType, + urlPattern, + urlRegex, + headerMatchers, +}) { + if (urlPattern && urlRegex) { + throw new Error('urlPattern and urlRegex cannot be used together'); + } + if (!urlIncludes && !method && !resourceType && !urlPattern && !urlRegex && headerMatchers.length === 0) { + throw new Error('at least one matching condition is required'); + } +} + +function createInterceptRuleId() { + return `rule-${nextInterceptRuleCounter++}`; +} + +function removeInterceptStateForPage(pageId) { + for (let index = interceptRules.length - 1; index >= 0; index -= 1) { + if (interceptRules[index].pageId === pageId) { + interceptRules.splice(index, 1); + } + } + for (let index = recentRequests.length - 1; index >= 0; index -= 1) { + if (recentRequests[index].pageId === pageId) { + recentRequests.splice(index, 1); + } + } + const interceptSession = interceptSessionsByPageId.get(pageId); + if (interceptSession) { + interceptSessionsByPageId.delete(pageId); + } +} + +function pushRecentRequest(entry) { + recentRequests.push(entry); + if (recentRequests.length > MAX_RECENT_REQUESTS) { + recentRequests.splice(0, recentRequests.length - MAX_RECENT_REQUESTS); + } +} + +function getSessionDownloadPath() { + if (!sessionDownloadDir) { + sessionDownloadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-downloads-')); + } + return sessionDownloadDir; +} + +function ensureWritableDirectory(downloadPath) { + fs.mkdirSync(downloadPath, { recursive: true }); + fs.accessSync(downloadPath, fs.constants.W_OK); + return downloadPath; +} + +function pushRecentDownload(entry) { + recentDownloads.push(entry); + if (recentDownloads.length > MAX_RECENT_DOWNLOADS) { + recentDownloads.splice(0, recentDownloads.length - MAX_RECENT_DOWNLOADS); + } +} + +function updateRecentDownload(guid, patch) { + const record = recentDownloads.find((candidate) => candidate.guid === guid); + if (!record) { + return null; + } + Object.assign(record, patch); + return record; +} + +function resolveDownloadStatus(progressState) { + if (progressState === 'completed') { + return 'completed'; + } + if (progressState === 'canceled') { + return 'canceled'; + } + return 'inProgress'; +} + +function formatInterceptRules(rules) { + if (rules.length === 0) { + return 'status: empty'; + } + return rules + .map((rule) => + [ + `ruleId: ${rule.ruleId}`, + `pageId: ${rule.pageId}`, + `pageTitle: ${rule.pageTitleSnapshot || ''}`, + `urlIncludes: ${rule.urlIncludes || ''}`, + `method: ${rule.method || ''}`, + `resourceType: ${rule.resourceType || ''}`, + `urlPattern: ${rule.urlPattern || ''}`, + `urlRegex: ${rule.urlRegex || ''}`, + `headerMatchers: ${Array.isArray(rule.headerMatchers) ? rule.headerMatchers.length : 0}`, + `priority: ${Number.isInteger(rule.priority) ? rule.priority : 0}`, + `action: ${rule.action}`, + `statusCode: ${rule.action === 'fulfill' ? rule.statusCode : 'n/a'}`, + `contentType: ${rule.action === 'fulfill' ? rule.contentType || '' : 'n/a'}`, + ].join('\n') + ) + .join('\n---\n'); +} + +function formatRecentRequests(entries) { + if (entries.length === 0) { + return 'status: empty'; + } + return entries + .map((entry) => + [ + `requestId: ${entry.requestId}`, + `pageId: ${entry.pageId}`, + `url: ${entry.url}`, + `method: ${entry.method}`, + `resourceType: ${entry.resourceType || ''}`, + `matchedRuleId: ${entry.matchedRuleId || 'none'}`, + `action: ${entry.action}`, + `statusCode: ${entry.action === 'fulfill' ? entry.statusCode : 'n/a'}`, + ].join('\n') + ) + .join('\n---\n'); +} + +function formatRecentDownloads(entries) { + if (entries.length === 0) { + return 'status: empty'; + } + return entries + .map((entry) => + [ + `downloadId: ${entry.downloadId}`, + `guid: ${entry.guid}`, + `pageId: ${entry.pageId || ''}`, + `url: ${entry.url}`, + `suggestedFilename: ${entry.suggestedFilename}`, + `status: ${entry.status}`, + `savedPath: ${entry.savedPath || ''}`, + `startedAt: ${entry.startedAt}`, + `finishedAt: ${entry.finishedAt || ''}`, + ].join('\n') + ) + .join('\n---\n'); +} + +function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId, options = {}) { + const hasPageIndex = toolArgs && Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex'); + const pageId = parseOptionalPageId(toolArgs); + const allowImplicitFallback = options.allowImplicitFallback !== false; + if (hasPageIndex && pageId) { + throw new Error('pageIndex and pageId cannot be used together'); + } + if (hasPageIndex) { + const pageIndex = parsePageIndex(toolArgs); + const page = pages[pageIndex]; + if (!page) { + throw new Error(`pageIndex out of range: ${pageIndex}`); + } + return { page, pageIndex }; + } + if (pageId) { + const pageIndex = findPageIndexById(pages, pageId); + if (pageIndex === -1) { + throw new Error(`page not found: ${pageId}`); + } + return { page: pages[pageIndex], pageIndex }; + } + const selectedIndex = findPageIndexById(pages, defaultSelectedId); + if (selectedIndex === -1) { + if (!allowImplicitFallback && defaultSelectedId) { + throw new Error('Selected page is no longer available; specify pageIndex or pageId explicitly.'); + } + const fallbackPage = pages[0]; + if (!fallbackPage) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + return { page: fallbackPage, pageIndex: 0 }; + } + return { page: pages[selectedIndex], pageIndex: selectedIndex }; +} + async function getSelectedPage(toolArgs) { const pages = await listPageTargets(); if (pages.length === 0) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const pageIndex = parsePageIndex(toolArgs); + if (toolArgs && Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex')) { + const pageIndex = parsePageIndex(toolArgs); + const page = pages[pageIndex]; + if (!page) { + throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`); + } + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + return { page, pageIndex, pages }; + } + + let pageIndex = findPageIndexById(pages, selectedPageId); + if (pageIndex === -1) { + selectedPageId = resolveFallbackSelectedPageId(pages, 0); + pageIndex = findPageIndexById(pages, selectedPageId); + } const page = pages[pageIndex]; - if (!page) { - throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`); - } - if (!page.webSocketDebuggerUrl) { - throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + if (!page || !page.webSocketDebuggerUrl) { + throw new Error('Browser MCP could not resolve a selected page target.'); } return { page, pageIndex, pages }; } -function formatSessionInfo(pages) { +function formatSessionInfo(pages, selectedId) { return [ '[CCS Browser Session]', '', - ...pages.map((page, index) => `${index}. ${page.title || ''} | ${page.url || ''}`), + ...pages.map((page, index) => { + const selectedSuffix = page.id === selectedId ? ' | selected: true' : ''; + return `${index}. ${page.title || ''} | ${page.url || ''}${selectedSuffix}`; + }), ].join('\n'); } @@ -455,6 +1650,177 @@ async function sendCdpCommand(page, method, params = {}) { }); } +async function getBrowserTarget() { + const targets = await fetchJson(`${getHttpUrl()}/json/list`); + const browserTarget = Array.isArray(targets) + ? targets.find((target) => target && typeof target === 'object' && target.type === 'browser') + : null; + if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) { + throw new Error('browser-level download events are unavailable'); + } + return browserTarget; +} + +async function sendBrowserCdpCommand(method, params = {}) { + const browserTarget = await getBrowserTarget(); + const ws = new WebSocket(browserTarget.webSocketDebuggerUrl); + const requestId = ++requestCounter; + + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + settled = true; + abortSocket(ws); + reject(new Error('Browser MCP timed out waiting for a DevTools response.')); + } + }, CDP_TIMEOUT_MS); + + function settleError(error) { + if (settled) { + return; + } + clearTimeout(timer); + settled = true; + reject(toSocketError(error)); + } + + addSocketListener(ws, 'open', () => { + ws.send(JSON.stringify({ id: requestId, method, params })); + }); + + addSocketListener(ws, 'message', (data) => { + void (async () => { + const raw = await getSocketMessageText(data); + if (settled) { + return; + } + + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + + if (message.id !== requestId) { + return; + } + + clearTimeout(timer); + settled = true; + closeSocket(ws); + + if (message.error) { + reject(new Error(message.error.message || 'DevTools request failed.')); + return; + } + + resolve(message.result || null); + })().catch(settleError); + }); + + addSocketListener(ws, 'error', settleError); + addSocketListener(ws, 'close', () => { + if (!settled) { + settleError(new Error('Browser MCP lost the DevTools websocket connection.')); + } + }); + }); +} + +async function ensureBrowserDownloadSession() { + if (browserDownloadSession) { + await browserDownloadSession.ready; + return browserDownloadSession; + } + + const browserTarget = await getBrowserTarget(); + const ws = new WebSocket(browserTarget.webSocketDebuggerUrl); + let resolveReady; + let rejectReady; + let activityChain = Promise.resolve(); + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + browserDownloadSession = { + ws, + ready, + getLastActivity() { + return activityChain; + }, + }; + + addSocketListener(ws, 'open', () => { + if (resolveReady) { + resolveReady(); + resolveReady = null; + rejectReady = null; + } + }); + + addSocketListener(ws, 'message', (data) => { + const activity = (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + + if (message?.method === 'Browser.downloadWillBegin') { + pushRecentDownload({ + downloadId: `download-${nextDownloadCounter++}`, + guid: String(message.params?.guid || ''), + pageId: '', + url: String(message.params?.url || ''), + suggestedFilename: String(message.params?.suggestedFilename || ''), + status: 'started', + savedPath: '', + receivedBytes: 0, + totalBytes: 0, + startedAt: new Date().toISOString(), + finishedAt: '', + }); + return; + } + + if (message?.method === 'Browser.downloadProgress') { + const status = resolveDownloadStatus(String(message.params?.state || 'inProgress')); + updateRecentDownload(String(message.params?.guid || ''), { + status, + receivedBytes: Number(message.params?.receivedBytes || 0), + totalBytes: Number(message.params?.totalBytes || 0), + savedPath: typeof message.params?.filePath === 'string' ? message.params.filePath : '', + finishedAt: status === 'completed' || status === 'canceled' ? new Date().toISOString() : '', + }); + } + })(); + activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); + void activity.catch(() => {}); + }); + + addSocketListener(ws, 'close', () => { + if (rejectReady) { + rejectReady(new Error('Browser MCP lost the DevTools websocket connection.')); + } + browserDownloadSession = null; + }); + + addSocketListener(ws, 'error', (error) => { + if (rejectReady) { + rejectReady(toSocketError(error)); + } + browserDownloadSession = null; + }); + + await ready; + return browserDownloadSession; +} + async function evaluateInPage(page, kind) { const response = await sendCdpCommand(page, 'Runtime.evaluate', { expression: createEvaluateExpression(kind), @@ -491,6 +1857,140 @@ async function evaluateExpression(page, expression) { return typeof result.value === 'string' ? result.value : result.value ?? ''; } +async function withPageCommandSession(page, callback) { + const ws = new WebSocket(page.webSocketDebuggerUrl); + let resolveReady; + let rejectReady; + let settled = false; + const pendingCommands = new Map(); + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + function settleReadyError(error) { + if (rejectReady) { + rejectReady(error instanceof Error ? error : new Error(String(error))); + rejectReady = null; + resolveReady = null; + } + } + + function rejectPendingCommands(error) { + for (const pending of pendingCommands.values()) { + pending.reject(error instanceof Error ? error : new Error(String(error))); + } + pendingCommands.clear(); + } + + addSocketListener(ws, 'open', () => { + if (resolveReady) { + resolveReady(); + resolveReady = null; + rejectReady = null; + } + }); + + addSocketListener(ws, 'message', (data) => { + void (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + if (!message?.id || !pendingCommands.has(message.id)) { + return; + } + const pending = pendingCommands.get(message.id); + pendingCommands.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message || 'DevTools request failed.')); + return; + } + pending.resolve(message.result || null); + })().catch((error) => { + const socketError = toSocketError(error); + if (!settled) { + settleReadyError(socketError); + rejectPendingCommands(socketError); + } + }); + }); + + addSocketListener(ws, 'close', () => { + const error = new Error('Browser MCP lost the DevTools websocket connection.'); + if (!settled) { + settleReadyError(error); + rejectPendingCommands(error); + } + }); + + addSocketListener(ws, 'error', (error) => { + const socketError = toSocketError(error); + if (!settled) { + settleReadyError(socketError); + rejectPendingCommands(socketError); + } + }); + + await ready; + + const session = { + async sendCommand(method, params = {}) { + const requestId = ++requestCounter; + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingCommands.delete(requestId); + reject(new Error('Browser MCP timed out waiting for a DevTools response.')); + }, CDP_TIMEOUT_MS); + pendingCommands.set(requestId, { + resolve(result) { + clearTimeout(timer); + resolve(result); + }, + reject(error) { + clearTimeout(timer); + reject(error); + }, + }); + ws.send(JSON.stringify({ id: requestId, method, params })); + }); + }, + }; + + try { + return await callback(session); + } finally { + settled = true; + rejectPendingCommands(new Error('Browser MCP closed the page command session.')); + closeSocket(ws); + } +} + +async function evaluateObjectHandle(session, expression) { + const response = await session.sendCommand('Runtime.evaluate', { + expression, + returnByValue: false, + }); + + const result = response && response.result ? response.result : null; + if (!result) { + throw new Error('Browser MCP received an invalid DevTools evaluation response.'); + } + + if (result.subtype === 'error') { + throw new Error(result.description || 'DevTools evaluation returned an error.'); + } + + if (typeof result.objectId !== 'string' || !result.objectId) { + throw new Error('Browser MCP could not resolve a file input handle.'); + } + + return result.objectId; +} + function requireNonEmptyString(value, label) { if (typeof value !== 'string' || value.trim() === '') { throw new Error(`${label} is required`); @@ -521,6 +2021,1108 @@ function requireValidHttpUrl(value) { return parsed.toString(); } +function parseQueryFields(value) { + if (value === undefined) { + return DEFAULT_QUERY_FIELDS; + } + if (!Array.isArray(value) || value.some((field) => typeof field !== 'string')) { + throw new Error('fields must be an array of strings'); + } + const unknownField = value.find((field) => !SUPPORTED_QUERY_FIELD_SET.has(field)); + if (unknownField) { + throw new Error(`unknown query field: ${unknownField}`); + } + return value; +} + +function requireOptionalNonNegativeInteger(value, label) { + if (value === undefined) { + return undefined; + } + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function requirePositiveIntegerOrDefault(value, label, fallback) { + if (value === undefined) { + return fallback; + } + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function requirePositiveInteger(value, label) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function requireFiniteNumber(value, label) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number`); + } + return value; +} + +function requireEnumString(value, label, allowedValues) { + if (value === undefined) { + return undefined; + } + const normalized = requireNonEmptyString(value, label); + if (!allowedValues.includes(normalized)) { + throw new Error(`${label} must be one of: ${allowedValues.join(', ')}`); + } + return normalized; +} + +function requireOptionalStringArray(value, label, allowedValues) { + if (value === undefined) { + return []; + } + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.trim() === '')) { + throw new Error(`${label} must be an array of non-empty strings`); + } + const normalized = value.map((item) => item.trim()); + if (Array.isArray(allowedValues) && allowedValues.length > 0) { + for (const item of normalized) { + if (!allowedValues.includes(item)) { + throw new Error(`${label} must only contain: ${allowedValues.join(', ')}`); + } + } + } + return normalized; +} + +function requireNonEmptyStringArray(value, label) { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${label} must be a non-empty array of non-empty strings`); + } + if (value.some((item) => typeof item !== 'string' || item.trim() === '')) { + throw new Error(`${label} must be a non-empty array of non-empty strings`); + } + return value.map((item) => item.trim()); +} + +function requireNonNegativeInteger(value, label) { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function getBrowserEvalMode() { + const raw = String(process.env.CCS_BROWSER_EVAL_MODE || 'readonly').trim(); + if (raw === 'disabled' || raw === 'readonly' || raw === 'readwrite') { + return raw; + } + return 'readonly'; +} + +function createSessionCanceledError(kind) { + const error = new Error(`${kind} canceled`); + error.code = SESSION_CANCELED_ERROR_CODE; + return error; +} + +function isSessionCanceledError(error) { + return Boolean(error && typeof error === 'object' && error.code === SESSION_CANCELED_ERROR_CODE); +} + +function throwIfSessionCanceled(session, kind) { + if (session?.cancelRequested === true) { + throw createSessionCanceledError(kind); + } +} + +function markOrchestrationFailureRecorded(error) { + if (error && typeof error === 'object') { + error.orchestrationFailureRecorded = true; + } + return error; +} + +function wasOrchestrationFailureRecorded(error) { + return Boolean(error && typeof error === 'object' && error.orchestrationFailureRecorded === true); +} + +function normalizeDevtoolsResultValue(result) { + if (!result || typeof result !== 'object') { + return { serializable: false, value: undefined }; + } + if (Object.prototype.hasOwnProperty.call(result, 'value')) { + return { serializable: true, value: result.value }; + } + if (Object.prototype.hasOwnProperty.call(result, 'unserializableValue')) { + return { serializable: true, value: result.unserializableValue }; + } + if (result.type === 'undefined') { + return { serializable: true, value: undefined }; + } + return { serializable: false, value: undefined }; +} + +function requireArtifactName(value, label = 'name') { + const name = requireNonEmptyString(value, label); + if (!SAFE_ARTIFACT_NAME_PATTERN.test(name)) { + throw new Error( + 'artifact name must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens' + ); + } + return name; +} + +function parseOptionalNonEmptyString(value) { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : ''; +} + +function formatScopedSelectorSuffix(frameSelector, pierceShadow) { + return `${frameSelector ? `\nframeSelector: ${frameSelector}` : ''}${pierceShadow ? '\npierceShadow: true' : ''}`; +} + +function buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow) { + return `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + root = frameDocument; + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + + const count = matches.length; + const targetIndex = nth ?? 0; + const element = matches[targetIndex]; + if (!element) { + return JSON.stringify({ + exists: nth === undefined ? count > 0 : count > targetIndex, + count, + targetIndex, + targetMissing: true, + }); + } + + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const text = typeof element.innerText === 'string' ? element.innerText : (element.textContent || ''); + return JSON.stringify({ + exists: true, + count, + targetIndex, + connected: element.isConnected, + text, + innerText: typeof element.innerText === 'string' ? element.innerText : '', + textContent: element.textContent || '', + boundingClientRect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + href: typeof element.getAttribute === 'function' ? element.getAttribute('href') || '' : '', + onclick: typeof element.getAttribute === 'function' ? element.getAttribute('onclick') || '' : '', + interactable: + element.isConnected && + style.display !== 'none' && + style.visibility !== 'hidden' && + rect.width > 0 && + rect.height > 0, + centerPoint: { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }, + }); + })()`; +} + +async function getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow) { + const raw = await evaluateExpression(page, buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow)); + return JSON.parse(raw); +} + +function buildFileInputHandleExpression(selector, nth, frameSelector, pierceShadow) { + return `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + root = frameDocument; + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + + const targetIndex = nth ?? 0; + const element = matches[targetIndex]; + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + if (!(element instanceof HTMLInputElement) || element.type !== 'file') { + throw new Error('element is not a file input for selector: ' + selector); + } + return element; + })()`; +} + +function validateLocalFiles(files) { + return files.map((filePath) => { + const resolvedPath = path.resolve(filePath); + if (!fs.existsSync(resolvedPath)) { + throw new Error(`file does not exist: ${resolvedPath}`); + } + const stat = fs.statSync(resolvedPath); + if (!stat.isFile()) { + throw new Error(`file is not a regular file: ${resolvedPath}`); + } + return resolvedPath; + }); +} + +function formatFileInputResult({ pageIndex, selector, nth, frameSelector, pierceShadow, files }) { + const lines = [ + `pageIndex: ${pageIndex}`, + `selector: ${selector}`, + `nth: ${nth}`, + `fileCount: ${files.length}`, + ]; + if (frameSelector) { + lines.push(`frameSelector: ${frameSelector}`); + } + if (pierceShadow) { + lines.push('pierceShadow: true'); + } + lines.push('status: files-set'); + return lines.join('\n'); +} + +function inferMimeType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.pdf') return 'application/pdf'; + if (ext === '.png') return 'image/png'; + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; + if (ext === '.txt') return 'text/plain'; + if (ext === '.json') return 'application/json'; + return 'application/octet-stream'; +} + +function readLocalFilesForDrop(files) { + return validateLocalFiles(files).map((resolvedPath) => { + const buffer = fs.readFileSync(resolvedPath); + return { + path: resolvedPath, + name: path.basename(resolvedPath), + mimeType: inferMimeType(resolvedPath), + size: buffer.length, + base64: buffer.toString('base64'), + }; + }); +} + +function buildDropFilesExpression(selector, nth, frameSelector, pierceShadow, files) { + return `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + const filePayloads = JSON.parse(${JSON.stringify(JSON.stringify(files))}); + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + root = frameDocument; + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + + const targetIndex = nth ?? 0; + const element = matches[targetIndex]; + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + + const files = filePayloads.map((file) => { + const binary = atob(file.base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new File([bytes], file.name, { type: file.mimeType }); + }); + + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + + const dragEnterEvent = new DragEvent('dragenter', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + const dragOverEvent = new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + const dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + + element.dispatchEvent(dragEnterEvent); + const dragOverCanceled = + element.dispatchEvent(dragOverEvent) === false || dragOverEvent.defaultPrevented; + const dropCanceled = element.dispatchEvent(dropEvent) === false || dropEvent.defaultPrevented; + + return { accepted: dragOverCanceled || dropCanceled }; + })()`; +} + +function formatDragFilesResult({ pageIndex, selector, nth, frameSelector, pierceShadow, files }) { + const lines = [ + `pageIndex: ${pageIndex}`, + `selector: ${selector}`, + `nth: ${nth}`, + `fileCount: ${files.length}`, + ]; + if (frameSelector) { + lines.push(`frameSelector: ${frameSelector}`); + } + if (pierceShadow) { + lines.push('pierceShadow: true'); + } + lines.push('status: files-dropped'); + return lines.join('\n'); +} + +function createRecordingId() { + const recordingId = `rec_${String(nextRecordingCounter).padStart(4, '0')}`; + nextRecordingCounter += 1; + return recordingId; +} + +function createReplayId() { + const replayId = `rep_${String(nextReplayCounter).padStart(4, '0')}`; + nextReplayCounter += 1; + return replayId; +} + +function requireLatestRecording() { + if (!latestRecordingSession) { + throw new Error('no recording available'); + } + return latestRecordingSession; +} + +function formatRecordingSummary(session) { + return [ + `recordingId: ${session.recordingId}`, + `pageId: ${session.pageId}`, + `pageIndex: ${session.pageIndex}`, + `stepCount: ${session.steps.length}`, + `warningCount: ${session.warnings.length}`, + `status: ${session.status}`, + ].join('\n'); +} + +function getArtifactDir() { + const scoped = Buffer.from(process.cwd()).toString('base64url'); + const baseDir = path.join(os.tmpdir(), 'ccs-browser-artifacts', scoped); + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 }); + } + try { + fs.chmodSync(baseDir, 0o700); + } catch { + // Best-effort on platforms/filesystems that ignore permission changes. + } + return baseDir; +} + +function getArtifactPath(name) { + const safeName = requireArtifactName(name); + return path.join(getArtifactDir(), `${safeName}.json`); +} + +function buildArtifact(kind, name, payload) { + return { + kind, + version: 1, + name, + createdAt: new Date().toISOString(), + payload, + }; +} + +function readArtifactFile(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error('artifact file not found'); + } + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + throw new Error('artifact file is not a regular file'); + } + if (stat.size > MAX_ARTIFACT_FILE_BYTES) { + throw new Error( + `artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes` + ); + } + const raw = fs.readFileSync(filePath, 'utf8'); + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('invalid artifact payload'); + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('invalid artifact payload'); + } + if (parsed.version !== 1) { + throw new Error('unsupported artifact version'); + } + if (typeof parsed.kind !== 'string' || typeof parsed.name !== 'string' || !('payload' in parsed)) { + throw new Error('invalid artifact payload'); + } + return parsed; +} + +function createOrchestrationId() { + const orchestrationId = `orc_${String(nextOrchestrationCounter).padStart(4, '0')}`; + nextOrchestrationCounter += 1; + return orchestrationId; +} + +function requireLatestReplay() { + if (!latestReplaySession) { + throw new Error('no replay available'); + } + return latestReplaySession; +} + +function requireLatestOrchestration() { + if (!latestOrchestrationSession) { + throw new Error('no orchestration available'); + } + return latestOrchestrationSession; +} + +function formatReplaySummary(session) { + return [ + `replayId: ${session.replayId}`, + `pageId: ${session.pageId}`, + `pageIndex: ${session.pageIndex}`, + `stepCount: ${session.stepCount}`, + `completedSteps: ${session.completedSteps}`, + `currentStepIndex: ${session.currentStepIndex}`, + `failedStepIndex: ${session.failedStepIndex === null ? 'none' : session.failedStepIndex}`, + `error: ${session.error || 'none'}`, + `status: ${session.status}`, + ].join('\n'); +} + +function formatOrchestrationSummary(session) { + const lines = [ + `orchestrationId: ${session.orchestrationId}`, + `pageId: ${session.pageId}`, + `pageIndex: ${session.pageIndex}`, + `blockCount: ${session.blockCount}`, + `completedBlocks: ${session.completedBlocks}`, + `failedCount: ${session.failedCount || 0}`, + `currentBlockIndex: ${session.currentBlockIndex}`, + `failedBlockIndex: ${session.failedBlockIndex === null ? 'none' : session.failedBlockIndex}`, + `error: ${session.error || 'none'}`, + `status: ${session.status}`, + ]; + if (Array.isArray(session.failures) && session.failures.length > 0) { + session.failures.forEach((failure, index) => { + lines.push(`failure[${index}].blockIndex: ${failure.blockIndex}`); + lines.push( + `failure[${index}].sequenceStepIndex: ${failure.sequenceStepIndex === null ? 'none' : failure.sequenceStepIndex}` + ); + lines.push(`failure[${index}].type: ${failure.type}`); + lines.push(`failure[${index}].message: ${failure.message}`); + }); + } + if (session.failedSequenceStepIndex !== null && session.failedSequenceStepIndex !== undefined) { + lines.push(`failedSequenceStepIndex: ${session.failedSequenceStepIndex}`); + } + if (session.errorDetails?.failedAssertionIndex !== undefined) { + lines.push(`failedAssertionIndex: ${session.errorDetails.failedAssertionIndex}`); + lines.push(`field: ${session.errorDetails.field}`); + lines.push(`op: ${session.errorDetails.op}`); + lines.push(`expected: ${JSON.stringify(session.errorDetails.expected)}`); + lines.push(`actual: ${JSON.stringify(session.errorDetails.actual)}`); + } + return lines.join('\n'); +} + +async function waitForSessionToSettle(task) { + await Promise.race([ + task.then(() => undefined), + sleep(SESSION_START_SETTLE_WINDOW_MS), + ]); +} + +function createReplaySession(page, pageIndex, steps) { + return { + replayId: createReplayId(), + pageId: page.id, + pageIndex, + status: 'running', + stepCount: steps.length, + completedSteps: 0, + currentStepIndex: 0, + failedStepIndex: null, + error: null, + cancelRequested: false, + steps, + }; +} + +function createOrchestrationSession(page, pageIndex, blocks) { + return { + orchestrationId: createOrchestrationId(), + pageId: page.id, + pageIndex, + status: 'running', + blockCount: blocks.length, + completedBlocks: 0, + currentBlockIndex: 0, + failedBlockIndex: null, + failedSequenceStepIndex: null, + error: null, + failedCount: 0, + failures: [], + cancelRequested: false, + blocks, + }; +} + +function formatRecordingDetail(session) { + const lines = [formatRecordingSummary(session), 'steps:']; + if (session.steps.length === 0) { + lines.push('[]'); + } else { + session.steps.forEach((step, index) => { + lines.push(`- [${index}] type: ${step.type}`); + lines.push(` pageId: ${step.pageId}`); + lines.push(` timestamp: ${step.timestamp}`); + if (step.selector) { + lines.push(` selector: ${step.selector}`); + } + if (step.frameSelector) { + lines.push(` frameSelector: ${step.frameSelector}`); + } + if (step.pierceShadow === true) { + lines.push(' pierceShadow: true'); + } + if (step.args && typeof step.args === 'object') { + for (const [key, value] of Object.entries(step.args)) { + if (value !== undefined) { + lines.push(` ${key}: ${JSON.stringify(value)}`); + } + } + } + }); + } + + lines.push('warnings:'); + if (session.warnings.length === 0) { + lines.push('[]'); + } else { + session.warnings.forEach((warning) => { + lines.push(`- ${warning}`); + }); + } + + return lines.join('\n'); +} + +const SUPPORTED_REPLAY_STEP_TYPES = new Set([ + 'click', + 'type', + 'press_key', + 'scroll', + 'drag_element', + 'pointer_action', +]); + +function requireReplaySteps(toolArgs) { + const steps = Array.isArray(toolArgs.steps) ? toolArgs.steps : null; + if (!steps || steps.length === 0) { + throw new Error('steps must be a non-empty array'); + } + return steps; +} + +function validateReplayStep(step, replayPageId) { + if (!step || typeof step !== 'object') { + throw new Error('invalid replay step payload'); + } + if (!SUPPORTED_REPLAY_STEP_TYPES.has(step.type)) { + throw new Error('unsupported replay step type'); + } + if (step.pageId && step.pageId !== replayPageId) { + throw new Error('replay step pageId mismatch'); + } +} + +const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([ + 'wait_for_then_click', + 'wait_for_then_type', + 'wait_for_then_press_key', + 'run_replay_sequence', + 'assert_query', + 'sequence', + 'open_page_then_run', + 'select_page_then_run', + 'close_page_then_continue', +]); + +const CROSS_PAGE_BLOCK_TYPES = new Set([ + 'open_page_then_run', + 'select_page_then_run', + 'close_page_then_continue', +]); + +const SUPPORTED_ASSERTION_OPERATORS = new Set(['equals', 'contains', 'gt', 'gte', 'lt', 'lte']); + +function parseAssertionValue(value) { + return value; +} + +function toComparableNumber(field, value) { + const numeric = Number(value); + if (Number.isNaN(numeric)) { + throw new Error(`numeric comparison expects a number-like field: ${field}`); + } + return numeric; +} + +function parseAssertions(assertQueryArgs) { + if (Array.isArray(assertQueryArgs.assertions) && assertQueryArgs.assertions.length > 0) { + return assertQueryArgs.assertions.map((entry, index) => { + if (!entry || typeof entry !== 'object') { + throw new Error(`invalid assertion payload at index ${index}`); + } + const field = requireNonEmptyString(entry.field, 'assertion.field'); + const op = requireNonEmptyString(entry.op, 'assertion.op'); + if (!SUPPORTED_ASSERTION_OPERATORS.has(op)) { + throw new Error('unsupported assertion operator'); + } + if (!Object.prototype.hasOwnProperty.call(entry, 'value')) { + throw new Error('assertion value is required'); + } + return { field, op, value: parseAssertionValue(entry.value) }; + }); + } + + if (assertQueryArgs.assert && typeof assertQueryArgs.assert === 'object') { + return [ + { + field: requireNonEmptyString(assertQueryArgs.assert.field, 'assert.field'), + op: 'equals', + value: assertQueryArgs.assert.equals, + }, + ]; + } + + throw new Error('assertions must be a non-empty array'); +} + +function parseQueryTextToMap(text) { + const result = {}; + for (const line of String(text || '').split('\n')) { + const separatorIndex = line.indexOf(': '); + if (separatorIndex === -1) { + continue; + } + const key = line.slice(0, separatorIndex).trim(); + const rawValue = line.slice(separatorIndex + 2).trim(); + result[key] = rawValue; + } + return result; +} + +function evaluateAssertion(assertion, actualValue, assertionIndex) { + const { field, op, value: expected } = assertion; + + if (op === 'equals') { + return String(actualValue) === String(expected) + ? null + : { + failedAssertionIndex: assertionIndex, + field, + op, + expected, + actual: actualValue, + message: `assert_query failed: ${field} equals ${JSON.stringify(expected)} (actual: ${JSON.stringify(actualValue)})`, + }; + } + + if (op === 'contains') { + if (typeof actualValue !== 'string') { + throw new Error('contains expects a string field'); + } + return actualValue.includes(String(expected)) + ? null + : { + failedAssertionIndex: assertionIndex, + field, + op, + expected, + actual: actualValue, + message: `assert_query failed: ${field} contains ${JSON.stringify(expected)} (actual: ${JSON.stringify(actualValue)})`, + }; + } + + const actualNumber = toComparableNumber(field, actualValue); + const expectedNumber = toComparableNumber(field, expected); + const passed = + op === 'gt' + ? actualNumber > expectedNumber + : op === 'gte' + ? actualNumber >= expectedNumber + : op === 'lt' + ? actualNumber < expectedNumber + : actualNumber <= expectedNumber; + + return passed + ? null + : { + failedAssertionIndex: assertionIndex, + field, + op, + expected: expectedNumber, + actual: actualNumber, + message: `assert_query failed: ${field} ${op} ${expectedNumber} (actual: ${actualNumber})`, + }; +} + +function formatOrchestrationError(error) { + const message = error instanceof Error ? error.message : String(error); + try { + const parsed = JSON.parse(message); + if (parsed && typeof parsed === 'object' && 'failedAssertionIndex' in parsed) { + return parsed; + } + } catch { + // Keep plain-text error fallback below. + } + return { message }; +} + +function pushOrchestrationFailure(session, details) { + session.failures = session.failures || []; + session.failures.push(details); + session.failedCount = session.failures.length; +} + +function requireOrchestrationBlocks(toolArgs) { + const blocks = Array.isArray(toolArgs.blocks) ? toolArgs.blocks : null; + if (!blocks || blocks.length === 0) { + throw new Error('blocks must be a non-empty array'); + } + return blocks; +} + +function validateOrchestrationBlock(block) { + if (!block || typeof block !== 'object') { + throw new Error('invalid orchestration block payload'); + } + if (!SUPPORTED_ORCHESTRATION_BLOCK_TYPES.has(block.type)) { + throw new Error('unsupported orchestration block type'); + } +} + +function requireSequenceSteps(block) { + const steps = Array.isArray(block.args?.steps) ? block.args.steps : null; + if (!steps || steps.length === 0) { + throw new Error('sequence steps must be a non-empty array'); + } + return steps; +} + +function validateSequenceStep(step) { + validateOrchestrationBlock(step); + if (step.type === 'sequence') { + throw new Error('sequence does not support nested sequence blocks'); + } +} + +function requireCrossPageRunBlock(block) { + if (!block.args || typeof block.args !== 'object' || !block.args.run || typeof block.args.run !== 'object') { + throw new Error('cross-page run block is required'); + } + if (CROSS_PAGE_BLOCK_TYPES.has(block.args.run.type)) { + throw new Error('nested cross-page blocks are not supported'); + } + return block.args.run; +} + +function normalizeRecordedEvent(pageId, event) { + if (!event || typeof event !== 'object') { + return null; + } + + const timestamp = typeof event.timestamp === 'number' ? event.timestamp : Date.now(); + if (event.kind === 'click') { + return { + type: 'click', + pageId, + timestamp, + selector: event.selector, + nth: event.nth ?? 0, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + button: event.button || 'left', + clickCount: event.clickCount || 1, + offsetX: event.offsetX, + offsetY: event.offsetY, + }, + }; + } + if (event.kind === 'type') { + return { + type: 'type', + pageId, + timestamp, + selector: event.selector, + nth: event.nth ?? 0, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + text: event.text || '', + }, + }; + } + if (event.kind === 'press_key') { + return { + type: 'press_key', + pageId, + timestamp, + args: { + key: event.key, + modifiers: Array.isArray(event.modifiers) ? event.modifiers : [], + }, + }; + } + if (event.kind === 'scroll') { + return { + type: 'scroll', + pageId, + timestamp, + selector: event.selector, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + deltaX: event.deltaX || 0, + deltaY: event.deltaY || 0, + }, + }; + } + if (event.kind === 'drag_element') { + return { + type: 'drag_element', + pageId, + timestamp, + selector: event.selector, + nth: event.nth ?? 0, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + targetSelector: event.targetSelector, + targetX: event.targetX, + targetY: event.targetY, + targetNth: event.targetNth, + }, + }; + } + if (event.kind === 'pointer_action') { + return { + type: 'pointer_action', + pageId, + timestamp, + args: { + actions: Array.isArray(event.actions) ? event.actions : [], + }, + }; + } + return null; +} + +function parseEventCondition(value) { + if (!value || typeof value !== 'object') { + throw new Error('event is required'); + } + if (value.kind === 'dialog') { + return { + kind: 'dialog', + dialogType: value.dialogType ? String(value.dialogType) : undefined, + messageIncludes: value.messageIncludes ? String(value.messageIncludes) : undefined, + }; + } + if (value.kind === 'navigation') { + return { + kind: 'navigation', + urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined, + }; + } + if (value.kind === 'request') { + return { + kind: 'request', + urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined, + method: value.method ? String(value.method) : undefined, + }; + } + if (value.kind === 'download') { + return { + kind: 'download', + urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined, + suggestedFilenameIncludes: value.suggestedFilenameIncludes + ? String(value.suggestedFilenameIncludes) + : undefined, + }; + } + throw new Error(`unknown event kind: ${String(value.kind || '')}`); +} + +function matchesObservedEvent(event, observed) { + if (event.kind === 'dialog') { + return ( + (!event.dialogType || observed.type === event.dialogType) && + (!event.messageIncludes || String(observed.message || '').includes(event.messageIncludes)) + ); + } + if (event.kind === 'navigation') { + return !event.urlIncludes || String(observed.url || '').includes(event.urlIncludes); + } + if (event.kind === 'request') { + return ( + (!event.urlIncludes || String(observed.url || '').includes(event.urlIncludes)) && + (!event.method || String(observed.method || '').toUpperCase() === event.method.toUpperCase()) + ); + } + if (event.kind === 'download') { + return ( + (!event.urlIncludes || String(observed.url || '').includes(event.urlIncludes)) && + (!event.suggestedFilenameIncludes || String(observed.suggestedFilename || '').includes(event.suggestedFilenameIncludes)) + ); + } + return false; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -601,12 +3203,65 @@ async function handleNavigate(toolArgs) { async function handleClick(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const targetIndex = nth ?? 0; + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const offsetX = toolArgs.offsetX === undefined ? undefined : requireFiniteNumber(toolArgs.offsetX, 'offsetX'); + const offsetY = toolArgs.offsetY === undefined ? undefined : requireFiniteNumber(toolArgs.offsetY, 'offsetY'); + const button = requireEnumString(toolArgs.button, 'button', ['left', 'middle', 'right']) || 'left'; + const clickCount = toolArgs.clickCount === undefined ? 1 : requirePositiveInteger(toolArgs.clickCount, 'clickCount'); const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); - const element = document.querySelector(selector); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + const offsetX = ${offsetX === undefined ? 'undefined' : String(offsetX)}; + const offsetY = ${offsetY === undefined ? 'undefined' : String(offsetY)}; + const button = JSON.parse(${JSON.stringify(JSON.stringify(button))}); + const clickCount = ${clickCount}; + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + root = frameDocument; + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + const element = matches[nth ?? 0]; if (!element) { - throw new Error('element not found for selector: ' + selector); + throw new Error('element index ' + (nth ?? 0) + ' is out of range for selector: ' + selector); } if (!element.isConnected) { throw new Error('element is detached for selector: ' + selector); @@ -615,52 +3270,104 @@ async function handleClick(toolArgs) { throw new Error('element is disabled for selector: ' + selector); } const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); + const initialRect = element.getBoundingClientRect(); if ( style.display === 'none' || style.visibility === 'hidden' || - rect.width <= 0 || - rect.height <= 0 + initialRect.width <= 0 || + initialRect.height <= 0 ) { throw new Error('element is hidden or not interactable for selector: ' + selector); } element.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = element.getBoundingClientRect(); - const dispatchMouseEvent = (type, init) => { + const resolvedOffsetX = offsetX === undefined ? rect.width / 2 : offsetX; + const resolvedOffsetY = offsetY === undefined ? rect.height / 2 : offsetY; + const clientX = rect.left + resolvedOffsetX; + const clientY = rect.top + resolvedOffsetY; + const buttonCode = button === 'middle' ? 1 : button === 'right' ? 2 : 0; + const buttonsMask = buttonCode === 1 ? 4 : buttonCode === 2 ? 2 : 1; + + const dispatchMouseEvent = (type, detail, init) => { const event = new MouseEvent(type, { bubbles: true, cancelable: true, composed: true, view: window, - detail: 1, + detail, + clientX, + clientY, + button: buttonCode, + buttons: type === 'mousedown' ? buttonsMask : 0, ...init, }); return element.dispatchEvent(event); }; try { - const dispatchResult = { - shouldActivate: - dispatchMouseEvent('mousedown', { button: 0, buttons: 1 }) && - dispatchMouseEvent('mouseup', { button: 0, buttons: 0 }), - }; - if (!dispatchResult.shouldActivate) { - return 'ok'; + for (let index = 1; index <= clickCount; index += 1) { + const dispatchResult = { + shouldActivate: + dispatchMouseEvent('mousedown', index, {}) && + dispatchMouseEvent('mouseup', index, {}), + }; + if (!dispatchResult.shouldActivate) { + return JSON.stringify({ resolvedOffsetX, resolvedOffsetY, button, clickCount }); + } + if (!element.isConnected) { + return JSON.stringify({ resolvedOffsetX, resolvedOffsetY, button, clickCount }); + } + if (button === 'left') { + element.click(); + } else if (button === 'right') { + element.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + composed: true, + view: window, + detail: index, + clientX, + clientY, + button: 2, + buttons: 0, + })); + } else { + element.dispatchEvent(new MouseEvent('auxclick', { + bubbles: true, + cancelable: true, + composed: true, + view: window, + detail: index, + clientX, + clientY, + button: 1, + buttons: 0, + })); + } } - if (!element.isConnected) { - return 'ok'; + if (button === 'left' && clickCount === 2) { + element.dispatchEvent(new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + composed: true, + view: window, + detail: 2, + clientX, + clientY, + button: 0, + buttons: 0, + })); } } catch (mouseError) { - // Fall through to the native activation path below. } - element.click(); - - return 'ok'; + return JSON.stringify({ resolvedOffsetX, resolvedOffsetY, button, clickCount }); })()`; - await evaluateExpression(page, expression); - return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: clicked`; + const raw = await evaluateExpression(page, expression); + const parsed = JSON.parse(raw); + return `pageIndex: ${pageIndex}\nselector: ${selector}\nnth: ${targetIndex}\noffsetX: ${parsed.resolvedOffsetX}\noffsetY: ${parsed.resolvedOffsetY}\nbutton: ${parsed.button}\nclickCount: ${parsed.clickCount}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: clicked`; } async function handleType(toolArgs) { @@ -742,6 +3449,183 @@ async function handleType(toolArgs) { return `pageIndex: ${pageIndex}\nselector: ${selector}\ntypedLength: ${typedLength}\nstatus: typed`; } +async function handlePressKey(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const key = requireNonEmptyString(toolArgs.key, 'key'); + const modifiers = requireOptionalStringArray(toolArgs.modifiers, 'modifiers', [ + 'Alt', + 'Control', + 'Meta', + 'Shift', + ]); + const repeat = toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat'); + const modifierMask = + (modifiers.includes('Alt') ? 1 : 0) | + (modifiers.includes('Control') ? 2 : 0) | + (modifiers.includes('Meta') ? 4 : 0) | + (modifiers.includes('Shift') ? 8 : 0); + const specialKeyMap = { + Enter: { code: 'Enter', keyCode: 13, text: '\r' }, + Tab: { code: 'Tab', keyCode: 9, text: '' }, + Escape: { code: 'Escape', keyCode: 27, text: '' }, + ArrowUp: { code: 'ArrowUp', keyCode: 38, text: '' }, + ArrowDown: { code: 'ArrowDown', keyCode: 40, text: '' }, + ArrowLeft: { code: 'ArrowLeft', keyCode: 37, text: '' }, + ArrowRight: { code: 'ArrowRight', keyCode: 39, text: '' }, + Backspace: { code: 'Backspace', keyCode: 8, text: '' }, + Delete: { code: 'Delete', keyCode: 46, text: '' }, + Space: { code: 'Space', keyCode: 32, text: ' ' }, + }; + const keyDescriptor = + key.length === 1 + ? { + code: `Key${key.toUpperCase()}`, + keyCode: key.toUpperCase().charCodeAt(0), + text: key, + } + : specialKeyMap[key]; + if (!keyDescriptor) { + throw new Error(`unsupported key: ${key}`); + } + const normalizedKey = key; + const normalizedText = keyDescriptor.text; + const code = keyDescriptor.code; + const keyCode = keyDescriptor.keyCode; + + for (let index = 0; index < repeat; index += 1) { + await sendCdpCommand(page, 'Input.dispatchKeyEvent', { + type: 'keyDown', + key: normalizedKey, + code, + text: normalizedText, + unmodifiedText: normalizedText, + windowsVirtualKeyCode: keyCode, + nativeVirtualKeyCode: keyCode, + modifiers: modifierMask, + autoRepeat: index > 0, + }); + await sendCdpCommand(page, 'Input.dispatchKeyEvent', { + type: 'keyUp', + key: normalizedKey, + code, + windowsVirtualKeyCode: keyCode, + nativeVirtualKeyCode: keyCode, + modifiers: modifierMask, + }); + } + + const modifierText = modifiers.length > 0 ? modifiers.join(',') : 'none'; + return `pageIndex: ${pageIndex}\nkey: ${key}\nmodifiers: ${modifierText}\nrepeat: ${repeat}\nstatus: key-pressed`; +} + +async function handleScroll(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = parseOptionalNonEmptyString(toolArgs.selector); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const behavior = requireEnumString(toolArgs.behavior, 'behavior', ['into-view', 'by-offset']); + const deltaX = toolArgs.deltaX === undefined ? 0 : requireFiniteNumber(toolArgs.deltaX, 'deltaX'); + const deltaY = toolArgs.deltaY === undefined ? 0 : requireFiniteNumber(toolArgs.deltaY, 'deltaY'); + + const expression = `(() => { + const selector = ${selector ? `JSON.parse(${JSON.stringify(JSON.stringify(selector))})` : 'undefined'}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + const behavior = JSON.parse(${JSON.stringify(JSON.stringify(behavior))}); + const deltaX = ${deltaX}; + const deltaY = ${deltaY}; + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + let scrollWindow = window; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + if (!frame.contentWindow) { + throw new Error('frame window is unavailable for selector: ' + frameSelector); + } + root = frameDocument; + scrollWindow = frame.contentWindow; + } + + if (!selector) { + if (behavior !== 'by-offset') { + throw new Error('selector is required for behavior: ' + behavior); + } + scrollWindow.scrollBy(deltaX, deltaY); + return JSON.stringify({ scope: 'page', behavior, deltaX, deltaY }); + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + const element = matches[0]; + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + + if (behavior === 'into-view') { + element.scrollIntoView({ block: 'center', inline: 'center' }); + return JSON.stringify({ scope: 'element', selector, behavior }); + } + + if (typeof element.scrollBy === 'function') { + element.scrollBy(deltaX, deltaY); + return JSON.stringify({ scope: 'element', selector, behavior, deltaX, deltaY }); + } + + throw new Error('element does not support scrollBy for selector: ' + selector); + })()`; + + const raw = await evaluateExpression(page, expression); + const parsed = JSON.parse(raw); + const lines = [`pageIndex: ${pageIndex}`]; + if (parsed.selector) { + lines.push(`selector: ${parsed.selector}`); + } + lines.push(`behavior: ${parsed.behavior}`); + if (typeof parsed.deltaX === 'number') { + lines.push(`deltaX: ${parsed.deltaX}`); + } + if (typeof parsed.deltaY === 'number') { + lines.push(`deltaY: ${parsed.deltaY}`); + } + if (parsed.scope === 'element') { + const scopedSuffix = formatScopedSelectorSuffix(frameSelector, pierceShadow); + if (scopedSuffix) { + lines.push(scopedSuffix.slice(1)); + } + } + lines.push('status: scrolled'); + return lines.join('\n'); +} + async function handleScreenshot(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const fullPage = toolArgs.fullPage === true; @@ -758,13 +3642,2139 @@ async function handleScreenshot(toolArgs) { return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; } +async function getElementDiagnostics(page, selector, nth, frameSelector = '', pierceShadow = false) { + return await getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow); +} + +async function getScrolledElementStateAt(page, selector, nth, frameSelector = '', pierceShadow = false) { + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + let frameOffset = { left: 0, top: 0 }; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + const frameRect = frame.getBoundingClientRect(); + frameOffset = { left: frameRect.left, top: frameRect.top }; + root = frameDocument; + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + const targetIndex = nth ?? 0; + const element = matches[targetIndex]; + if (!element) { + return JSON.stringify({ exists: false }); + } + if (!element.isConnected) { + return JSON.stringify({ exists: true, connected: false }); + } + element.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = element.getBoundingClientRect(); + const absoluteRect = { + x: rect.x + frameOffset.left, + y: rect.y + frameOffset.top, + width: rect.width, + height: rect.height, + top: rect.top + frameOffset.top, + right: rect.right + frameOffset.left, + bottom: rect.bottom + frameOffset.top, + left: rect.left + frameOffset.left, + }; + const style = window.getComputedStyle(element); + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; + const clipX = Math.max(0, absoluteRect.left); + const clipY = Math.max(0, absoluteRect.top); + const clipRight = Math.min(viewportWidth, absoluteRect.right); + const clipBottom = Math.min(viewportHeight, absoluteRect.bottom); + return JSON.stringify({ + exists: true, + connected: true, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + interactable: + style.display !== 'none' && + style.visibility !== 'hidden' && + rect.width > 0 && + rect.height > 0, + boundingClientRect: absoluteRect, + centerPoint: { + x: absoluteRect.left + absoluteRect.width / 2, + y: absoluteRect.top + absoluteRect.height / 2, + }, + visibleClip: { + x: clipX, + y: clipY, + width: Math.max(0, clipRight - clipX), + height: Math.max(0, clipBottom - clipY), + scale: 1, + }, + }); + })()`; + return JSON.parse(await evaluateExpression(page, expression)); +} + +async function getScrolledElementState(page, selector, frameSelector = '', pierceShadow = false) { + return await getScrolledElementStateAt(page, selector, undefined, frameSelector, pierceShadow); +} + +function formatQueryValue(field, value) { + if (field === 'boundingClientRect') { + return JSON.stringify(value); + } + return String(value); +} + +function hasTargetSpecificQueryField(fields) { + return fields.some((field) => field !== 'exists' && field !== 'count'); +} + +function parseWaitCondition(value, hasSelector) { + if (!value || typeof value !== 'object') { + throw new Error('condition is required'); + } + const condition = value; + if (condition.kind === 'existence') { + if (!hasSelector) { + throw new Error('page-level wait only supports text conditions in Phase 1'); + } + return { kind: 'existence', exists: condition.exists !== false }; + } + if (condition.kind === 'visibility') { + if (!hasSelector) { + throw new Error('page-level wait only supports text conditions in Phase 1'); + } + return { + kind: 'visibility', + visibility: condition.visibility === 'hidden' ? 'hidden' : 'visible', + opacityGt: typeof condition.opacityGt === 'number' ? condition.opacityGt : undefined, + }; + } + if (condition.kind === 'text') { + if (typeof condition.includes !== 'string' || condition.includes === '') { + throw new Error('condition.includes is required'); + } + return { kind: 'text', includes: condition.includes }; + } + throw new Error(`unknown wait condition kind: ${String(condition.kind || '')}`); +} + +function isVisibleObservation(observation, opacityGt) { + if (!observation || observation.targetMissing) { + return false; + } + const opacity = Number.parseFloat(String(observation.opacity ?? '1')); + return ( + observation.display !== 'none' && + observation.visibility === 'visible' && + Number(observation.boundingClientRect?.width || 0) > 0 && + Number(observation.boundingClientRect?.height || 0) > 0 && + (opacityGt === undefined || opacity > opacityGt) + ); +} + +function isHiddenObservation(observation) { + if (!observation || observation.targetMissing) { + return true; + } + return ( + observation.display === 'none' || + observation.visibility !== 'visible' || + Number(observation.boundingClientRect?.width || 0) <= 0 || + Number(observation.boundingClientRect?.height || 0) <= 0 + ); +} + +function isWaitConditionSatisfied(observation, condition) { + if (condition.kind === 'existence') { + return condition.exists ? observation.exists === true : observation.exists === false; + } + if (condition.kind === 'visibility') { + return condition.visibility === 'hidden' + ? isHiddenObservation(observation) + : isVisibleObservation(observation, condition.opacityGt); + } + if (condition.kind === 'text') { + return String(observation.text || '').includes(condition.includes); + } + return false; +} + +function formatWaitObservation(observation) { + if (!observation) { + return 'unavailable'; + } + if ('exists' in observation || 'count' in observation || 'display' in observation) { + return [ + `exists=${observation.exists === true ? 'true' : 'false'}`, + `count=${String(observation.count ?? 0)}`, + `display=${String(observation.display ?? '')}`, + `visibility=${String(observation.visibility ?? '')}`, + `opacity=${String(observation.opacity ?? '')}`, + ].join(', '); + } + if ('text' in observation) { + return `text=${JSON.stringify(observation.text || '')}`; + } + return 'unavailable'; +} + +function formatQueryResponse(pageIndex, selector, nth, diagnostics, fields) { + const lines = [`pageIndex: ${pageIndex}`, `selector: ${selector}`]; + if (nth !== undefined) { + lines.push(`nth: ${nth}`); + } + if (diagnostics.targetMissing) { + if (hasTargetSpecificQueryField(fields)) { + throw new Error(`element index ${diagnostics.targetIndex} is out of range for selector: ${selector}`); + } + for (const field of fields) { + if (field === 'exists') { + lines.push(`exists: ${diagnostics.exists ? 'true' : 'false'}`); + continue; + } + if (field === 'count') { + lines.push(`count: ${formatQueryValue(field, diagnostics.count)}`); + } + } + return lines.join('\n'); + } + for (const field of fields) { + lines.push(`${field}: ${formatQueryValue(field, diagnostics[field])}`); + } + return lines.join('\n'); +} + +async function getWaitPageObservation(page) { + const text = await evaluateExpression( + page, + `(() => document.body ? document.body.innerText || '' : '')()` + ); + return { text }; +} + +async function getWaitSelectorObservation(page, selector, nth, frameSelector, pierceShadow) { + return getElementDiagnostics(page, selector, nth, frameSelector, pierceShadow); +} + +async function handleWaitFor(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = typeof toolArgs.selector === 'string' ? toolArgs.selector.trim() : ''; + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS); + const pollIntervalMs = requirePositiveIntegerOrDefault( + toolArgs.pollIntervalMs, + 'pollIntervalMs', + DEFAULT_WAIT_POLL_INTERVAL_MS + ); + const condition = parseWaitCondition(toolArgs.condition, selector !== ''); + const deadline = Date.now() + timeoutMs; + let lastObserved = null; + + while (Date.now() <= deadline) { + lastObserved = selector + ? await getWaitSelectorObservation(page, selector, nth, frameSelector, pierceShadow) + : await getWaitPageObservation(page); + if (isWaitConditionSatisfied(lastObserved, condition)) { + return `pageIndex: ${pageIndex}${selector ? `\nselector: ${selector}` : ''}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: satisfied`; + } + if (Date.now() + pollIntervalMs > deadline) { + break; + } + await sleep(pollIntervalMs); + } + + throw new Error(`wait condition timed out\nlastObserved: ${formatWaitObservation(lastObserved)}`); +} + +async function handleEval(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const expression = requireNonEmptyString(toolArgs.expression, 'expression'); + const mode = toolArgs.mode === 'readwrite' ? 'readwrite' : 'readonly'; + const evalMode = getBrowserEvalMode(); + + if (evalMode === 'disabled') { + throw new Error('browser_eval is disabled by CCS_BROWSER_EVAL_MODE=disabled'); + } + if (mode === 'readwrite' && evalMode !== 'readwrite') { + throw new Error(`browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=${evalMode}`); + } + + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + ...(mode === 'readonly' ? { throwOnSideEffect: true } : {}), + }); + + if (response?.exceptionDetails?.text) { + throw new Error(response.exceptionDetails.text); + } + if (!response?.result) { + throw new Error('evaluation result is not JSON-serializable'); + } + + const result = response.result; + const normalizedResult = normalizeDevtoolsResultValue(result); + if (!normalizedResult.serializable) { + throw new Error('evaluation result is not JSON-serializable'); + } + + return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(normalizedResult.value)}`; +} + +function getButtonMask(button) { + if (button === 'right') { + return 2; + } + if (button === 'middle') { + return 4; + } + return 1; +} + +async function dispatchMousePointerEvent(page, type, x, y, button = 'left', isPressed = false) { + await sendCdpCommand(page, 'Input.dispatchMouseEvent', { + type, + x, + y, + button: type === 'mouseMoved' && !isPressed ? 'none' : button, + buttons: isPressed ? getButtonMask(button) : 0, + pointerType: 'mouse', + clickCount: 1, + }); +} + +function interpolatePoints(sourcePoint, targetPoint, steps) { + const points = []; + for (let index = 1; index <= steps; index += 1) { + const ratio = index / steps; + points.push({ + x: Math.round(sourcePoint.x + (targetPoint.x - sourcePoint.x) * ratio), + y: Math.round(sourcePoint.y + (targetPoint.y - sourcePoint.y) * ratio), + }); + } + return points; +} + +async function resolveElementCenterPoint(page, selector, nth, frameSelector, pierceShadow, missingLabel) { + const state = await getScrolledElementStateAt(page, selector, nth, frameSelector, pierceShadow); + if (!state.exists) { + throw new Error(missingLabel); + } + if (state.connected !== true) { + throw new Error(`drag coordinates unavailable for selector: ${selector}`); + } + if (state.interactable !== true || !state.centerPoint) { + throw new Error(`drag coordinates unavailable for selector: ${selector}`); + } + return state.centerPoint; +} + +async function handlePointerAction(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const actions = Array.isArray(toolArgs.actions) ? toolArgs.actions : null; + if (!actions || actions.length === 0) { + throw new Error('actions must be a non-empty array'); + } + if (actions.length > MAX_POINTER_ACTIONS) { + throw new Error(`actions exceeds maximum of ${MAX_POINTER_ACTIONS}`); + } + + let pointerX = null; + let pointerY = null; + let pressedButton = null; + + for (const action of actions) { + if (!action || typeof action !== 'object') { + throw new Error('each action must be an object'); + } + if (!['move', 'down', 'up', 'pause'].includes(action.type)) { + throw new Error(`unsupported pointer action: ${String(action.type || '')}`); + } + + if (action.type === 'pause') { + const durationMs = action.durationMs === undefined ? 0 : requireNonNegativeInteger(action.durationMs, 'durationMs'); + if (durationMs > 0) { + await sleep(durationMs); + } + continue; + } + + if (action.type === 'move') { + let point = null; + if (typeof action.selector === 'string' && action.selector.trim() !== '') { + point = await resolveElementCenterPoint( + page, + requireNonEmptyString(action.selector, 'actions.selector'), + requireOptionalNonNegativeInteger(action.nth, 'actions.nth'), + parseOptionalNonEmptyString(action.frameSelector), + action.pierceShadow === true, + 'drag coordinates unavailable' + ); + } else if (typeof action.x === 'number' && typeof action.y === 'number') { + point = { + x: requireFiniteNumber(action.x, 'actions.x'), + y: requireFiniteNumber(action.y, 'actions.y'), + }; + } else { + throw new Error('drag coordinates unavailable'); + } + + pointerX = point.x; + pointerY = point.y; + await dispatchMousePointerEvent(page, 'mouseMoved', pointerX, pointerY, pressedButton || 'left', Boolean(pressedButton)); + continue; + } + + if (pointerX === null || pointerY === null) { + throw new Error('pointer state error'); + } + + if (action.type === 'down') { + if (pressedButton) { + throw new Error('pointer state error'); + } + pressedButton = requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || 'left'; + await dispatchMousePointerEvent(page, 'mousePressed', pointerX, pointerY, pressedButton, true); + continue; + } + + if (!pressedButton) { + throw new Error('pointer state error'); + } + const releaseButton = + requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton; + if (releaseButton !== pressedButton) { + throw new Error('pointer state error'); + } + await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false); + pressedButton = null; + } + + if (pressedButton) { + throw new Error('pointer state error'); + } + + return `pageIndex: ${pageIndex}\nactionCount: ${actions.length}\nstatus: pointer-actions-completed`; +} + +async function handleDragElement(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const targetNth = requireOptionalNonNegativeInteger(toolArgs.targetNth, 'targetNth'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const targetSelector = parseOptionalNonEmptyString(toolArgs.targetSelector); + const hasTargetX = Object.prototype.hasOwnProperty.call(toolArgs, 'targetX'); + const hasTargetY = Object.prototype.hasOwnProperty.call(toolArgs, 'targetY'); + const hasTargetCoordinates = hasTargetX || hasTargetY; + if (targetSelector && hasTargetCoordinates) { + throw new Error('targetSelector and targetX/targetY cannot be used together'); + } + if (!targetSelector && !hasTargetCoordinates) { + throw new Error('targetSelector or targetX/targetY is required'); + } + if (hasTargetX !== hasTargetY) { + throw new Error('targetX and targetY must be provided together'); + } + + const sourcePoint = await resolveElementCenterPoint( + page, + selector, + nth, + frameSelector, + pierceShadow, + 'source element not found' + ); + + const targetPoint = targetSelector + ? await resolveElementCenterPoint( + page, + targetSelector, + targetNth, + frameSelector, + pierceShadow, + 'target element not found' + ) + : { + x: requireFiniteNumber(toolArgs.targetX, 'targetX'), + y: requireFiniteNumber(toolArgs.targetY, 'targetY'), + }; + + const steps = toolArgs.steps === undefined ? DEFAULT_DRAG_STEPS : requirePositiveInteger(toolArgs.steps, 'steps'); + + await dispatchMousePointerEvent(page, 'mouseMoved', sourcePoint.x, sourcePoint.y, 'left', false); + await dispatchMousePointerEvent(page, 'mousePressed', sourcePoint.x, sourcePoint.y, 'left', true); + for (const point of interpolatePoints(sourcePoint, targetPoint, steps)) { + await dispatchMousePointerEvent(page, 'mouseMoved', point.x, point.y, 'left', true); + } + await dispatchMousePointerEvent(page, 'mouseReleased', targetPoint.x, targetPoint.y, 'left', false); + + return [ + `pageIndex: ${pageIndex}`, + `selector: ${selector}`, + `nth: ${nth ?? 0}`, + targetSelector ? `targetSelector: ${targetSelector}` : `targetX: ${targetPoint.x}`, + targetSelector ? `targetNth: ${targetNth ?? 0}` : `targetY: ${targetPoint.y}`, + `steps: ${steps}`, + `${frameSelector ? `frameSelector: ${frameSelector}\n` : ''}${pierceShadow ? 'pierceShadow: true\n' : ''}status: dragged`, + ].join('\n'); +} + +async function handleHover(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const state = await getScrolledElementState(page, selector, frameSelector, pierceShadow); + if (!state.exists) { + throw new Error(`element not found for selector: ${selector}`); + } + if (state.connected !== true) { + throw new Error(`element is detached for selector: ${selector}`); + } + if (state.interactable !== true || !state.centerPoint) { + throw new Error(`element is hidden or not interactable for selector: ${selector}`); + } + + await dispatchMousePointerEvent(page, 'mouseMoved', state.centerPoint.x, state.centerPoint.y, 'left', false); + + return `pageIndex: ${pageIndex}\nselector: ${selector}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: hovered`; +} + +async function handleQuery(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const fields = parseQueryFields(toolArgs.fields); + const diagnostics = await getElementDiagnostics(page, selector, nth, frameSelector, pierceShadow); + return formatQueryResponse(pageIndex, selector, nth, diagnostics, fields); +} + +async function handleElementScreenshot(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const state = await getScrolledElementState(page, selector, frameSelector, pierceShadow); + if (!state.exists) { + throw new Error(`element not found for selector: ${selector}`); + } + if (state.connected !== true) { + throw new Error(`element is detached for selector: ${selector}`); + } + if (state.interactable !== true || !state.visibleClip) { + throw new Error(`element has empty bounds for selector: ${selector}`); + } + if (state.visibleClip.width <= 0 || state.visibleClip.height <= 0) { + throw new Error(`element has empty bounds for selector: ${selector}`); + } + + const response = await sendCdpCommand(page, 'Page.captureScreenshot', { + format: 'png', + clip: state.visibleClip, + }); + + const data = response && typeof response.data === 'string' ? response.data : ''; + if (!data) { + throw new Error('screenshot capture failed'); + } + + return `pageIndex: ${pageIndex}\nselector: ${selector}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nformat: png\ndata: ${data}`; +} + +async function waitForPageEvent({ page, timeoutMs, event }) { + const ws = new WebSocket(page.webSocketDebuggerUrl); + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + abortSocket(ws); + reject(new Error(`event wait timed out for kind: ${event.kind}`)); + }, timeoutMs); + + const settleError = (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + abortSocket(ws); + reject(toSocketError(error)); + }; + + const settleSuccess = (observed) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + closeSocket(ws); + resolve(observed); + }; + + addSocketListener(ws, 'open', () => { + if (event.kind === 'dialog' || event.kind === 'navigation') { + ws.send(JSON.stringify({ id: ++requestCounter, method: 'Page.enable', params: {} })); + } + if (event.kind === 'request') { + ws.send(JSON.stringify({ id: ++requestCounter, method: 'Network.enable', params: {} })); + } + }); + + addSocketListener(ws, 'message', (data) => { + void (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + if (!message || typeof message !== 'object' || typeof message.method !== 'string') { + return; + } + let observed = null; + if (message.method === 'Page.javascriptDialogOpening') { + observed = { type: message.params?.type || '', message: message.params?.message || '' }; + } else if (message.method === 'Page.frameNavigated') { + const frame = message.params?.frame; + if (!frame?.parentId) { + observed = { url: frame?.url || '' }; + } + } else if (message.method === 'Network.requestWillBeSent') { + observed = { + url: message.params?.request?.url || '', + method: message.params?.request?.method || '', + }; + } + if (observed && matchesObservedEvent(event, observed)) { + settleSuccess(observed); + } + })().catch(settleError); + }); + + addSocketListener(ws, 'error', settleError); + addSocketListener(ws, 'close', () => { + if (!settled) { + settleError(new Error('Browser MCP lost the DevTools websocket connection.')); + } + }); + }); +} + +async function getPageFrameIds(page) { + const response = await sendCdpCommand(page, 'Page.getFrameTree', {}); + const frameTree = response?.frameTree; + const frameIds = new Set(); + + function visitFrameTree(node) { + if (!node || typeof node !== 'object') { + return; + } + const frameId = node.frame?.id; + if (typeof frameId === 'string' && frameId) { + frameIds.add(frameId); + } + const childFrames = Array.isArray(node.childFrames) ? node.childFrames : []; + for (const childFrame of childFrames) { + visitFrameTree(childFrame); + } + } + + visitFrameTree(frameTree); + + if (frameIds.size === 0) { + throw new Error('Browser MCP could not determine the selected page frame IDs.'); + } + return frameIds; +} + +async function waitForBrowserDownloadEvent(page, timeoutMs, event) { + const [targets, frameIds] = await Promise.all([ + fetchJson(`${getHttpUrl()}/json/list`), + getPageFrameIds(page), + ]); + const browserTarget = Array.isArray(targets) + ? targets.find((target) => target && typeof target === 'object' && target.type === 'browser') + : null; + if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) { + throw new Error('browser-level download events are unavailable'); + } + + const ws = new WebSocket(browserTarget.webSocketDebuggerUrl); + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + abortSocket(ws); + reject(new Error('event wait timed out for kind: download')); + }, timeoutMs); + + const settleError = (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + abortSocket(ws); + reject(toSocketError(error)); + }; + + const settleSuccess = (observed) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + closeSocket(ws); + resolve(observed); + }; + + addSocketListener(ws, 'message', (data) => { + void (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + if (message?.method !== 'Browser.downloadWillBegin') { + return; + } + const eventFrameId = message.params?.frameId; + if (!frameIds.has(eventFrameId)) { + const refreshedFrameIds = await getPageFrameIds(page); + if (!refreshedFrameIds.has(eventFrameId)) { + return; + } + for (const frameId of refreshedFrameIds) { + frameIds.add(frameId); + } + } + const observed = { + url: message.params?.url || '', + suggestedFilename: message.params?.suggestedFilename || '', + }; + if (matchesObservedEvent(event, observed)) { + settleSuccess(observed); + } + })().catch(settleError); + }); + + addSocketListener(ws, 'error', settleError); + addSocketListener(ws, 'close', () => { + if (!settled) { + settleError(new Error('Browser MCP lost the DevTools websocket connection.')); + } + }); + }); +} + +async function waitForMatchingEvent({ page, timeoutMs, event }) { + if (event.kind === 'download') { + return await waitForBrowserDownloadEvent(page, timeoutMs, event); + } + return await waitForPageEvent({ page, timeoutMs, event }); +} + +async function handleWaitForEvent(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS); + const event = parseEventCondition(toolArgs.event); + const observed = await waitForMatchingEvent({ page, pageIndex, timeoutMs, event }); + return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(observed)}`; +} + +function findInterceptRuleIndex(ruleId) { + return interceptRules.findIndex((rule) => rule.ruleId === ruleId); +} + +function getRulesForPage(pageId) { + return interceptRules.filter((rule) => rule.pageId === pageId); +} + +function getRulesForMatching(pageId) { + return getRulesForPage(pageId) + .slice() + .sort((left, right) => right.priority - left.priority); +} + +function matchesUrlPattern(pattern, url) { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`).test(url); +} + +function normalizePausedRequestHeaders(paused) { + const normalized = new Map(); + const headers = paused.request?.headers; + if (!headers || typeof headers !== 'object') { + return normalized; + } + for (const [name, value] of Object.entries(headers)) { + normalized.set(name.toLowerCase(), String(value)); + } + return normalized; +} + +function matchesHeaderMatchers(headerMatchers, paused) { + if (!headerMatchers.length) { + return true; + } + const normalizedHeaders = normalizePausedRequestHeaders(paused); + return headerMatchers.every((matcher) => { + const headerValue = normalizedHeaders.get(matcher.name.toLowerCase()); + if (headerValue === undefined) { + return false; + } + if (matcher.valueIncludes && !headerValue.includes(matcher.valueIncludes)) { + return false; + } + if (matcher.valueRegex && !new RegExp(matcher.valueRegex).test(headerValue)) { + return false; + } + return true; + }); +} + +function matchesInterceptRule(rule, paused) { + const requestMethod = String(paused.request?.method || '').toUpperCase(); + const requestUrl = String(paused.request?.url || ''); + const requestResourceType = String(paused.resourceType || ''); + if (rule.method && rule.method !== requestMethod) { + return false; + } + if (rule.urlIncludes && !requestUrl.includes(rule.urlIncludes)) { + return false; + } + if (rule.urlPattern && !matchesUrlPattern(rule.urlPattern, requestUrl)) { + return false; + } + if (rule.urlRegex && !new RegExp(rule.urlRegex).test(requestUrl)) { + return false; + } + if (rule.resourceType && rule.resourceType.toLowerCase() !== requestResourceType.toLowerCase()) { + return false; + } + if (!matchesHeaderMatchers(rule.headerMatchers || [], paused)) { + return false; + } + return true; +} + +async function ensureInterceptSession(page) { + const existing = interceptSessionsByPageId.get(page.id); + if (existing) { + return existing; + } + + const ws = new WebSocket(page.webSocketDebuggerUrl); + let enableRequestId = 0; + let resolveReady; + let rejectReady; + let activityChain = Promise.resolve(); + const pendingCommands = new Map(); + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const session = { + pageId: page.id, + webSocketDebuggerUrl: page.webSocketDebuggerUrl, + ws, + enabled: false, + ready, + pendingCommands, + activityVersion: 0, + getLastActivity() { + return activityChain; + }, + }; + interceptSessionsByPageId.set(page.id, session); + + function settleReadyError(error) { + if (rejectReady) { + rejectReady(error instanceof Error ? error : new Error(String(error))); + rejectReady = null; + resolveReady = null; + } + } + + function rejectPendingCommands(error) { + for (const pending of pendingCommands.values()) { + pending.reject(error instanceof Error ? error : new Error(String(error))); + } + pendingCommands.clear(); + } + + addSocketListener(ws, 'open', () => { + enableRequestId = ++requestCounter; + ws.send( + JSON.stringify({ + id: enableRequestId, + method: 'Fetch.enable', + params: { patterns: [{ urlPattern: '*' }] }, + }) + ); + }); + + addSocketListener(ws, 'message', (data) => { + const activity = (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + session.activityVersion += 1; + if (message.id === enableRequestId) { + if (message.error) { + const error = new Error(message.error.message || 'DevTools request failed.'); + settleReadyError(error); + rejectPendingCommands(error); + removeInterceptStateForPage(page.id); + closeSocket(ws); + return; + } + session.enabled = true; + if (resolveReady) { + resolveReady(); + resolveReady = null; + rejectReady = null; + } + return; + } + if (message.id && pendingCommands.has(message.id)) { + const pending = pendingCommands.get(message.id); + pendingCommands.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message || 'DevTools request failed.')); + return; + } + pending.resolve(message.result || null); + return; + } + if (message.method !== 'Fetch.requestPaused') { + return; + } + const paused = message.params || {}; + const matchedRule = getRulesForMatching(page.id).find((rule) => matchesInterceptRule(rule, paused)); + const action = matchedRule ? matchedRule.action : 'continue'; + if (action === 'fail') { + ws.send( + JSON.stringify({ + id: ++requestCounter, + method: 'Fetch.failRequest', + params: { requestId: paused.requestId, errorReason: FETCH_FAIL_ERROR_REASON }, + }) + ); + } else if (action === 'fulfill') { + ws.send( + JSON.stringify({ + id: ++requestCounter, + method: 'Fetch.fulfillRequest', + params: { + requestId: paused.requestId, + responseCode: matchedRule.statusCode, + responseHeaders: matchedRule.responseHeaders, + body: encodeFulfillBody(matchedRule.body), + }, + }) + ); + } else { + ws.send( + JSON.stringify({ + id: ++requestCounter, + method: 'Fetch.continueRequest', + params: { requestId: paused.requestId }, + }) + ); + } + pushRecentRequest({ + requestId: String(paused.requestId || ''), + pageId: page.id, + url: String(paused.request?.url || ''), + method: String(paused.request?.method || ''), + resourceType: String(paused.resourceType || ''), + matchedRuleId: matchedRule ? matchedRule.ruleId : '', + action, + statusCode: action === 'fulfill' ? matchedRule.statusCode : 0, + }); + })(); + activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); + void activity.catch(() => {}); + }); + + addSocketListener(ws, 'close', () => { + const error = new Error('Browser MCP lost the DevTools websocket connection.'); + if (!session.enabled) { + settleReadyError(error); + } + rejectPendingCommands(error); + removeInterceptStateForPage(page.id); + }); + + addSocketListener(ws, 'error', (error) => { + const socketError = toSocketError(error); + if (!session.enabled) { + settleReadyError(socketError); + } + rejectPendingCommands(socketError); + removeInterceptStateForPage(page.id); + }); + + await ready; + return session; +} + +async function handleSelectPage(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + selectedPageId = page.id; + return `pageIndex: ${pageIndex}\npageId: ${page.id}\ntitle: ${page.title || ''}\nurl: ${page.url || ''}\nstatus: selected`; +} + +async function handleOpenPage(toolArgs) { + const query = toolArgs?.url + ? `?${new URLSearchParams({ url: requireValidHttpUrl(toolArgs.url) }).toString()}` + : ''; + const createdTarget = await fetchJson(`${getHttpUrl()}/json/new${query}`); + const pageId = typeof createdTarget?.id === 'string' ? createdTarget.id : ''; + if (!pageId) { + throw new Error('Browser MCP failed to create a new page target.'); + } + selectedPageId = pageId; + const pages = await listPageTargets(); + const pageIndex = findPageIndexById(pages, pageId); + const selectedPage = pages[pageIndex]; + if (!selectedPage) { + throw new Error('Browser MCP could not resolve the newly opened page target.'); + } + return `pageIndex: ${pageIndex}\npageId: ${selectedPage.id}\ntitle: ${selectedPage.title || ''}\nurl: ${selectedPage.url || ''}\nstatus: opened`; +} + +async function handleClosePage(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + const previousSelectedPageId = selectedPageId; + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (activeRecordingSession && activeRecordingSession.pageId === page.id) { + try { + await finalizeRecordingCapture(activeRecordingSession); + } catch { + // Keep the recording session and still report the page-close warning below. + } + activeRecordingSession.status = 'stopped'; + activeRecordingSession.stoppedAt = new Date().toISOString(); + activeRecordingSession.warnings.push('recording stopped because target page was closed'); + latestRecordingSession = activeRecordingSession; + activeRecordingSession = null; + } + + await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`); + const interceptSession = interceptSessionsByPageId.get(page.id); + if (interceptSession) { + closeSocket(interceptSession.ws); + } + removeInterceptStateForPage(page.id); + const remainingPages = await listPageTargets(); + if (findPageIndexById(remainingPages, previousSelectedPageId) !== -1) { + selectedPageId = previousSelectedPageId; + } else { + selectedPageId = resolveFallbackSelectedPageId(remainingPages, pageIndex > 0 ? pageIndex - 1 : 0); + } + const selectedIndex = findPageIndexById(remainingPages, selectedPageId); + return [ + `pageIndex: ${pageIndex}`, + `pageId: ${page.id}`, + 'status: closed', + selectedPageId ? `selectedPageIndex: ${selectedIndex}` : 'selectedPageIndex: none', + selectedPageId ? `selectedPageId: ${selectedPageId}` : 'selectedPageId: none', + ].join('\n'); +} + +async function handleAddInterceptRule(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + const defaultPageId = selectedPageId || resolveFallbackSelectedPageId(pages, 0); + const { page } = resolveTargetPage(pages, toolArgs, defaultPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error('Target page does not expose a websocket debugger URL.'); + } + const urlIncludes = parseOptionalUrlIncludes(toolArgs.urlIncludes); + const method = parseOptionalMethod(toolArgs.method); + const resourceType = parseOptionalResourceType(toolArgs.resourceType); + const urlPattern = parseOptionalUrlPattern(toolArgs.urlPattern); + const urlRegex = parseOptionalUrlRegex(toolArgs.urlRegex); + const headerMatchers = parseOptionalHeaderMatchers(toolArgs.headerMatchers); + const priority = parseOptionalPriority(toolArgs.priority); + const action = parseInterceptAction(toolArgs.action); + validateInterceptMatcherSet({ + urlIncludes, + method, + resourceType, + urlPattern, + urlRegex, + headerMatchers, + }); + const statusCode = parseOptionalStatusCode(toolArgs.statusCode); + const contentType = + toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType'); + const body = parseOptionalBody(toolArgs.body); + const responseHeaders = parseOptionalResponseHeaders(toolArgs.responseHeaders, contentType); + const rule = { + ruleId: createInterceptRuleId(), + pageId: page.id, + pageTitleSnapshot: page.title || '', + urlIncludes, + method, + resourceType, + urlPattern, + urlRegex, + headerMatchers, + priority, + action, + statusCode: action === 'fulfill' ? statusCode : 0, + contentType: action === 'fulfill' ? contentType : '', + responseHeaders: action === 'fulfill' ? responseHeaders : [], + body: action === 'fulfill' ? body : '', + createdAt: new Date().toISOString(), + }; + interceptRules.push(rule); + await ensureInterceptSession(page); + return formatInterceptRules([rule]); +} + +async function handleRemoveInterceptRule(toolArgs) { + const ruleId = requireNonEmptyString(toolArgs.ruleId, 'ruleId'); + const index = findInterceptRuleIndex(ruleId); + if (index === -1) { + throw new Error(`rule not found: ${ruleId}`); + } + const [removed] = interceptRules.splice(index, 1); + if (getRulesForPage(removed.pageId).length === 0) { + const session = interceptSessionsByPageId.get(removed.pageId); + if (session) { + closeSocket(session.ws); + interceptSessionsByPageId.delete(removed.pageId); + } + } + return `ruleId: ${removed.ruleId}\nstatus: removed`; +} + +async function handleListInterceptRules() { + const pages = await listPageTargets(); + const livePageIds = new Set(pages.map((page) => page.id)); + for (let index = interceptRules.length - 1; index >= 0; index -= 1) { + if (!livePageIds.has(interceptRules[index].pageId)) { + interceptRules.splice(index, 1); + } + } + return formatInterceptRules(interceptRules); +} + +async function handleListRequests(toolArgs) { + const pages = await listPageTargets(); + const filteredPage = + toolArgs && (Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex') || toolArgs.pageId) + ? resolveTargetPage(pages, toolArgs, selectedPageId) + : null; + const limit = requirePositiveIntegerOrDefault(toolArgs.limit, 'limit', 20); + const sessions = Array.from(interceptSessionsByPageId.values()); + await Promise.all(sessions.map((session) => session.ready)); + await Promise.all( + sessions.map(async (session) => { + const barrierRequestId = ++requestCounter; + const barrierPromise = new Promise((resolve, reject) => { + session.pendingCommands.set(barrierRequestId, { resolve, reject }); + }); + session.ws.send( + JSON.stringify({ + id: barrierRequestId, + method: 'Runtime.evaluate', + params: { + expression: '0', + returnByValue: true, + }, + }) + ); + await barrierPromise; + await session.getLastActivity(); + await new Promise((resolve) => setTimeout(resolve, 35)); + await session.getLastActivity(); + }) + ); + const entries = recentRequests + .filter((entry) => !filteredPage || entry.pageId === filteredPage.page.id) + .slice(-limit) + .reverse(); + return formatRecentRequests(entries); +} + +async function handleSetDownloadBehavior(toolArgs) { + const behavior = requireEnumString(toolArgs.behavior, 'behavior', ['accept', 'deny']); + const downloadPath = parseOptionalNonEmptyString(toolArgs.downloadPath); + const eventsEnabled = toolArgs.eventsEnabled === undefined ? true : toolArgs.eventsEnabled === true; + + if (behavior === 'deny' && downloadPath) { + throw new Error('downloadPath is only allowed when behavior=accept'); + } + + await ensureBrowserDownloadSession(); + + const resolvedDownloadPath = + behavior === 'accept' ? ensureWritableDirectory(downloadPath || getSessionDownloadPath()) : ''; + await sendBrowserCdpCommand('Browser.setDownloadBehavior', { + behavior: behavior === 'accept' ? 'allow' : 'deny', + ...(resolvedDownloadPath ? { downloadPath: resolvedDownloadPath } : {}), + eventsEnabled, + }); + + return `scope: browser\nbehavior: ${behavior}\ndownloadPath: ${resolvedDownloadPath || ''}\neventsEnabled: ${eventsEnabled}\nstatus: configured`; +} + +async function handleListDownloads(toolArgs) { + const limit = requirePositiveIntegerOrDefault(toolArgs.limit, 'limit', 20); + if (browserDownloadSession) { + await browserDownloadSession.ready; + await browserDownloadSession.getLastActivity(); + await sleep(35); + await browserDownloadSession.getLastActivity(); + } + const entries = recentDownloads.slice(-limit).reverse(); + return formatRecentDownloads(entries); +} + +function resolveDownloadRecord(toolArgs) { + const downloadId = parseOptionalNonEmptyString(toolArgs.downloadId); + const guid = parseOptionalNonEmptyString(toolArgs.guid); + if (!downloadId && !guid) { + throw new Error('downloadId or guid is required'); + } + + const matched = recentDownloads.filter( + (entry) => (!downloadId || entry.downloadId === downloadId) && (!guid || entry.guid === guid) + ); + if (matched.length !== 1) { + throw new Error('download not found'); + } + return matched[0]; +} + +async function handleCancelDownload(toolArgs) { + const entry = resolveDownloadRecord(toolArgs); + if (entry.status === 'completed' || entry.status === 'failed' || entry.status === 'denied') { + throw new Error(`download is not cancelable in status: ${entry.status}`); + } + + await sendBrowserCdpCommand('Browser.cancelDownload', { guid: entry.guid }); + entry.status = 'canceled'; + entry.finishedAt = new Date().toISOString(); + return `downloadId: ${entry.downloadId}\nguid: ${entry.guid}\nstatus: canceled`; +} + +async function handleSetFileInput(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const files = validateLocalFiles(requireNonEmptyStringArray(toolArgs.files, 'files')); + const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + + await withPageCommandSession(page, async (session) => { + const objectId = await evaluateObjectHandle( + session, + buildFileInputHandleExpression(selector, nth, frameSelector, pierceShadow) + ); + await session.sendCommand('DOM.setFileInputFiles', { + files, + objectId, + }); + }); + + return formatFileInputResult({ + pageIndex, + selector, + nth, + frameSelector, + pierceShadow, + files, + }); +} + +async function handleDragFiles(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const files = readLocalFilesForDrop(requireNonEmptyStringArray(toolArgs.files, 'files')); + const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: buildDropFilesExpression(selector, nth, frameSelector, pierceShadow, files), + returnByValue: true, + awaitPromise: true, + }); + + if (response?.exceptionDetails) { + throw new Error(response.exceptionDetails.text || 'drop target evaluation failed'); + } + + const result = response?.result || null; + if (!result) { + throw new Error('Browser MCP received an invalid DevTools evaluation response.'); + } + if (result.subtype === 'error') { + throw new Error(result.description || 'drop target evaluation failed'); + } + if (result.value?.accepted === false) { + throw new Error('drop target rejected files'); + } + + return formatDragFilesResult({ + pageIndex, + selector, + nth, + frameSelector, + pierceShadow, + files, + }); +} + +function buildRecordingInstallExpression(recordingPayload) { + return `(() => { + const recordingPayload = JSON.parse(${JSON.stringify(JSON.stringify(recordingPayload))}); + const existing = globalThis.__CCS_BROWSER_RECORDING_RECORDER__; + if (existing && existing.installed === true) { + return { installed: true }; + } + + const events = Array.isArray(recordingPayload.events) ? [...recordingPayload.events] : []; + const warnings = Array.isArray(recordingPayload.warnings) ? [...recordingPayload.warnings] : []; + const getSelector = (element) => { + if (!(element instanceof Element)) { + return undefined; + } + if (element.id) { + return '#' + element.id; + } + const attr = typeof element.getAttribute === 'function' ? element.getAttribute('data-testid') : ''; + if (attr) { + return '[data-testid="' + attr + '"]'; + } + return element.tagName ? element.tagName.toLowerCase() : undefined; + }; + const pushEvent = (event) => { + events.push(event); + }; + const onClick = (event) => { + pushEvent({ + kind: 'click', + selector: getSelector(event.target), + button: event.button === 1 ? 'middle' : event.button === 2 ? 'right' : 'left', + clickCount: event.detail || 1, + offsetX: typeof event.offsetX === 'number' ? event.offsetX : undefined, + offsetY: typeof event.offsetY === 'number' ? event.offsetY : undefined, + timestamp: Date.now(), + }); + }; + const onInput = (event) => { + const target = event.target; + let text = ''; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + text = target.value; + } else if (target && target.isContentEditable === true) { + text = target.textContent || ''; + } else { + return; + } + pushEvent({ kind: 'type', selector: getSelector(target), text, timestamp: Date.now() }); + }; + const onKeyDown = (event) => { + const modifiers = []; + if (event.altKey) modifiers.push('Alt'); + if (event.ctrlKey) modifiers.push('Control'); + if (event.metaKey) modifiers.push('Meta'); + if (event.shiftKey) modifiers.push('Shift'); + pushEvent({ kind: 'press_key', key: event.key, modifiers, timestamp: Date.now() }); + }; + const onScroll = (event) => { + const target = event.target === document ? document.scrollingElement || document.documentElement : event.target; + pushEvent({ + kind: 'scroll', + selector: getSelector(target), + deltaX: 0, + deltaY: 0, + timestamp: Date.now(), + }); + }; + + document.addEventListener('click', onClick, true); + document.addEventListener('input', onInput, true); + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('scroll', onScroll, true); + + globalThis.__CCS_BROWSER_RECORDING_RECORDER__ = { + installed: true, + events, + warnings, + teardown: () => { + document.removeEventListener('click', onClick, true); + document.removeEventListener('input', onInput, true); + document.removeEventListener('keydown', onKeyDown, true); + document.removeEventListener('scroll', onScroll, true); + }, + }; + return { installed: true }; + })()`; +} + +async function installRecorderAndCapture(page) { + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: buildRecordingInstallExpression({ events: [], warnings: [] }), + returnByValue: true, + awaitPromise: true, + }); + + if (response?.exceptionDetails) { + throw new Error(response.exceptionDetails.text || 'recording injection failed'); + } + + const result = response?.result || null; + if (!result) { + throw new Error('recording injection failed'); + } + if (result.subtype === 'error') { + throw new Error(result.description || 'recording injection failed'); + } + + return result.value || { installed: true }; +} + +async function finalizeRecordingCapture(session) { + const page = { + id: session.pageId, + webSocketDebuggerUrl: session.pageWebSocketDebuggerUrl, + }; + + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: `(() => globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] })()`, + returnByValue: true, + awaitPromise: true, + }); + + if (response?.exceptionDetails) { + throw new Error(response.exceptionDetails.text || 'recording injection failed'); + } + + const value = response?.result?.value || { events: [], warnings: [] }; + const rawEvents = Array.isArray(value.events) ? value.events : []; + const warnings = Array.isArray(value.warnings) ? value.warnings : []; + + session.rawEvents = rawEvents; + session.steps = rawEvents.map((event) => normalizeRecordedEvent(session.pageId, event)).filter(Boolean); + session.warnings = warnings.map((warning) => String(warning.message || warning)); +} + +async function handleStartRecording(toolArgs) { + if (activeRecordingSession) { + throw new Error('recording already active'); + } + + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const session = { + recordingId: createRecordingId(), + pageId: page.id, + pageIndex, + startedAt: new Date().toISOString(), + stoppedAt: null, + status: 'recording', + steps: [], + warnings: [], + rawEvents: [], + pageWebSocketDebuggerUrl: page.webSocketDebuggerUrl, + captureInstalled: false, + }; + + await installRecorderAndCapture(page); + session.captureInstalled = true; + activeRecordingSession = session; + latestRecordingSession = session; + + return formatRecordingSummary(session); +} + +async function handleStopRecording() { + if (!activeRecordingSession) { + throw new Error('no active recording'); + } + const session = activeRecordingSession; + let finalizeError = null; + try { + await finalizeRecordingCapture(session); + } catch (error) { + finalizeError = error instanceof Error ? error : new Error(String(error)); + session.warnings.push( + `recording capture finalization failed: ${finalizeError.message}` + ); + } + session.status = 'stopped'; + session.stoppedAt = new Date().toISOString(); + activeRecordingSession = null; + latestRecordingSession = session; + if (finalizeError) { + throw finalizeError; + } + return formatRecordingSummary(session); +} + +async function handleGetRecording() { + const session = requireLatestRecording(); + return formatRecordingDetail(session); +} + +async function handleClearRecording() { + if (!latestRecordingSession && !activeRecordingSession) { + throw new Error('no recording available'); + } + activeRecordingSession = null; + latestRecordingSession = null; + return 'status: cleared'; +} + +function buildReplayToolArgs(step, replayPage) { + const baseArgs = { + pageId: replayPage.pageId, + }; + + if (step.type === 'click') { + return { + toolName: TOOL_CLICK, + toolArgs: { + ...baseArgs, + selector: requireNonEmptyString(step.selector, 'selector'), + nth: step.nth, + frameSelector: step.frameSelector, + pierceShadow: step.pierceShadow, + button: step.args?.button, + clickCount: step.args?.clickCount, + offsetX: step.args?.offsetX, + offsetY: step.args?.offsetY, + }, + }; + } + + if (step.type === 'type') { + return { + toolName: TOOL_TYPE, + toolArgs: { + ...baseArgs, + selector: requireNonEmptyString(step.selector, 'selector'), + nth: step.nth, + frameSelector: step.frameSelector, + pierceShadow: step.pierceShadow, + text: requireString(step.args?.text, 'text'), + clearFirst: true, + }, + }; + } + + if (step.type === 'press_key') { + return { + toolName: TOOL_PRESS_KEY, + toolArgs: { + ...baseArgs, + key: requireNonEmptyString(step.args?.key, 'key'), + modifiers: Array.isArray(step.args?.modifiers) ? step.args.modifiers : [], + }, + }; + } + + if (step.type === 'scroll') { + return { + toolName: TOOL_SCROLL, + toolArgs: { + ...baseArgs, + selector: step.selector, + frameSelector: step.frameSelector, + pierceShadow: step.pierceShadow, + behavior: 'by-offset', + deltaX: step.args?.deltaX ?? 0, + deltaY: step.args?.deltaY ?? 0, + }, + }; + } + + if (step.type === 'drag_element') { + return { + toolName: TOOL_DRAG_ELEMENT, + toolArgs: { + ...baseArgs, + selector: requireNonEmptyString(step.selector, 'selector'), + nth: step.nth, + frameSelector: step.frameSelector, + pierceShadow: step.pierceShadow, + ...(step.args?.targetSelector !== undefined ? { targetSelector: step.args.targetSelector } : {}), + ...(step.args?.targetNth !== undefined ? { targetNth: step.args.targetNth } : {}), + ...(step.args?.targetX !== undefined ? { targetX: step.args.targetX } : {}), + ...(step.args?.targetY !== undefined ? { targetY: step.args.targetY } : {}), + }, + }; + } + + if (step.type === 'pointer_action') { + return { + toolName: TOOL_POINTER_ACTION, + toolArgs: { + ...baseArgs, + actions: Array.isArray(step.args?.actions) ? step.args.actions : [], + }, + }; + } + + return null; +} + +async function executeReplaySteps(session) { + for (let index = 0; index < session.steps.length; index += 1) { + throwIfSessionCanceled(session, 'replay'); + const step = session.steps[index]; + session.currentStepIndex = index; + validateReplayStep(step, session.pageId); + + const mapped = buildReplayToolArgs(step, session); + if (!mapped) { + throw new Error('unsupported replay step type'); + } + + if (mapped.toolName === TOOL_CLICK) { + await handleClick(mapped.toolArgs); + } else if (mapped.toolName === TOOL_TYPE) { + await handleType(mapped.toolArgs); + } else if (mapped.toolName === TOOL_PRESS_KEY) { + await handlePressKey(mapped.toolArgs); + } else if (mapped.toolName === TOOL_SCROLL) { + await handleScroll(mapped.toolArgs); + } else if (mapped.toolName === TOOL_DRAG_ELEMENT) { + await handleDragElement(mapped.toolArgs); + } else if (mapped.toolName === TOOL_POINTER_ACTION) { + await handlePointerAction(mapped.toolArgs); + } else { + throw new Error('unsupported replay step type'); + } + + session.completedSteps = index + 1; + throwIfSessionCanceled(session, 'replay'); + } +} + +async function runReplaySession(session, options = {}) { + try { + await executeReplaySteps(session); + if (session.cancelRequested) { + session.status = 'canceled'; + } else { + session.status = 'completed'; + } + } catch (error) { + if (isSessionCanceledError(error) || session.cancelRequested) { + session.status = 'canceled'; + session.error = null; + } else { + session.status = 'failed'; + session.failedStepIndex = session.currentStepIndex; + session.error = error instanceof Error ? error.message : String(error); + } + } finally { + latestReplaySession = session; + if (options.manageActiveState === true) { + if (activeReplaySession === session) { + activeReplaySession = null; + } + activeReplayTask = null; + } + } + return session; +} + +async function handleStartReplay(toolArgs) { + if (activeReplaySession) { + throw new Error('replay already active'); + } + + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const steps = requireReplaySteps(toolArgs); + const session = createReplaySession(page, pageIndex, steps); + + latestReplaySession = session; + activeReplaySession = session; + const task = Promise.resolve().then(() => + runReplaySession(session, { manageActiveState: true }) + ); + activeReplayTask = task; + task.catch(() => { + // Session state is recorded in runReplaySession. + }); + await waitForSessionToSettle(task); + return formatReplaySummary(session); +} + +async function handleGetReplay() { + const session = requireLatestReplay(); + return formatReplaySummary(session); +} + +async function handleCancelReplay() { + if (!activeReplaySession) { + throw new Error('no active replay'); + } + activeReplaySession.cancelRequested = true; + activeReplaySession.status = 'canceled'; + latestReplaySession = activeReplaySession; + return formatReplaySummary(activeReplaySession); +} + +async function executeOrchestrationBlock(session, block) { + if (block.type === 'select_page_then_run') { + const selectResult = await handleSelectPage(block.args.select || {}); + const match = /pageId: (.+)/.exec(selectResult); + const selectedPageId = match?.[1]?.trim() || session.pageId; + const runBlock = requireCrossPageRunBlock(block); + const childSession = { ...session, pageId: selectedPageId }; + await executeOrchestrationBlock(childSession, runBlock); + return; + } + + if (block.type === 'open_page_then_run') { + const openResult = await handleOpenPage(block.args.open || {}); + const match = /pageId: (.+)/.exec(openResult); + const openedPageId = match?.[1]?.trim() || session.pageId; + const runBlock = requireCrossPageRunBlock(block); + const childSession = { ...session, pageId: openedPageId }; + await executeOrchestrationBlock(childSession, runBlock); + return; + } + + if (block.type === 'close_page_then_continue') { + await handleClosePage(block.args.close || {}); + return; + } + + if (block.type === 'wait_for_then_click') { + await handleWaitFor({ pageId: session.pageId, ...block.args.wait }); + await handleClick({ pageId: session.pageId, ...block.args.click }); + return; + } + + if (block.type === 'wait_for_then_type') { + await handleWaitFor({ pageId: session.pageId, ...block.args.wait }); + await handleType({ pageId: session.pageId, ...block.args.type }); + return; + } + + if (block.type === 'wait_for_then_press_key') { + await handleWaitFor({ pageId: session.pageId, ...block.args.wait }); + await handlePressKey({ pageId: session.pageId, ...block.args.pressKey }); + return; + } + + if (block.type === 'run_replay_sequence') { + const replaySteps = requireReplaySteps({ steps: block.args.steps }); + const replaySession = createReplaySession( + { id: session.pageId }, + session.pageIndex, + replaySteps + ); + await runReplaySession(replaySession); + if (replaySession.status === 'failed' || replaySession.status === 'canceled') { + throw new Error(formatReplaySummary(replaySession)); + } + return; + } + + if (block.type === 'assert_query') { + const queryText = await handleQuery({ pageId: session.pageId, ...block.args.query }); + const queryMap = parseQueryTextToMap(queryText); + const assertions = parseAssertions(block.args); + + for (const [index, assertion] of assertions.entries()) { + const actualValue = queryMap[assertion.field]; + if (actualValue === undefined) { + throw new Error(`assert_query field missing from query result: ${assertion.field}`); + } + const failure = evaluateAssertion(assertion, actualValue, index); + if (failure) { + throw new Error(JSON.stringify(failure)); + } + } + return; + } + + if (block.type === 'sequence') { + await executeSequenceSteps(session, block); + return; + } + + throw new Error('unsupported orchestration block type'); +} + +async function executeSequenceSteps(session, block) { + const steps = requireSequenceSteps(block); + session.failedSequenceStepIndex = null; + for (let index = 0; index < steps.length; index += 1) { + throwIfSessionCanceled(session, 'orchestration'); + const step = steps[index]; + validateSequenceStep(step); + try { + await executeOrchestrationBlock(session, step); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + pushOrchestrationFailure(session, { + blockIndex: session.currentBlockIndex, + sequenceStepIndex: index, + type: step.type, + message, + }); + session.failedSequenceStepIndex = index; + if (step.continueOnError === true) { + continue; + } + throw markOrchestrationFailureRecorded(error); + } + } +} + +async function executeOrchestrationBlocks(session) { + for (let index = 0; index < session.blocks.length; index += 1) { + throwIfSessionCanceled(session, 'orchestration'); + const block = session.blocks[index]; + session.currentBlockIndex = index; + session.failedSequenceStepIndex = null; + validateOrchestrationBlock(block); + try { + await executeOrchestrationBlock(session, block); + session.completedBlocks = index + 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!wasOrchestrationFailureRecorded(error)) { + pushOrchestrationFailure(session, { + blockIndex: index, + sequenceStepIndex: null, + type: block.type, + message, + }); + } + if (block.continueOnError === true) { + session.completedBlocks = index + 1; + continue; + } + throw error; + } + } +} + +async function runOrchestrationSession(session, options = {}) { + try { + await executeOrchestrationBlocks(session); + if (session.cancelRequested) { + session.status = 'canceled'; + } else if ((session.failedCount || 0) > 0) { + session.status = 'completed_with_failures'; + } else { + session.status = 'completed'; + } + } catch (error) { + if (isSessionCanceledError(error) || session.cancelRequested) { + session.status = 'canceled'; + session.error = null; + session.errorDetails = undefined; + } else { + session.status = 'failed'; + session.failedBlockIndex = session.currentBlockIndex; + session.errorDetails = formatOrchestrationError(error); + session.error = + session.errorDetails.message || (error instanceof Error ? error.message : String(error)); + } + } finally { + latestOrchestrationSession = session; + if (options.manageActiveState === true) { + if (activeOrchestrationSession === session) { + activeOrchestrationSession = null; + } + activeOrchestrationTask = null; + } + } + return session; +} + +async function handleStartOrchestration(toolArgs) { + if (activeOrchestrationSession) { + throw new Error('orchestration already active'); + } + + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const blocks = requireOrchestrationBlocks(toolArgs); + const session = createOrchestrationSession(page, pageIndex, blocks); + + latestOrchestrationSession = session; + activeOrchestrationSession = session; + const task = Promise.resolve().then(() => + runOrchestrationSession(session, { manageActiveState: true }) + ); + activeOrchestrationTask = task; + task.catch(() => { + // Session state is recorded in runOrchestrationSession. + }); + await waitForSessionToSettle(task); + return formatOrchestrationSummary(session); +} + +async function handleGetOrchestration() { + const session = requireLatestOrchestration(); + return formatOrchestrationSummary(session); +} + +async function handleCancelOrchestration() { + if (!activeOrchestrationSession) { + throw new Error('no active orchestration'); + } + activeOrchestrationSession.cancelRequested = true; + activeOrchestrationSession.status = 'canceled'; + latestOrchestrationSession = activeOrchestrationSession; + return formatOrchestrationSummary(activeOrchestrationSession); +} + +function getArtifactPayloadForKind(kind) { + if (kind === 'recording') { + return requireLatestRecording(); + } + if (kind === 'replay') { + return requireLatestReplay(); + } + if (kind === 'orchestration') { + return requireLatestOrchestration(); + } + throw new Error(`unsupported artifact kind: ${kind}`); +} + +function resolveArtifactPath(reference) { + const value = requireNonEmptyString(reference, 'path'); + if (value.startsWith('artifact:')) { + return getArtifactPath(value.slice('artifact:'.length)); + } + return path.resolve(value); +} + +function deleteArtifactByName(name) { + const filePath = getArtifactPath(name); + if (!fs.existsSync(filePath)) { + throw new Error('artifact not found'); + } + fs.unlinkSync(filePath); + return filePath; +} + +async function handleExportArtifact(toolArgs) { + const kind = requireNonEmptyString(toolArgs.kind, 'kind'); + const name = requireArtifactName(toolArgs.name); + const filePath = getArtifactPath(name); + if (fs.existsSync(filePath)) { + throw new Error('artifact already exists'); + } + const payload = getArtifactPayloadForKind(kind); + const artifact = buildArtifact(kind, name, payload); + const serialized = JSON.stringify(artifact, null, 2); + if (Buffer.byteLength(serialized, 'utf8') > MAX_ARTIFACT_FILE_BYTES) { + throw new Error( + `artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes` + ); + } + fs.writeFileSync(filePath, serialized, { encoding: 'utf8', mode: 0o600 }); + return `name: ${name}\nkind: ${kind}\npath: ${filePath}\nstatus: exported`; +} + +async function handleListArtifacts() { + const dir = getArtifactDir(); + const entries = fs + .readdirSync(dir) + .filter((entry) => entry.endsWith('.json')) + .map((entry) => readArtifactFile(path.join(dir, entry))); + if (entries.length === 0) { + return 'artifacts: []'; + } + return entries + .map((entry) => [ + `name: ${entry.name}`, + `kind: ${entry.kind}`, + `createdAt: ${entry.createdAt}`, + `path: ${getArtifactPath(entry.name)}`, + ].join('\n')) + .join('\n---\n'); +} + +async function handleImportArtifact(toolArgs) { + const filePath = resolveArtifactPath(toolArgs.path); + const artifact = readArtifactFile(filePath); + + if (artifact.kind === 'recording') { + latestRecordingSession = artifact.payload; + } else if (artifact.kind === 'replay') { + latestReplaySession = artifact.payload; + } else if (artifact.kind === 'orchestration') { + latestOrchestrationSession = artifact.payload; + } else { + throw new Error('artifact kind mismatch'); + } + + return `name: ${artifact.name}\nkind: ${artifact.kind}\nstatus: imported`; +} + +async function handleDeleteArtifact(toolArgs) { + const name = requireArtifactName(toolArgs.name); + deleteArtifactByName(name); + return `name: ${name}\nstatus: deleted`; +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; const toolName = params.name || ''; const toolArgs = params.arguments || {}; - if (!TOOL_NAMES.includes(toolName)) { + if (!getAvailableToolNames().includes(toolName)) { writeError(id, -32602, `Unknown tool: ${toolName}`); return; } @@ -785,8 +5795,11 @@ async function handleToolCall(message) { try { if (toolName === TOOL_SESSION_INFO) { const pages = await listPageTargets(); + if (pages.length > 0 && findPageIndexById(pages, selectedPageId) === -1) { + selectedPageId = resolveFallbackSelectedPageId(pages, 0); + } writeResponse(id, { - content: [{ type: 'text', text: formatSessionInfo(pages) }], + content: [{ type: 'text', text: formatSessionInfo(pages, selectedPageId) }], }); return; } @@ -815,6 +5828,246 @@ async function handleToolCall(message) { return; } + if (toolName === TOOL_PRESS_KEY) { + const text = await handlePressKey(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_SCROLL) { + const text = await handleScroll(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_SELECT_PAGE) { + const text = await handleSelectPage(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_OPEN_PAGE) { + const text = await handleOpenPage(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CLOSE_PAGE) { + const text = await handleClosePage(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_ADD_INTERCEPT_RULE) { + const text = await handleAddInterceptRule(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_REMOVE_INTERCEPT_RULE) { + const text = await handleRemoveInterceptRule(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_INTERCEPT_RULES) { + const text = await handleListInterceptRules(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_REQUESTS) { + const text = await handleListRequests(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_SET_DOWNLOAD_BEHAVIOR) { + const text = await handleSetDownloadBehavior(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_DOWNLOADS) { + const text = await handleListDownloads(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CANCEL_DOWNLOAD) { + const text = await handleCancelDownload(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_SET_FILE_INPUT) { + const text = await handleSetFileInput(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_DRAG_FILES) { + const text = await handleDragFiles(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_DRAG_ELEMENT) { + const text = await handleDragElement(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_POINTER_ACTION) { + const text = await handlePointerAction(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_START_RECORDING) { + const text = await handleStartRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_STOP_RECORDING) { + const text = await handleStopRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_GET_RECORDING) { + const text = await handleGetRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CLEAR_RECORDING) { + const text = await handleClearRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_START_REPLAY) { + const text = await handleStartReplay(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_GET_REPLAY) { + const text = await handleGetReplay(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CANCEL_REPLAY) { + const text = await handleCancelReplay(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_START_ORCHESTRATION) { + const text = await handleStartOrchestration(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_GET_ORCHESTRATION) { + const text = await handleGetOrchestration(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CANCEL_ORCHESTRATION) { + const text = await handleCancelOrchestration(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_EXPORT_ARTIFACT) { + const text = await handleExportArtifact(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_IMPORT_ARTIFACT) { + const text = await handleImportArtifact(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_ARTIFACTS) { + const text = await handleListArtifacts(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_DELETE_ARTIFACT) { + const text = await handleDeleteArtifact(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + if (toolName === TOOL_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { @@ -823,6 +6076,54 @@ async function handleToolCall(message) { return; } + if (toolName === TOOL_WAIT_FOR) { + const text = await handleWaitFor(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_EVAL) { + const text = await handleEval(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_HOVER) { + const text = await handleHover(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_QUERY) { + const text = await handleQuery(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_TAKE_ELEMENT_SCREENSHOT) { + const text = await handleElementScreenshot(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_WAIT_FOR_EVENT) { + const text = await handleWaitForEvent(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + const { page, pageIndex } = await getSelectedPage(toolArgs); if (toolName === TOOL_URL_TITLE) { @@ -883,7 +6184,7 @@ async function handleMessage(message) { writeResponse(message.id, {}); return; case 'tools/list': - writeResponse(message.id, { tools: getTools() }); + writeResponse(message.id, { tools: getAvailableTools() }); return; case 'tools/call': await handleToolCall(message); @@ -945,11 +6246,13 @@ function parseMessages() { continue; } - Promise.resolve(handleMessage(message)).catch((error) => { - if (message && message.id !== undefined) { - writeError(message.id, -32603, (error && error.message) || 'Internal error'); - } - }); + messageQueue = messageQueue + .then(() => handleMessage(message)) + .catch((error) => { + if (message && message.id !== undefined) { + writeError(message.id, -32603, (error && error.message) || 'Internal error'); + } + }); } } diff --git a/package.json b/package.json index fd47aa7f..c6ab9f49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.73.1", + "version": "7.73.1-dev.12", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", @@ -70,12 +70,14 @@ "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", - "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test:all", + "validate": "bun run typecheck && bun run lint && bun run format:check && bun run test:fast", "validate:ci-parity": "bash scripts/ci-parity-gate.sh", "verify:bundle": "node scripts/verify-bundle.js", "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", - "test:all": "bun test tests/unit tests/integration tests/npm", + "test:fast": "node scripts/run-test-bucket.js fast", + "test:slow": "node scripts/run-test-bucket.js slow", + "test:all": "node scripts/run-test-bucket.js all", "test:unit": "bun test tests/unit", "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", diff --git a/scripts/ci-parity-gate.sh b/scripts/ci-parity-gate.sh index 5e1c7a1f..b325a3d8 100755 --- a/scripts/ci-parity-gate.sh +++ b/scripts/ci-parity-gate.sh @@ -45,7 +45,9 @@ echo "[i] Pre-push CI parity gate" echo " branch: $CURRENT_BRANCH" echo " base: $BASE_BRANCH" -git fetch origin "$BASE_BRANCH" --quiet || true +if git ls-remote --exit-code --heads origin "$BASE_BRANCH" >/dev/null 2>&1; then + git fetch origin "$BASE_BRANCH" --quiet +fi if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then if ! git merge-base --is-ancestor "origin/$BASE_BRANCH" HEAD; then echo "[X] Branch '$CURRENT_BRANCH' is behind origin/$BASE_BRANCH." @@ -55,8 +57,14 @@ if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then fi fi -echo "[i] Running CI-equivalent local checks..." +echo "[i] Running CI-parity local checks..." +# `set -euo pipefail` above makes every step fail fast. Keep these commands +# explicit so parity drift is visible when CI changes. +bun run typecheck +bun run lint +bun run format:check bun run build:all -bun run validate +bun run test:all +CCS_E2E_SKIP_BUILD=1 bun run test:e2e echo "[OK] CI parity gate passed." diff --git a/scripts/ensure-deps.sh b/scripts/ensure-deps.sh new file mode 100644 index 00000000..384a0983 --- /dev/null +++ b/scripts/ensure-deps.sh @@ -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 diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js new file mode 100644 index 00000000..6d97a1ee --- /dev/null +++ b/scripts/run-test-bucket.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const rootDir = path.resolve(__dirname, '..'); +const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm']; +// Add a `.ts` test to `slowTests` when ANY of these apply: +// 1. It spawns a child process (CLI, bun test, node, gh, etc.). +// 2. It binds a port, starts a server, or talks to localhost. +// 3. It reads a real file from `dist/` or the repo root at runtime. +// 4. It waits on a timer > 500ms or a filesystem watcher. +// 5. A single run consistently takes > 1500ms on reference hardware. +// Tests that literally reference `dist/` in source are auto-forced slow by +// `readsBuiltDist`. This list is the manual catch-all for `.ts` tests that +// meet the criteria above without the literal `dist/` string. +// `tests/unit/scripts/run-test-bucket.test.js` verifies every path here exists +// (catches deletion drift) but CANNOT detect new undeclared slow tests. +// Automated perf-budget enforcement tracked in issue #1071. +const slowTests = [ + 'tests/integration/cursor-daemon-lifecycle.test.ts', + 'tests/integration/proxy/daemon-lifecycle.test.ts', + 'tests/unit/commands/persist-command-handler.test.ts', + 'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts', + 'tests/unit/hooks/browser-mcp-downloads-and-files.test.ts', + 'tests/unit/hooks/browser-mcp-navigation-and-query.test.ts', + 'tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts', + 'tests/unit/hooks/browser-mcp-recording-and-replay.test.ts', + 'tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts', + 'tests/unit/targets/codex-runtime-integration.test.ts', + 'tests/unit/targets/codex-settings-bridge-launch.test.ts', + 'tests/unit/targets/droid-command-routing-integration.test.ts', + 'tests/unit/targets/droid-config-manager.test.ts', + 'tests/unit/targets/settings-profile-browser-launch.test.ts', + 'tests/unit/targets/settings-profile-image-analysis-launch.test.ts', + 'tests/unit/targets/settings-profile-websearch-launch.test.ts', + 'tests/unit/web-server/cursor-routes.test.ts', + 'tests/unit/web-server/websearch-routes.test.ts', +]; +// CommonJS-heavy JS suites stay slow by default because many of them mutate +// module cache or process state. Opt them into `test:fast` only after they are +// proven stable in the mixed fast bucket. +const fastJsTests = new Set([ + 'tests/unit/flag-parsing-simple.test.js', +]); + +const filePattern = /(\.test\.(c|m)?[jt]s|\.spec\.(c|m)?[jt]s|-test\.(c|m)?[jt]s)$/; + +function collectFiles(dir, files = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectFiles(fullPath, files); + continue; + } + + if (filePattern.test(entry.name)) { + files.push(path.relative(rootDir, fullPath).split(path.sep).join('/')); + } + } + + return files; +} + +function readsBuiltDist(relativePath) { + const source = fs.readFileSync(path.join(rootDir, relativePath), 'utf8'); + return source.includes('dist/'); +} + +function getDiscoveredTests() { + return candidateRoots + .flatMap((relativeDir) => collectFiles(path.join(rootDir, relativeDir))) + .sort(); +} + +function shouldForceSlow(file) { + if (file.startsWith('tests/npm/')) { + return true; + } + + if (/\.(c|m)?js$/.test(file) && !fastJsTests.has(file)) { + return true; + } + + return readsBuiltDist(file); +} + +function getSlowSet() { + const discovered = getDiscoveredTests(); + const forceSlow = discovered.filter((file) => shouldForceSlow(file)); + return new Set([...slowTests, ...forceSlow]); +} + +function selectBucket(name) { + const discovered = getDiscoveredTests(); + const slowSet = getSlowSet(); + + return name === 'slow' + ? [...slowSet].sort() + : discovered.filter((file) => !slowSet.has(file)); +} + +function ensureBuildForSlowBucket() { + if (fs.existsSync(path.join(rootDir, 'dist', 'ccs.js'))) { + return 0; + } + + const build = spawnSync('bun', ['run', 'build'], { + cwd: rootDir, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + return build.status ?? 1; +} + +function runBucket(name) { + const selected = selectBucket(name); + + if (selected.length === 0) { + console.error(`[X] No tests matched the '${name}' bucket.`); + return 1; + } + + if (name === 'slow') { + const buildStatus = ensureBuildForSlowBucket(); + if (buildStatus !== 0) { + return buildStatus; + } + } + + // Slow bucket forces sequential execution because it spawns subprocesses, + // binds ports, and touches shared state — parallelism causes flakes. + // Fast bucket keeps bun's default parallelism for speed. + const bunArgs = name === 'slow' + ? ['test', '--max-concurrency=1', ...selected] + : ['test', ...selected]; + + const result = spawnSync('bun', bunArgs, { + cwd: rootDir, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + return result.status ?? 1; +} + +function main(args = process.argv.slice(2)) { + const bucket = args[0]; + + if (!['fast', 'slow', 'all'].includes(bucket)) { + console.error('[X] Usage: node scripts/run-test-bucket.js '); + return 1; + } + + if (bucket === 'all') { + let exitCode = 0; + + for (const name of ['fast', 'slow']) { + const status = runBucket(name); + if (status !== 0) { + exitCode = status; + } + } + + return exitCode; + } + + return runBucket(bucket); +} + +if (require.main === module) { + process.exit(main()); +} + +module.exports = { + slowTests, + fastJsTests, + readsBuiltDist, + shouldForceSlow, + getDiscoveredTests, + getSlowSet, + selectBucket, + main, +}; diff --git a/src/ccs.ts b/src/ccs.ts index 8a5371fa..23389316 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -39,7 +39,10 @@ import { import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, + getBlockedBrowserOverrideWarning, getEffectiveClaudeBrowserAttachConfig, + resolveBrowserExposure, + resolveBrowserLaunchFlagResolution, resolveOptionalBrowserAttachRuntime, syncBrowserMcpToConfigDir, } from './utils/browser'; @@ -77,12 +80,13 @@ import { handleError, runCleanup } from './errors'; import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions -import { execClaude } from './utils/shell-executor'; +import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; 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 + target: ReturnType, + 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 { } 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', @@ -697,7 +721,38 @@ async function main(): Promise { // 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.`)); @@ -1057,13 +1112,11 @@ async function main(): Promise { // Settings-based profiles (glm, glmt) are third-party providers const imageAnalysisMcpReady = resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; - const browserAttachConfig = - resolvedTarget === 'claude' - ? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) - : undefined; const browserAttachRuntime = - resolvedTarget === 'claude' && browserAttachConfig?.enabled - ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) + resolvedTarget === 'claude' && + claudeBrowserExposure?.exposeForLaunch && + claudeAttachConfig?.enabled + ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) : undefined; const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; if (browserAttachRuntime?.warning) { @@ -1276,6 +1329,10 @@ async function main(): Promise { ...imageAnalysisEnv, CCS_CURRENT_PROVIDER: '', CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: '', + CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED: '0', }; } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { const ensureServiceResult = await ensureCliproxyService( @@ -1306,11 +1363,24 @@ async function main(): Promise { console.error(info(`Global env: ${envNames}`)); } - // Explicitly inject effective settings env vars so stale ANTHROPIC_* - // values from prior sessions cannot leak into the active profile. + // For Claude target launches that already pass `--settings`, keep runtime + // env free of ANTHROPIC routing/auth while preserving non-routing profile + // env so nested Team/subagent sessions can still inherit model intent and + // other profile-scoped runtime flags. + const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv }); + const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { + ...stripAnthropicRoutingEnv(settingsRuntimeEnv), + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + ...webSearchEnv, + ...imageAnalysisEnv, + ...(browserRuntimeEnv || {}), + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + }; + + // Non-Claude targets still need effective credentials injected directly. const envVars: NodeJS.ProcessEnv = { - ...globalEnv, - ...settingsEnv, + ...settingsRuntimeEnv, ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, @@ -1419,7 +1489,7 @@ async function main(): Promise { settingsPath: expandedSettingsPath, }); - execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv }); + execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv }); } else if (profileInfo.type === 'account') { // NEW FLOW: Account-based profile (work, personal) // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR @@ -1467,13 +1537,11 @@ async function main(): Promise { CCS_WEBSEARCH_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', }; - const browserAttachConfig = - resolvedTarget === 'claude' - ? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) - : undefined; const browserAttachRuntime = - resolvedTarget === 'claude' && browserAttachConfig?.enabled - ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) + resolvedTarget === 'claude' && + claudeBrowserExposure?.exposeForLaunch && + claudeAttachConfig?.enabled + ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) : undefined; const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; if (browserAttachRuntime?.warning) { diff --git a/src/cliproxy/account-safety.ts b/src/cliproxy/account-safety.ts index f8f61e0c..d7b26607 100644 --- a/src/cliproxy/account-safety.ts +++ b/src/cliproxy/account-safety.ts @@ -37,10 +37,26 @@ interface AutoPausedFile { sessions: AutoPausedSession[]; } +interface QuotaPausedEntry { + provider: CLIProxyProvider; + accountId: string; + pausedAt: string; + until: number; + reason: 'quota_exhausted'; +} + +interface QuotaPausedFile { + entries: QuotaPausedEntry[]; +} + function getAutoPausedPath(): string { return path.join(getCcsDir(), 'cliproxy', 'auto-paused.json'); } +function getQuotaPausedPath(): string { + return path.join(getCcsDir(), 'cliproxy', 'quota-paused.json'); +} + function loadAutoPaused(): AutoPausedFile { try { const filePath = getAutoPausedPath(); @@ -69,6 +85,48 @@ function saveAutoPaused(data: AutoPausedFile): void { fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); } +function loadQuotaPaused(): QuotaPausedFile { + try { + const filePath = getQuotaPausedPath(); + if (fs.existsSync(filePath)) { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { + entries?: unknown; + }; + if (Array.isArray(data.entries)) { + return { + entries: data.entries.filter( + (entry): entry is QuotaPausedEntry => + typeof entry === 'object' && + entry !== null && + typeof (entry as QuotaPausedEntry).provider === 'string' && + typeof (entry as QuotaPausedEntry).accountId === 'string' && + typeof (entry as QuotaPausedEntry).pausedAt === 'string' && + Number.isFinite((entry as QuotaPausedEntry).until) + ), + }; + } + } + } catch { + // Corrupted or malformed file — start fresh + } + return { entries: [] }; +} + +function saveQuotaPaused(data: QuotaPausedFile): void { + const filePath = getQuotaPausedPath(); + if (data.entries.length === 0) { + try { + fs.unlinkSync(filePath); + } catch { + /* already gone */ + } + return; + } + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); +} + /** * Check if a process is alive. NOTE: PIDs can be recycled by the OS. * If a stale PID is reused by an unrelated process, cleanup is deferred until that process exits. @@ -325,6 +383,93 @@ export function cleanupStaleAutoPauses(): void { } } +/** + * Resume quota-paused accounts whose cooldown windows have expired. + * Auto-resume only applies to pauses created by CCS quota handling. + */ +export function restoreExpiredQuotaPauses(now = Date.now()): number { + const data = loadQuotaPaused(); + if (data.entries.length === 0) return 0; + + const keep: QuotaPausedEntry[] = []; + let resumed = 0; + const registry = loadAccountsRegistry(); + + for (const entry of data.entries) { + if (!Number.isFinite(entry.until) || entry.until > now) { + keep.push(entry); + continue; + } + + const account = registry.providers[entry.provider]?.accounts[entry.accountId]; + if (!account?.paused) { + continue; + } + + // Only auto-resume the exact pause CCS created for quota cooldown. + // Missing or changed pausedAt metadata is treated as a mismatch so we do + // not accidentally resume a manually paused account. + if (account.pausedAt !== entry.pausedAt) { + continue; + } + + if (resumeAccount(entry.provider, entry.accountId)) { + resumed += 1; + continue; + } + + // Resume failures are treated as transient I/O/state issues. Keep the + // quota-pause record so the next restore pass can retry instead of leaving + // the account paused forever without any cooldown metadata. + keep.push(entry); + } + + saveQuotaPaused({ entries: keep }); + return resumed; +} + +/** + * Temporarily remove an exhausted account from CLIProxy rotation for the + * configured cooldown window. Returns false when the account was already paused + * or could not be paused, so callers can fall back to in-memory cooldown only. + */ +export function pauseAccountForQuotaCooldown( + provider: CLIProxyProvider, + accountId: string, + cooldownMinutes: number, + now = Date.now() +): boolean { + const registryBefore = loadAccountsRegistry(); + const accountBefore = registryBefore.providers[provider]?.accounts[accountId]; + if (!accountBefore || accountBefore.paused) { + return false; + } + + if (!pauseAccount(provider, accountId)) { + return false; + } + + const registryAfter = loadAccountsRegistry(); + const pausedAt = registryAfter.providers[provider]?.accounts[accountId]?.pausedAt; + if (!pausedAt) { + return false; + } + + const data = loadQuotaPaused(); + data.entries = data.entries.filter( + (entry) => !(entry.provider === provider && entry.accountId === accountId) + ); + data.entries.push({ + provider, + accountId, + pausedAt, + until: now + cooldownMinutes * 60 * 1000, + reason: 'quota_exhausted', + }); + saveQuotaPaused(data); + return true; +} + /** * Enforce provider isolation by auto-pausing conflicting accounts in other providers. * Records paused accounts for crash recovery and session exit restore. @@ -553,6 +698,7 @@ export async function handleQuotaExhaustion( const alternative = await findHealthyAccount(provider, [accountId]); if (alternative) { + pauseAccountForQuotaCooldown(provider, accountId, cooldownMinutes); setDefaultAccount(provider, alternative.id); touchAccount(provider, alternative.id); writeQuotaExhausted(accountId, alternative.id, cooldownMinutes); diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 081c7b5c..95c486b3 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -40,11 +40,14 @@ import { import type { CLIProxyBackend } from './types'; import { getVersionListCachePath } from './binary/version-cache'; -export const CLIPROXY_PLUS_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062'; +export const CLIPROXY_DELETED_PLUS_REPO = 'router-for-me/CLIProxyAPIPlus'; +export const CLIPROXY_PLUS_FALLBACK_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062'; +export const CLIPROXY_PLUS_FORK_URL = 'https://github.com/kaitranntt/CLIProxyAPIPlus'; +export const CLIPROXY_PLUS_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1065'; /** - * Track whether we've already warned the user about the Plus fallback this - * process lifetime. Prevents spamming the warning on every command. + * Track whether we've already warned the user about the legacy Plus fallback + * this process lifetime. Prevents spamming the warning on every command. */ let plusFallbackWarned = false; @@ -53,8 +56,27 @@ function emitPlusFallbackWarning(): void { plusFallbackWarned = true; process.stderr.write( `${warn( - 'CLIProxyAPIPlus upstream repo is currently unavailable; local CLIProxy is falling back to ' + - '`backend: original`. Run `ccs config` to update your saved config. ' + + 'Legacy CLIProxyAPIPlus upstream repo is unavailable; local CLIProxy is falling back to ' + + '`backend: original`. Run `ccs config` to use the maintained Plus fork. ' + + `Tracking: ${CLIPROXY_PLUS_FALLBACK_TRACKING_URL}` + )}\n` + ); +} + +/** + * Track whether we've already shown the optional Plus backend notice this + * process lifetime. Prevents spamming the message on every command. + */ +let plusBackendInfoShown = false; + +function emitPlusBackendInfo(): void { + if (plusBackendInfoShown) return; + plusBackendInfoShown = true; + process.stderr.write( + `${info( + 'Using optional community-maintained CLIProxyAPIPlus backend. Stay on ' + + '`backend: original` unless you need plus-only providers. ' + + `Fork: ${CLIPROXY_PLUS_FORK_URL} ` + `Tracking: ${CLIPROXY_PLUS_TRACKING_URL}` )}\n` ); @@ -63,22 +85,21 @@ function emitPlusFallbackWarning(): void { export function getPlusBackendUnavailableMessage(provider?: string): string { const prefix = provider ? `${provider} requires CLIProxyAPIPlus,` - : 'CLIProxyAPIPlus upstream repo is currently unavailable,'; + : 'This provider requires CLIProxyAPIPlus,'; return ( - `${prefix} but local CLIProxy currently supports only \`backend: original\`. ` + - `Tracking: ${CLIPROXY_PLUS_TRACKING_URL}` + `${prefix} which is available through the optional community-maintained ` + + `\`backend: plus\` fork. Set \`cliproxy.backend: plus\` in config.yaml ` + + `only if you need plus-only providers. Fork: ${CLIPROXY_PLUS_FORK_URL}` ); } -export function resolveLocalBackend( - backend: CLIProxyBackend = DEFAULT_BACKEND, - options: { warnOnFallback?: boolean } = {} -): CLIProxyBackend { - if (backend !== 'plus') return backend; - if (options.warnOnFallback) { - emitPlusFallbackWarning(); - } - return 'original'; +interface ResolveLocalBackendOptions { + notifyOnPlus?: boolean; + warnOnFallback?: boolean; +} + +function isPlusBackendFallbackActive(): boolean { + return (BACKEND_CONFIG.plus.repo as string) === CLIPROXY_DELETED_PLUS_REPO; } function copyFallbackStateIfMissing(sourcePath: string, targetPath: string): void { @@ -88,7 +109,7 @@ function copyFallbackStateIfMissing(sourcePath: string, targetPath: string): voi } export function syncPlusFallbackStateIfNeeded(configuredBackend: CLIProxyBackend): void { - if (configuredBackend !== 'plus') return; + if (configuredBackend !== 'plus' || !isPlusBackendFallbackActive()) return; const plusDir = getBackendBinDir('plus'); const originalDir = getBackendBinDir('original'); @@ -103,6 +124,26 @@ export function syncPlusFallbackStateIfNeeded(configuredBackend: CLIProxyBackend copyFallbackStateIfMissing(getVersionListCachePath('plus'), getVersionListCachePath('original')); } +export function resolveLocalBackend( + backend: CLIProxyBackend = DEFAULT_BACKEND, + options: ResolveLocalBackendOptions = {} +): CLIProxyBackend { + if (backend !== 'plus') return backend; + + if (isPlusBackendFallbackActive()) { + syncPlusFallbackStateIfNeeded(backend); + if (options.warnOnFallback) { + emitPlusFallbackWarning(); + } + return 'original'; + } + + if (options.notifyOnPlus) { + emitPlusBackendInfo(); + } + return backend; +} + function getConfiguredOrDefaultBackend(): CLIProxyBackend { try { const config = loadOrCreateUnifiedConfig(); @@ -117,16 +158,10 @@ export function getStoredConfiguredBackend(): CLIProxyBackend { } /** - * Get backend from config, with runtime fallback to 'original' when the user - * still has `backend: plus` saved. - * - * Context (issue #1062): the upstream `router-for-me/CLIProxyAPIPlus` repo was - * deleted, so any `backend: plus` install/update path hits a 404. Rather than - * forcing users to manually edit config.yaml, we degrade to `original` at - * runtime and warn once. This keeps existing installations working without a - * reconfig step, while CCS self-maintains its own Plus fork (future work). + * Get backend from config. Plus remains opt-in and resolves to the CCS-maintained + * community fork instead of degrading to original. */ -export function getConfiguredBackend(options: { warnOnFallback?: boolean } = {}): CLIProxyBackend { +export function getConfiguredBackend(options: ResolveLocalBackendOptions = {}): CLIProxyBackend { return resolveLocalBackend(getConfiguredOrDefaultBackend(), options); } @@ -164,8 +199,7 @@ export class BinaryManager { constructor(config: Partial = {}, backend?: CLIProxyBackend) { const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); - syncPlusFallbackStateIfNeeded(configuredBackend); - this.backend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + this.backend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); const defaultConfig = createDefaultConfig(this.backend); this.config = { ...defaultConfig, ...config }; } @@ -217,8 +251,7 @@ export async function ensureCLIProxyBinary( options: EnsureCLIProxyBinaryOptions = {} ): Promise { const configuredBackend = getConfiguredOrDefaultBackend(); - syncPlusFallbackStateIfNeeded(configuredBackend); - const backend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const backend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); // Migrate old shared pin to backend-specific location (one-time migration) migrateVersionPin(backend); @@ -250,24 +283,21 @@ export async function ensureCLIProxyBinary( /** Check if CLIProxyAPI binary is installed */ export function isCLIProxyInstalled(backend?: CLIProxyBackend): boolean { const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); return new BinaryManager({}, effectiveBackend).isBinaryInstalled(); } /** Get CLIProxyAPI binary path (may not exist) */ export function getCLIProxyPath(backend?: CLIProxyBackend): string { const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); return new BinaryManager({}, effectiveBackend).getBinaryPath(); } /** Get installed CLIProxyAPI version from .version file */ export function getInstalledCliproxyVersion(backend?: CLIProxyBackend): string { const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); return readInstalledVersion( getBackendBinDir(effectiveBackend), BACKEND_CONFIG[effectiveBackend].fallbackVersion @@ -294,8 +324,7 @@ export async function installCliproxyVersion( deps: InstallCliproxyVersionDeps = {} ): Promise { const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); const manager = deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ?? new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); @@ -337,8 +366,7 @@ export async function installCliproxyVersion( /** Fetch the latest CLIProxyAPI version from GitHub API */ export async function fetchLatestCliproxyVersion(backend?: CLIProxyBackend): Promise { const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); const result = await new BinaryManager({}, effectiveBackend).checkForUpdates(); return result.latestVersion; } @@ -364,8 +392,7 @@ export async function checkCliproxyUpdate( backend?: CLIProxyBackend ): Promise { const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); const result = await new BinaryManager({}, effectiveBackend).checkForUpdates(); // Import isNewerVersion for stability check diff --git a/src/cliproxy/binary/types.ts b/src/cliproxy/binary/types.ts index c82a05cc..22e6d6fa 100644 --- a/src/cliproxy/binary/types.ts +++ b/src/cliproxy/binary/types.ts @@ -29,14 +29,14 @@ export const VERSION_PIN_FILE = '.version-pin'; * @deprecated Use getGitHubApiUrls(backend) instead */ export const GITHUB_API_LATEST_RELEASE = - 'https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases/latest'; + 'https://api.github.com/repos/kaitranntt/CLIProxyAPIPlus/releases/latest'; export const GITHUB_API_ALL_RELEASES = - 'https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases'; + 'https://api.github.com/repos/kaitranntt/CLIProxyAPIPlus/releases'; /** GitHub repos per backend */ export const GITHUB_REPOS = { original: 'router-for-me/CLIProxyAPI', - plus: 'router-for-me/CLIProxyAPIPlus', + plus: 'kaitranntt/CLIProxyAPIPlus', } as const; /** Get GitHub API URLs for specific backend */ diff --git a/src/cliproxy/catalog-cache.ts b/src/cliproxy/catalog-cache.ts index 10436887..998c86c3 100644 --- a/src/cliproxy/catalog-cache.ts +++ b/src/cliproxy/catalog-cache.ts @@ -264,9 +264,17 @@ export function mergeCatalog( mergedIds.add(remote.id.toLowerCase()); if (staticEntry) { + const mergedThinking = remoteEntry.thinking + ? { + ...remoteEntry.thinking, + maxLevel: remoteEntry.thinking.maxLevel ?? staticEntry.thinking?.maxLevel, + } + : staticEntry.thinking; + // Merge: remote overrides, static fills gaps mergedModels.push({ ...remoteEntry, + thinking: mergedThinking, // Preserve static-only fields tier: staticEntry.tier, broken: staticEntry.broken, diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index b24508e4..e3f98907 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -24,6 +24,8 @@ export interface CodexReasoningProxyConfig { modelMap: CodexReasoningModelMap; defaultEffort?: CodexReasoningEffort; traceFilePath?: string; + /** Skip TLS certificate validation for self-signed remote HTTPS proxies */ + allowSelfSigned?: boolean; /** * Path prefix to strip from incoming requests before forwarding to upstream. * Used for remote proxy mode where upstream expects /v1/messages, not /api/provider/codex/v1/messages. @@ -179,6 +181,7 @@ export class CodexReasoningProxy { | 'defaultEffort' | 'traceFilePath' | 'disableEffort' + | 'allowSelfSigned' > > & Pick; @@ -203,6 +206,7 @@ export class CodexReasoningProxy { traceFilePath: config.traceFilePath ?? '', stripPathPrefix: config.stripPathPrefix, disableEffort: config.disableEffort ?? false, + allowSelfSigned: config.allowSelfSigned ?? false, }; this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort); } @@ -508,6 +512,38 @@ export class CodexReasoningProxy { return url.protocol === 'https:' ? https.request : http.request; } + private startResponseTimeout(upstreamReq: http.ClientRequest): () => void { + const deadline = setTimeout(() => { + upstreamReq.destroy(new Error('Upstream request timeout')); + }, this.config.timeoutMs); + upstreamReq.setTimeout(this.config.timeoutMs, () => { + upstreamReq.destroy(new Error('Upstream request timeout')); + }); + return () => clearTimeout(deadline); + } + + private buildRequestOptions( + upstreamUrl: URL, + method: string | undefined, + headers: http.OutgoingHttpHeaders + ): https.RequestOptions { + const options: https.RequestOptions = { + protocol: upstreamUrl.protocol, + hostname: upstreamUrl.hostname, + port: upstreamUrl.port, + path: upstreamUrl.pathname + upstreamUrl.search, + method, + timeout: this.config.timeoutMs, + headers, + }; + + if (upstreamUrl.protocol === 'https:' && this.config.allowSelfSigned) { + options.rejectUnauthorized = false; + } + + return options; + } + private forwardRaw( originalReq: http.IncomingMessage, clientRes: http.ServerResponse, @@ -515,17 +551,15 @@ export class CodexReasoningProxy { ): Promise { return new Promise((resolve, reject) => { const requestFn = this.getRequestFn(upstreamUrl); + let clearResponseTimeout: () => void = () => undefined; const upstreamReq = requestFn( - { - protocol: upstreamUrl.protocol, - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: originalReq.method, - timeout: this.config.timeoutMs, - headers: this.buildForwardHeaders(originalReq.headers), - }, + this.buildRequestOptions( + upstreamUrl, + originalReq.method, + this.buildForwardHeaders(originalReq.headers) + ), (upstreamRes) => { + clearResponseTimeout(); clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); upstreamRes.pipe(clientRes); upstreamRes.on('end', () => resolve()); @@ -533,8 +567,12 @@ export class CodexReasoningProxy { } ); + clearResponseTimeout = this.startResponseTimeout(upstreamReq); upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); - upstreamReq.on('error', reject); + upstreamReq.on('error', (err) => { + clearResponseTimeout(); + reject(err); + }); originalReq.pipe(upstreamReq); }); } @@ -549,17 +587,15 @@ export class CodexReasoningProxy { return new Promise((resolve, reject) => { const bodyString = JSON.stringify(body); const requestFn = this.getRequestFn(upstreamUrl); + let clearResponseTimeout: () => void = () => undefined; const upstreamReq = requestFn( - { - protocol: upstreamUrl.protocol, - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: originalReq.method, - timeout: this.config.timeoutMs, - headers: this.buildForwardHeaders(originalReq.headers, bodyString), - }, + this.buildRequestOptions( + upstreamUrl, + originalReq.method, + this.buildForwardHeaders(originalReq.headers, bodyString) + ), (upstreamRes) => { + clearResponseTimeout(); const statusCode = upstreamRes.statusCode || 200; if (statusCode >= 200 && statusCode < 300) { clientRes.writeHead(statusCode, upstreamRes.headers); @@ -631,8 +667,12 @@ export class CodexReasoningProxy { } ); + clearResponseTimeout = this.startResponseTimeout(upstreamReq); upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); - upstreamReq.on('error', reject); + upstreamReq.on('error', (err) => { + clearResponseTimeout(); + reject(err); + }); upstreamReq.write(bodyString); upstreamReq.end(); }); diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index dafd0c95..417d5f04 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -42,7 +42,6 @@ interface ProviderSettings { const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; /** Replacement prefix matching actual upstream model names */ const UPSTREAM_MODEL_PREFIX = 'claude-'; -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', @@ -62,10 +61,6 @@ function isObjectRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function stripCodexEffortSuffix(modelId: string): string { - return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); -} - /** * Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file. * CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention. @@ -130,58 +125,6 @@ function migrateDeprecatedModelNames( return migrated; } -/** - * Migrate codex effort-suffixed model IDs in settings to canonical IDs. - * Example: gpt-5.3-codex-xhigh -> gpt-5.3-codex - */ -function migrateCodexEffortSuffixes( - settingsPath: string, - provider: CLIProxyProvider, - settings: ProviderSettings -): boolean { - if (provider !== 'codex') return false; - if (!settings.env || typeof settings.env !== 'object') return false; - - let migrated = false; - - for (const key of MODEL_ENV_VAR_KEYS) { - const value = settings.env[key]; - if (typeof value !== 'string') continue; - const canonical = stripCodexEffortSuffix(value); - if (canonical !== value) { - settings.env[key] = canonical; - migrated = true; - } - } - - if (Array.isArray(settings.presets)) { - for (const preset of settings.presets) { - if (!preset || typeof preset !== 'object') continue; - const presetRecord = preset as Record; - - for (const key of PRESET_MODEL_KEYS) { - const value = presetRecord[key]; - if (typeof value !== 'string') continue; - const canonical = stripCodexEffortSuffix(value); - if (canonical !== value) { - presetRecord[key] = canonical; - migrated = true; - } - } - } - } - - if (migrated) { - try { - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); - } catch { - // Best-effort migration — don't block startup if write fails - } - } - - return migrated; -} - /** * Migrate legacy iFlow model IDs to current upstream model IDs. * Example: iflow-default -> qwen3-coder-plus, kimi-k2.5 -> kimi-k2 @@ -510,8 +453,6 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present migrateDeprecatedModelNames(expandedPath, provider, settings); - // Migrate codex effort suffixes to canonical IDs if present - migrateCodexEffortSuffixes(expandedPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs migrateIFlowPlaceholderModel(expandedPath, provider, settings); // Custom variant settings found - merge with global env @@ -545,8 +486,6 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present migrateDeprecatedModelNames(settingsPath, provider, settings); - // Migrate codex effort suffixes to canonical IDs if present - migrateCodexEffortSuffixes(settingsPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs migrateIFlowPlaceholderModel(settingsPath, provider, settings); // User override found - merge with global env @@ -719,7 +658,6 @@ export function getRemoteEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(expandedPath, provider, settings); - migrateCodexEffortSuffixes(expandedPath, provider, settings); migrateIFlowPlaceholderModel(expandedPath, provider, settings); userEnvVars = settings.env as Record; } @@ -739,7 +677,6 @@ export function getRemoteEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(settingsPath, provider, settings); - migrateCodexEffortSuffixes(settingsPath, provider, settings); migrateIFlowPlaceholderModel(settingsPath, provider, settings); userEnvVars = settings.env as Record; } diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index d9f2b39f..ef98234e 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -7,7 +7,7 @@ import type { CLIProxyProvider } from '../types'; import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types'; import type { ThinkingConfig } from '../../config/unified-config-types'; import { getThinkingConfig } from '../../config/unified-config-loader'; -import { supportsThinking } from '../model-catalog'; +import { getModelThinkingSupport, supportsThinking } from '../model-catalog'; import { isThinkingOffValue, validateThinking } from '../thinking-validator'; import { normalizeModelIdForProvider } from '../model-id-normalizer'; import { warn } from '../../utils/ui'; @@ -86,7 +86,8 @@ function applyThinkingSuffixForProvider( const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/); // Existing parenthesized suffix: - // - keep as-is for non-codex providers + // - keep as-is for non-codex providers unless the target model now expects + // named levels and we need to rewrite an old numeric suffix // - for codex effort levels, normalize to codex model suffix style if (parenthesizedSuffixMatch) { if (provider === 'codex') { @@ -95,6 +96,15 @@ function applyThinkingSuffixForProvider( return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`); } } + + if (provider) { + const normalizedBaseModel = normalizeModelForThinkingLookup(model, provider); + const thinking = getModelThinkingSupport(provider, normalizedBaseModel); + if (thinking?.type === 'levels') { + return model.replace(/\([^)]+\)$/, `(${thinkingValue})`); + } + } + return model; } diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index bafcea63..b5872947 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -28,7 +28,7 @@ import { import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status'; import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; -import { stripClaudeCodeEnv } from '../../utils/shell-executor'; +import { stripBrowserEnv, stripClaudeCodeEnv } from '../../utils/shell-executor'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; @@ -383,12 +383,17 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record v !== undefined) + const baseEnv = stripBrowserEnv( + Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined)) as Record< + string, + string + > ) as Record; - const effectiveEnvVarsFiltered = Object.fromEntries( - Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined) + const effectiveEnvVarsFiltered = stripBrowserEnv( + Object.fromEntries( + Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined) + ) as Record ) as Record; const mergedEnv = { diff --git a/src/cliproxy/executor/https-tunnel-policy.ts b/src/cliproxy/executor/https-tunnel-policy.ts new file mode 100644 index 00000000..09cb8ec2 --- /dev/null +++ b/src/cliproxy/executor/https-tunnel-policy.ts @@ -0,0 +1,33 @@ +import type { CLIProxyProvider } from '../types'; + +interface HttpsTunnelPolicyInput { + provider: CLIProxyProvider; + useRemoteProxy: boolean; + protocol?: 'http' | 'https'; + host?: string; + isComposite?: boolean; +} + +/** + * Claude only needs the HTTPS tunnel when it would otherwise talk directly to a + * remote HTTPS CLIProxy. Single-provider Codex launches already route Claude + * through local HTTP proxy layers, so those layers can connect upstream via + * HTTPS without adding another local tunnel. + */ +export function shouldStartHttpsTunnel({ + provider, + useRemoteProxy, + protocol, + host, + isComposite, +}: HttpsTunnelPolicyInput): boolean { + if (!useRemoteProxy || protocol !== 'https' || !host) { + return false; + } + + if (provider === 'codex' && !isComposite) { + return false; + } + + return true; +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 8d5e67ef..90db6209 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -68,8 +68,12 @@ import { } from '../../utils/image-analysis'; import { appendBrowserToolArgs, + type BrowserLaunchOverride, ensureBrowserMcpOrThrow, + getBlockedBrowserOverrideWarning, getEffectiveClaudeBrowserAttachConfig, + resolveBrowserExposure, + resolveBrowserLaunchFlagResolution, resolveOptionalBrowserAttachRuntime, syncBrowserMcpToConfigDir, } from '../../utils/browser'; @@ -122,6 +126,7 @@ import { resolveRuntimeThinkingOverride, shouldDisableCodexReasoning, } from './thinking-override-resolver'; +import { shouldStartHttpsTunnel } from './https-tunnel-policy'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -233,6 +238,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) { @@ -252,10 +284,10 @@ export async function execClaudeWithCLIProxy( // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); - const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); - const browserAttachRuntime = browserAttachConfig.enabled - ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) - : undefined; + const browserAttachRuntime = + browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch + ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) + : undefined; const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; if (browserAttachRuntime?.warning) { process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); @@ -320,14 +352,14 @@ export async function execClaudeWithCLIProxy( } if (!useRemoteProxy) { - localBackend = getConfiguredBackend({ warnOnFallback: true }); + localBackend = getConfiguredBackend({ notifyOnPlus: true }); for (const p of allProviders) { if (localBackend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) { console.error(''); console.error(fail(getPlusBackendUnavailableMessage(p))); console.error(''); - throw new Error(`Provider ${p} is temporarily unavailable on local CLIProxy`); + throw new Error(`Provider ${p} requires local CLIProxy Plus backend`); } } } @@ -543,7 +575,7 @@ export async function execClaudeWithCLIProxy( console.error(' Alias: --thinking xhigh (same behavior)'); } else { console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); - console.error(' Levels: minimal, low, medium, high, xhigh, auto'); + console.error(' Levels: minimal, low, medium, high, xhigh, max, auto'); } process.exit(1); @@ -986,7 +1018,15 @@ export async function execClaudeWithCLIProxy( let httpsTunnel: HttpsTunnelProxy | null = null; let tunnelPort: number | null = null; - if (useRemoteProxy && proxyConfig.protocol === 'https' && proxyConfig.host) { + const useHttpsTunnel = shouldStartHttpsTunnel({ + provider, + useRemoteProxy, + protocol: proxyConfig.protocol, + host: proxyConfig.host, + isComposite: cfg.isComposite, + }); + + if (useHttpsTunnel && proxyConfig.host) { try { httpsTunnel = new HttpsTunnelProxy({ remoteHost: proxyConfig.host, @@ -997,13 +1037,15 @@ export async function execClaudeWithCLIProxy( }); tunnelPort = await httpsTunnel.start(); log( - `HTTPS tunnel started on port ${tunnelPort} → https://${proxyConfig.host}:${proxyConfig.port}` + `HTTPS tunnel started on port ${tunnelPort} -> https://${proxyConfig.host}:${proxyConfig.port}` ); } catch (error) { const err = error as Error; console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`)); throw new Error(`HTTPS tunnel startup failed: ${err.message}`); } + } else if (useRemoteProxy && proxyConfig.protocol === 'https' && provider === 'codex') { + log('HTTPS tunnel skipped for Codex; local proxy chain will connect to remote HTTPS directly'); } const imageAnalysisProxyTarget = @@ -1104,6 +1146,7 @@ export async function execClaudeWithCLIProxy( upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL, verbose, warnOnSanitize: true, + allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, }); toolSanitizationPort = await toolSanitizationProxy.start(); log(`Tool sanitization proxy active on port ${toolSanitizationPort}`); @@ -1142,6 +1185,7 @@ export async function execClaudeWithCLIProxy( defaultEffort: 'medium', disableEffort: codexThinkingOff, traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '', + allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, modelMap: { defaultModel: initialEnvVars.ANTHROPIC_MODEL, opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL, @@ -1262,7 +1306,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; @@ -1272,14 +1316,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; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index f4187873..ee1032f5 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -33,7 +33,7 @@ export interface ThinkingSupport { /** Valid level names (for levels type) */ levels?: string[]; /** Maximum reasoning effort level (caps effort at this level for levels type) */ - maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; + maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; /** Whether zero/disabled thinking is allowed */ zeroAllowed?: boolean; /** Whether dynamic/auto thinking is allowed */ @@ -290,10 +290,27 @@ export const MODEL_CATALOG: Partial> = 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, + // Opus 4.7 only supports adaptive thinking on the Anthropic API; manual + // thinking.type: "enabled" with budget_tokens is rejected with 400. + // Expose effort levels; the proxy translates these into adaptive effort. + // `max` is a distinct adaptive effort above `xhigh` exposed by Anthropic. + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', + 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', diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 8b99316d..843bfa1b 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -15,16 +15,8 @@ import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; import { normalizeModelIdForProvider } from './model-id-normalizer'; -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; - -function stripCodexEffortSuffix(model: string, provider: CLIProxyProvider): string { - if (provider !== 'codex') return model; - return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); -} - function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string): string { - const withoutCodexSuffix = stripCodexEffortSuffix(model, provider); - return normalizeModelIdForProvider(withoutCodexSuffix, provider); + return normalizeModelIdForProvider(model, provider); } /** diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 192a369f..7bdaf503 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -65,6 +65,18 @@ function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: st }; } +function splitCodexEffortSuffix(model: string): { baseModel: string; suffix: string } { + const match = model.match(CODEX_EFFORT_SUFFIX_REGEX); + if (!match?.[0]) { + return { baseModel: model, suffix: '' }; + } + + return { + baseModel: model.slice(0, -match[0].length), + suffix: match[0], + }; +} + /** * Extract provider segment from `/api/provider/{provider}` request paths. * @@ -97,20 +109,24 @@ export function isIFlowProvider(provider: ProviderLike): boolean { return provider.trim().toLowerCase() === 'iflow'; } -/** Normalize Codex effort-suffixed IDs to canonical IDs. */ +/** Strip Codex effort suffixes while preserving trailing config suffixes. */ export function stripCodexEffortSuffix(model: string): string { - return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); + const trimmed = trimModelId(model); + const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); + const { baseModel: withoutEffort } = splitCodexEffortSuffix(baseModel); + return `${withoutEffort}${suffix}`; } /** Normalize legacy Codex aliases to the current public Codex model IDs. */ export function normalizeCodexLegacyModelAliases(model: string): string { const trimmed = trimModelId(model); const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); - const replacement = CODEX_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()]; + const { baseModel: baseWithoutEffort, suffix: effortSuffix } = splitCodexEffortSuffix(baseModel); + const replacement = CODEX_LEGACY_MODEL_ALIASES[baseWithoutEffort.trim().toLowerCase()]; if (!replacement) { return trimmed; } - return `${replacement}${suffix}`; + return `${replacement}${effortSuffix}${suffix}`; } /** @@ -217,15 +233,12 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik /** * Canonicalize model ID for provider-specific compatibility. - * - Codex: strip effort suffixes. + * - Codex: preserve valid effort suffixes while normalizing legacy aliases. * - Antigravity: normalize dotted/historical aliases. */ export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string { const trimmedModel = trimModelId(model); - const withoutCodexSuffix = isCodexProvider(provider) - ? stripCodexEffortSuffix(trimmedModel) - : trimmedModel; - return normalizeModelIdForProvider(withoutCodexSuffix, provider); + return normalizeModelIdForProvider(trimmedModel, provider); } /** diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index 6c73c533..8a75dc4e 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -22,20 +22,19 @@ export const BACKEND_CONFIG = { fallbackVersion: '6.8.2', }, plus: { - repo: 'router-for-me/CLIProxyAPIPlus', + repo: 'kaitranntt/CLIProxyAPIPlus', binaryPrefix: 'CLIProxyAPIPlus', executable: 'cli-proxy-api-plus', - fallbackVersion: '6.8.2-0', + fallbackVersion: '6.9.36-0', }, } as const; /** * Default backend * - * Set to 'original' because upstream `router-for-me/CLIProxyAPIPlus` was - * deleted (issue #1062). The original `router-for-me/CLIProxyAPI` repo is - * still maintained. Users with existing `backend: plus` configs are migrated - * at runtime via a 404 fallback in the installer (see binary/installer.ts). + * Keep 'original' as the stable default. The Plus backend is available as an + * opt-in community-maintained fork for providers that are not available in the + * original upstream binary. */ export const DEFAULT_BACKEND: CLIProxyBackend = 'original'; diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index 31c46780..ce3b59a8 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -286,6 +286,9 @@ export async function findHealthyAccount( return null; } + const { restoreExpiredQuotaPauses } = await import('./account-safety'); + restoreExpiredQuotaPauses(); + const config = loadOrCreateUnifiedConfig(); const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free']; const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; @@ -388,6 +391,11 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise; }> { + const { restoreExpiredQuotaPauses } = await import('./account-safety'); + restoreExpiredQuotaPauses(); + const accounts = getProviderAccounts(provider); const defaultAccount = getDefaultAccount(provider); diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts index 834df41c..c3457552 100644 --- a/src/cliproxy/services/binary-service.ts +++ b/src/cliproxy/services/binary-service.ts @@ -19,7 +19,6 @@ import { clearPinnedVersion, isVersionPinned, resolveLocalBackend, - syncPlusFallbackStateIfNeeded, } from '../binary-manager'; import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../platform-detector'; import { CLIProxyBackend } from '../types'; @@ -58,8 +57,7 @@ export interface LatestVersionResult { export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult { const configuredBackend = backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); const backendConfig = BACKEND_CONFIG[effectiveBackend]; return { installed: isCLIProxyInstalled(effectiveBackend), @@ -77,8 +75,7 @@ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult { export async function checkLatestVersion(backend?: CLIProxyBackend): Promise { const configuredBackend = backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); try { // Use checkCliproxyUpdate which is backend-aware (uses correct GitHub repo) @@ -127,8 +124,7 @@ export async function installVersion( const configuredBackend = backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); try { await installCliproxyVersion(version, verbose, effectiveBackend); @@ -157,8 +153,7 @@ export async function installLatest( ): Promise { const configuredBackend = backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); try { const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend); @@ -196,8 +191,7 @@ export async function installLatest( export function isPinned(backend?: CLIProxyBackend): boolean { const configuredBackend = backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); return isVersionPinned(effectiveBackend); } @@ -207,8 +201,7 @@ export function isPinned(backend?: CLIProxyBackend): boolean { export function getPinned(backend?: CLIProxyBackend): string | null { const configuredBackend = backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); return getPinnedVersion(effectiveBackend); } @@ -218,7 +211,6 @@ export function getPinned(backend?: CLIProxyBackend): string | null { export function clearPin(backend?: CLIProxyBackend): void { const configuredBackend = backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; - syncPlusFallbackStateIfNeeded(configuredBackend); - const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); clearPinnedVersion(effectiveBackend); } diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 90361c78..b5019e90 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -79,8 +79,8 @@ export function validateProfileName(name: string): string | null { /** * Validate provider/backend compatibility - * Returns error message if a provider requires Plus while local CLIProxy is - * running with the fallbacked original backend. + * Returns an error message if a provider requires Plus while local CLIProxy is + * configured for the default original backend. */ export function validateProviderBackend(provider: CLIProxyProfileName): string | null { // Normalize provider to lowercase for case-insensitive comparison diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index f0aa20eb..3b22998d 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -41,16 +41,12 @@ interface SettingsFile { [key: string]: unknown; } -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; - function canonicalizeModelForProvider( provider: CLIProxyProfileName | undefined, model: string ): string { - const withoutCodexSuffix = - provider === 'codex' ? model.replace(CODEX_EFFORT_SUFFIX_REGEX, '') : model; - if (!provider) return withoutCodexSuffix; - return normalizeModelIdForProvider(withoutCodexSuffix, provider); + if (!provider) return model; + return normalizeModelIdForProvider(model, provider); } /** diff --git a/src/cliproxy/thinking-validator.ts b/src/cliproxy/thinking-validator.ts index 5c58994f..55ff319a 100644 --- a/src/cliproxy/thinking-validator.ts +++ b/src/cliproxy/thinking-validator.ts @@ -32,6 +32,11 @@ export interface ThinkingValidationResult { /** * Named thinking level mappings to budget values (when converting level→budget) + * + * `max` sits above `xhigh` to represent unconstrained thinking (Claude Opus 4.7, + * Mythos). The numeric value is a CCS-internal mapping, not an Anthropic wire + * value: Opus 4.7 uses adaptive thinking with an effort string, and other + * max-capable models already treat the level as a qualitative cap. */ export const THINKING_LEVEL_BUDGETS: Record = { minimal: 512, @@ -39,6 +44,7 @@ export const THINKING_LEVEL_BUDGETS: Record = { medium: 8192, high: 24576, xhigh: 32768, + max: 65536, }; /** @@ -50,12 +56,21 @@ export const THINKING_LEVEL_RANK: Record = { medium: 3, high: 4, xhigh: 5, + max: 6, }; /** * Valid thinking level names */ -export const VALID_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto'] as const; +export const VALID_THINKING_LEVELS = [ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'auto', +] as const; export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number]; /** @@ -121,7 +136,12 @@ function findClosestLevel(input: string, validLevels: string[]): string | undefi } } - // Common aliases + // Common aliases. + // + // `max: 'xhigh'` is a graceful fallback for models whose levels list does not + // include `max` (e.g. Codex `gpt-5.4`). Exact match above takes priority, so + // Opus 4.7 (which has `max` in its validLevels) returns `max` directly while + // Codex still maps `max` → `xhigh`. const aliases: Record = { min: 'minimal', lo: 'low', diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 45114812..c839f7ab 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -37,6 +37,8 @@ export interface ToolSanitizationProxyConfig { warnOnSanitize?: boolean; /** Request timeout in milliseconds */ timeoutMs?: number; + /** Skip TLS certificate validation for self-signed remote HTTPS proxies */ + allowSelfSigned?: boolean; } /** @@ -162,6 +164,7 @@ export class ToolSanitizationProxy { verbose: config.verbose ?? false, warnOnSanitize: config.warnOnSanitize ?? true, timeoutMs: config.timeoutMs ?? 120000, + allowSelfSigned: config.allowSelfSigned ?? false, }; this.debugMode = process.env.CCS_DEBUG === '1'; this.logFilePath = this.initLogFile(); @@ -469,6 +472,38 @@ export class ToolSanitizationProxy { return url.protocol === 'https:' ? https.request : http.request; } + private startResponseTimeout(upstreamReq: http.ClientRequest): () => void { + const deadline = setTimeout(() => { + upstreamReq.destroy(new Error('Upstream request timeout')); + }, this.config.timeoutMs); + upstreamReq.setTimeout(this.config.timeoutMs, () => { + upstreamReq.destroy(new Error('Upstream request timeout')); + }); + return () => clearTimeout(deadline); + } + + private buildRequestOptions( + upstreamUrl: URL, + method: string | undefined, + headers: http.OutgoingHttpHeaders + ): https.RequestOptions { + const options: https.RequestOptions = { + protocol: upstreamUrl.protocol, + hostname: upstreamUrl.hostname, + port: upstreamUrl.port, + path: upstreamUrl.pathname + upstreamUrl.search, + method, + timeout: this.config.timeoutMs, + headers, + }; + + if (upstreamUrl.protocol === 'https:' && this.config.allowSelfSigned) { + options.rejectUnauthorized = false; + } + + return options; + } + private forwardRaw( originalReq: http.IncomingMessage, clientRes: http.ServerResponse, @@ -476,16 +511,15 @@ export class ToolSanitizationProxy { ): Promise { return new Promise((resolve, reject) => { const requestFn = this.getRequestFn(upstreamUrl); + let clearResponseTimeout: () => void = () => undefined; const upstreamReq = requestFn( - { - protocol: upstreamUrl.protocol, - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: originalReq.method, - headers: this.buildForwardHeaders(originalReq.headers), - }, + this.buildRequestOptions( + upstreamUrl, + originalReq.method, + this.buildForwardHeaders(originalReq.headers) + ), (upstreamRes) => { + clearResponseTimeout(); clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); upstreamRes.pipe(clientRes); upstreamRes.on('end', () => resolve()); @@ -493,8 +527,12 @@ export class ToolSanitizationProxy { } ); + clearResponseTimeout = this.startResponseTimeout(upstreamReq); upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); - upstreamReq.on('error', reject); + upstreamReq.on('error', (err) => { + clearResponseTimeout(); + reject(err); + }); originalReq.pipe(upstreamReq); }); } @@ -512,16 +550,15 @@ export class ToolSanitizationProxy { return new Promise((resolve, reject) => { const bodyString = JSON.stringify(body); const requestFn = this.getRequestFn(upstreamUrl); + let clearResponseTimeout: () => void = () => undefined; const upstreamReq = requestFn( - { - protocol: upstreamUrl.protocol, - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: originalReq.method, - headers: this.buildForwardHeaders(originalReq.headers, bodyString), - }, + this.buildRequestOptions( + upstreamUrl, + originalReq.method, + this.buildForwardHeaders(originalReq.headers, bodyString) + ), (upstreamRes) => { + clearResponseTimeout(); const chunks: Buffer[] = []; upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk)); @@ -564,8 +601,12 @@ export class ToolSanitizationProxy { } ); + clearResponseTimeout = this.startResponseTimeout(upstreamReq); upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); - upstreamReq.on('error', reject); + upstreamReq.on('error', (err) => { + clearResponseTimeout(); + reject(err); + }); upstreamReq.write(bodyString); upstreamReq.end(); }); @@ -585,16 +626,15 @@ export class ToolSanitizationProxy { return new Promise((resolve, reject) => { const bodyString = JSON.stringify(body); const requestFn = this.getRequestFn(upstreamUrl); + let clearResponseTimeout: () => void = () => undefined; const upstreamReq = requestFn( - { - protocol: upstreamUrl.protocol, - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: originalReq.method, - headers: this.buildForwardHeaders(originalReq.headers, bodyString), - }, + this.buildRequestOptions( + upstreamUrl, + originalReq.method, + this.buildForwardHeaders(originalReq.headers, bodyString) + ), (upstreamRes) => { + clearResponseTimeout(); clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); // Track upstream SSE lifecycle events (guards against empty proxy responses) @@ -712,8 +752,12 @@ export class ToolSanitizationProxy { } ); + clearResponseTimeout = this.startResponseTimeout(upstreamReq); upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); - upstreamReq.on('error', reject); + upstreamReq.on('error', (err) => { + clearResponseTimeout(); + reject(err); + }); upstreamReq.write(bodyString); upstreamReq.end(); }); diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 37686a50..2202e345 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -148,10 +148,9 @@ export type CLIProxyProvider = /** * CLIProxy backend selection - * - original: CLIProxyAPI (current default; upstream still maintained) - * - plus: CLIProxyAPIPlus (expanded provider support; upstream deleted as of - * issue #1062 — runtime falls back to `original`. Retained for forward - * compatibility once CCS self-maintains its own Plus build.) + * - original: CLIProxyAPI (default; upstream maintained by router-for-me) + * - plus: CCS-maintained CLIProxyAPIPlus community fork for opt-in providers + * that are not available in the original backend */ export type CLIProxyBackend = 'original' | 'plus'; diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts index d9c4ccf6..3633f67e 100644 --- a/src/commands/browser-command.ts +++ b/src/commands/browser-command.ts @@ -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 ', 'command')} Turn a browser lane on without forcing auto-exposure` + ); + writeLine( + ` ${color('ccs browser disable ', '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(` Managed MCP: ${status.managedMcpServerName}`); writeLine(` Managed path: ${status.managedMcpServerPath}`); @@ -64,10 +117,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(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`); writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`); @@ -79,27 +136,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 { await initUI(); writeLine(header('CCS Browser Help')); writeLine(''); writeIntro(writeLine); writeLine(subheader('Usage')); - writeLine(` ${color('ccs browser ', 'command')}`); + writeLine(` ${color('ccs browser ', 'command')}`); writeLine(` ${color('ccs help browser', 'command')}`); writeLine(''); writeCommandTable(writeLine); writeLine(subheader('What Each Lane Does')); writeLine(' Claude Browser Attach expects a Chrome user-data dir and remote debugging port.'); writeLine(' Codex Browser Tools depend on a Codex build that supports --config overrides.'); + writeLine( + ' 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(''); } @@ -108,24 +335,92 @@ export async function handleBrowserCommand( args: string[], writeLine: HelpWriter = console.log ): Promise { - 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} `, '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(''); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index f3f81797..ccecad99 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -86,7 +86,7 @@ export async function showHelp(): Promise { [ [ '--backend ', - 'Use specific backend: original | plus (local default: original; plus currently falls back locally)', + 'Use specific backend: original | plus (default: original; plus uses community fork)', ], ['--target ', 'Default target for created/edited variants: claude | droid'], ['--verbose, -v', 'Show detailed diagnostics including routing hints and quota fetches'], @@ -105,11 +105,7 @@ export async function showHelp(): Promise { console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); console.log(dim(' Routing: use gcli/ or agy/ to keep overlapping models pinned.')); - console.log( - dim( - ' Backend: local CLIProxy currently uses original by default; saved plus configs fall back locally.' - ) - ); + console.log(dim(' Backend: original is the default; plus is opt-in for plus-only providers.')); console.log(''); console.log(subheader('Notes:')); console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`); diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 5b37e223..a1ad558f 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -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 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': diff --git a/src/commands/config-thinking-command.ts b/src/commands/config-thinking-command.ts index 36b55b41..646d4571 100644 --- a/src/commands/config-thinking-command.ts +++ b/src/commands/config-thinking-command.ts @@ -60,7 +60,7 @@ function showHelp(): void { console.log(subheader('Levels:')); console.log( - ` ${dim('minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto, off')}` + ` ${dim('minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto, off')}` ); console.log(''); diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 51c406f9..258e5526 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -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,12 +59,12 @@ function showHelp(): number { console.log( ' start Start the local proxy for an OpenAI-compatible settings profile' ); - console.log(' stop Stop the running proxy'); - console.log(' status Show daemon status and active profile'); - console.log(' activate Print shell exports for the running proxy'); + console.log(' 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 Override the local proxy port (default: 3456)'); + console.log(' --port Pin an exact local proxy port for this launch'); console.log(' --host Bind the proxy server to a specific host (default: 127.0.0.1)'); console.log(' --shell activate only: auto|bash|zsh|fish|powershell'); console.log(' --fish activate only: shorthand for --shell fish'); @@ -62,7 +91,10 @@ function resolveProfile(profileName: string) { } async function handleStart(args: string[]): Promise { - 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 [--port ] [--host ] [--insecure]') @@ -72,7 +104,19 @@ async function handleStart(args: string[]): Promise { const portValue = parseOptionValue(args, '--port'); const host = parseOptionValue(args, '--host'); - const port = portValue ? Number.parseInt(portValue, 10) || 3456 : undefined; + const parsedPort = portValue ? Number(portValue) : undefined; + if ( + portValue && + (parsedPort === undefined || + !/^\d+$/.test(portValue) || + !Number.isInteger(parsedPort) || + parsedPort < 1 || + parsedPort > 65535) + ) { + console.error(fail(`Invalid port: ${portValue}`)); + return 1; + } + const port = parsedPort; let profile; try { profile = resolveProfile(profileName); @@ -100,31 +144,78 @@ async function handleStart(args: string[]): Promise { return 0; } -async function handleStatus(): Promise { +async function handleStatus(args: string[] = []): Promise { + if (hasHelpFlag(args)) { + return showHelp(); + } + const profileName = findPositionalArg(args); + const printStatus = (status: Awaited>) => { + 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 { - 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 ') + ); + 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 ')); return 1; @@ -163,16 +254,20 @@ export async function handleProxyCommand(args: string[]): Promise { 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: diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 6338e469..261b2d72 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -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,6 +37,8 @@ import type { OfficialChannelId, DashboardAuthConfig, BrowserConfig, + BrowserEvalMode, + BrowserToolPolicy, ImageAnalysisConfig, LoggingConfig, CursorConfig, @@ -70,15 +73,41 @@ function normalizeBrowserDevtoolsPort(value: number | undefined): number { return port; } -function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig { +function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy { + return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy; +} + +function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode { + if (value === 'disabled' || value === 'readonly' || value === 'readwrite') { + return value; + } + + return DEFAULT_BROWSER_CONFIG.claude.eval_mode ?? 'readonly'; +} + +function canonicalizeBrowserConfig( + config?: BrowserConfig, + fallback: BrowserConfig = DEFAULT_BROWSER_CONFIG +): BrowserConfig { + const claudeUserDataDir = + config?.claude?.user_data_dir === undefined + ? fallback.claude.user_data_dir || getRecommendedBrowserUserDataDir() + : config.claude.user_data_dir.trim() || getRecommendedBrowserUserDataDir(); + return { claude: { - enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled, - user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(), - devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port), + enabled: config?.claude?.enabled ?? fallback.claude.enabled, + policy: normalizeBrowserPolicy(config?.claude?.policy ?? fallback.claude.policy), + user_data_dir: claudeUserDataDir, + devtools_port: normalizeBrowserDevtoolsPort( + config?.claude?.devtools_port ?? fallback.claude.devtools_port + ), + eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode ?? fallback.claude.eval_mode), }, codex: { - enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled, + enabled: config?.codex?.enabled ?? fallback.codex.enabled, + policy: normalizeBrowserPolicy(config?.codex?.policy ?? fallback.codex.policy), + eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode ?? fallback.codex.eval_mode), }, }; } @@ -414,6 +443,10 @@ function mergeWithDefaults(partial: Partial): 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, @@ -892,7 +925,9 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push( '# Modes: auto (use tier_defaults), off (disable), manual (--thinking/--effort flags)' ); - lines.push('# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto'); + lines.push( + '# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto' + ); lines.push('# Override: Set global override value (number or level name)'); lines.push('# Provider overrides: Per-provider tier defaults'); lines.push('# ----------------------------------------------------------------------------'); @@ -1119,7 +1154,13 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig { return withConfigWriteLock(() => { const current = loadUnifiedConfigWithLockHeld(); + const previousBrowser = current.browser + ? canonicalizeBrowserConfig(current.browser) + : undefined; mutator(current); + if (current.browser) { + current.browser = canonicalizeBrowserConfig(current.browser, previousBrowser); + } writeUnifiedConfigWithLockHeld(current); return current; }); diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 2be764d5..4639744c 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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; routing?: OpenAICompatProxyRoutingConfig; } @@ -816,18 +821,29 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { * Browser automation configuration. * Controls Claude browser attach and Codex browser tooling. */ +export type BrowserToolPolicy = 'auto' | 'manual'; +export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; + 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 exposed through browser settings/status surfaces */ + eval_mode?: BrowserEvalMode; } export interface BrowserCodexConfig { - /** Enable Codex browser tooling injection (default: true) */ + /** Enable Codex browser tooling injection (default: false) */ enabled: boolean; + /** Control whether Codex browser tooling is exposed automatically or only via --browser */ + policy: BrowserToolPolicy; + /** Eval access mode exposed through browser settings/status surfaces */ + eval_mode?: BrowserEvalMode; } export interface BrowserConfig { @@ -838,11 +854,15 @@ 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, + enabled: false, + policy: 'manual', + eval_mode: 'readonly', }, }; @@ -986,6 +1006,7 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = { }; export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = { + profile_ports: {}, routing: { longContextThreshold: 60_000, }, @@ -1016,6 +1037,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, }, diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts index 6dbe7d92..416b4246 100644 --- a/src/cursor/cursor-anthropic-translator.ts +++ b/src/cursor/cursor-anthropic-translator.ts @@ -68,17 +68,30 @@ function toToolResultContent(content: unknown, label: string): string { return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK); } -function mapThinkingToReasoningEffort( - thinking: CursorAnthropicRequest['thinking'] -): string | undefined { +function mapAdaptiveEffortToCursorReasoningEffort(effort: string | undefined): string { + const normalized = effort?.trim().toLowerCase(); + if (!normalized || normalized === 'auto') { + return 'high'; + } + if (normalized === 'minimal' || normalized === 'low' || normalized === 'medium') { + return 'medium'; + } + return 'high'; +} + +function mapThinkingToReasoningEffort(request: CursorAnthropicRequest): string | undefined { + const thinking = request.thinking; if (!thinking) { return undefined; } if (thinking.type === 'disabled') { return undefined; } + if (thinking.type === 'adaptive') { + return mapAdaptiveEffortToCursorReasoningEffort(request.output_config?.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"'); } return typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192 ? 'high' @@ -209,7 +222,7 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ ? request.model : undefined, stream: request.stream === true, - reasoning_effort: mapThinkingToReasoningEffort(request.thinking), + reasoning_effort: mapThinkingToReasoningEffort(request), tools: Array.isArray(request.tools) ? request.tools : undefined, messages: translatedMessages, }; diff --git a/src/cursor/cursor-anthropic-types.ts b/src/cursor/cursor-anthropic-types.ts index 336fb75f..9374de5b 100644 --- a/src/cursor/cursor-anthropic-types.ts +++ b/src/cursor/cursor-anthropic-types.ts @@ -41,6 +41,9 @@ export interface CursorAnthropicRequest { system?: string | AnthropicTextBlock[]; stream?: boolean; tools?: CursorTool[]; + output_config?: { + effort?: string; + }; thinking?: { type?: string; budget_tokens?: number; diff --git a/src/cursor/cursor-default-models.ts b/src/cursor/cursor-default-models.ts index 1457de05..01cc05c6 100644 --- a/src/cursor/cursor-default-models.ts +++ b/src/cursor/cursor-default-models.ts @@ -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', diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 569302f2..76e4bcdd 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -16,8 +16,13 @@ import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from import { StreamBuffer, formatToolVerbose } from './executor/stream-parser'; import { buildExecutionResult } from './executor/result-aggregator'; import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager'; +import { getGlobalEnvConfig } from '../config/unified-config-loader'; import { getProfileLookupCandidates } from '../utils/profile-compat'; -import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor'; +import { + getClaudeLaunchEnvOverrides, + stripAnthropicRoutingEnv, + stripClaudeCodeEnv, +} from '../utils/shell-executor'; import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; import { appendThirdPartyImageAnalysisToolArgs, @@ -38,6 +43,11 @@ import { import { resolveCliproxyBridgeMetadata } from '../api/services'; import { ensureCliproxyService } from '../cliproxy'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; +import { + buildOpenAICompatProxyEnv, + resolveOpenAICompatProfileConfig, + startOpenAICompatProxy, +} from '../proxy'; import { appendThirdPartyWebSearchToolArgs, appendWebSearchTrace, @@ -129,6 +139,14 @@ export class HeadlessExecutor { syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); const settings = loadSettings(settingsPath); + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + const settingsEnv = settings.env || {}; + const openAICompatProfile = resolveOpenAICompatProfileConfig( + profile, + settingsPath, + settingsEnv + ); const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); let imageAnalysisFallbackHookReady: boolean | undefined; if (imageAnalysisMcpReady) { @@ -207,6 +225,33 @@ export class HeadlessExecutor { } } + let runtimeEnvVars: NodeJS.ProcessEnv = { + ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }), + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + }; + + if (openAICompatProfile) { + const proxyStart = await startOpenAICompatProxy(openAICompatProfile, { + insecure: openAICompatProfile.insecure, + }); + if (!proxyStart.success) { + throw new Error(proxyStart.error || 'Failed to start local OpenAI-compatible proxy'); + } + + runtimeEnvVars = { + ...runtimeEnvVars, + ...buildOpenAICompatProxyEnv( + openAICompatProfile, + proxyStart.port, + proxyStart.authToken || '', + inheritedClaudeConfigDir + ), + }; + delete runtimeEnvVars.ANTHROPIC_API_KEY; + } + // Smart slash command detection and preservation const processedPrompt = this._processSlashCommand(enhancedPrompt); @@ -321,6 +366,7 @@ export class HeadlessExecutor { sessionMgr, claudeConfigDir: inheritedClaudeConfigDir, imageAnalysisEnv, + runtimeEnvVars, traceEnv, }); } @@ -340,6 +386,7 @@ export class HeadlessExecutor { sessionMgr: SessionManager; claudeConfigDir?: string; imageAnalysisEnv?: Record; + runtimeEnvVars?: NodeJS.ProcessEnv; traceEnv?: Record; } ): Promise { @@ -352,6 +399,7 @@ export class HeadlessExecutor { sessionMgr, claudeConfigDir, imageAnalysisEnv = {}, + runtimeEnvVars = {}, traceEnv = {}, } = ctx; @@ -368,9 +416,10 @@ export class HeadlessExecutor { // Strip Claude Code nested session guard env var to allow CCS delegation // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) const cleanEnv = stripClaudeCodeEnv({ - ...process.env, + ...stripAnthropicRoutingEnv(process.env), ...getClaudeLaunchEnvOverrides(), ...getWebSearchHookEnv(), + ...runtimeEnvVars, ...imageAnalysisEnv, ...traceEnv, ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), diff --git a/src/glmt/delta-accumulator.ts b/src/glmt/delta-accumulator.ts index 4988c9e8..7915267c 100644 --- a/src/glmt/delta-accumulator.ts +++ b/src/glmt/delta-accumulator.ts @@ -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 diff --git a/src/glmt/pipeline/stream-parser.ts b/src/glmt/pipeline/stream-parser.ts index c0b6fc71..b72218a2 100644 --- a/src/glmt/pipeline/stream-parser.ts +++ b/src/glmt/pipeline/stream-parser.ts @@ -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)); diff --git a/src/glmt/pipeline/tool-call-handler.ts b/src/glmt/pipeline/tool-call-handler.ts index ffe0d670..251813a4 100644 --- a/src/glmt/pipeline/tool-call-handler.ts +++ b/src/glmt/pipeline/tool-call-handler.ts @@ -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, }, - }); - } + }, + }); } } diff --git a/src/proxy/index.ts b/src/proxy/index.ts index 24a0a516..2f389857 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -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'; diff --git a/src/proxy/proxy-daemon-entry.ts b/src/proxy/proxy-daemon-entry.ts index 0013ef7e..d66b1f34 100644 --- a/src/proxy/proxy-daemon-entry.ts +++ b/src/proxy/proxy-daemon-entry.ts @@ -1,5 +1,6 @@ import { loadSettings } from '../utils/config-manager'; import { resolveOpenAICompatProfileConfig } from './profile-router'; +import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; import { startOpenAICompatProxyServer } from './server/proxy-server'; interface RuntimeOptions { @@ -12,7 +13,7 @@ interface RuntimeOptions { } function parseArgs(argv: string[]): RuntimeOptions { - let port = 3456; + let port = OPENAI_COMPAT_PROXY_DEFAULT_PORT; let host = '127.0.0.1'; let profileName = ''; let settingsPath = ''; diff --git a/src/proxy/proxy-daemon-paths.ts b/src/proxy/proxy-daemon-paths.ts index 92b6b95b..843e1286 100644 --- a/src/proxy/proxy-daemon-paths.ts +++ b/src/proxy/proxy-daemon-paths.ts @@ -1,17 +1,38 @@ import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; -export const OPENAI_COMPAT_PROXY_DEFAULT_PORT = 3456; +export const OPENAI_COMPAT_PROXY_LEGACY_DEFAULT_PORT = 3456; +export const OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START = 43_456; +export const OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END = 43_555; +export const OPENAI_COMPAT_PROXY_DEFAULT_PORT = OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START; export const OPENAI_COMPAT_PROXY_SERVICE_NAME = 'ccs-openai-compat-proxy'; 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'); } diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts index 525c5a7e..8ca7bc05 100644 --- a/src/proxy/proxy-daemon-state.ts +++ b/src/proxy/proxy-daemon-state.ts @@ -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(); + 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'); diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 8a28382e..a96caf85 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -2,18 +2,23 @@ import { spawn, type ChildProcess } from 'child_process'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as http from 'http'; -import * as net from 'net'; import * as lockfile from 'proper-lockfile'; import { verifyProcessOwnership } from '../cursor/daemon-process-ownership'; import type { OpenAICompatProfileConfig } from './profile-router'; import { - OPENAI_COMPAT_PROXY_DEFAULT_PORT, + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END, + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START, OPENAI_COMPAT_PROXY_SERVICE_NAME, getOpenAICompatProxyDir, } from './proxy-daemon-paths'; import { + getLegacyOpenAICompatProxyPid, getOpenAICompatProxyPid, + listOpenAICompatProxyProfileNames, + readLegacyOpenAICompatProxySession, readOpenAICompatProxySession, + removeLegacyOpenAICompatProxyPid, + removeLegacyOpenAICompatProxySession, removeOpenAICompatProxyPid, removeOpenAICompatProxySession, resolveOpenAICompatProxyEntrypointCandidates, @@ -21,6 +26,10 @@ import { writeOpenAICompatProxyPid, writeOpenAICompatProxySession, } from './proxy-daemon-state'; +import { + listOpenAICompatProxyCandidatePorts as listFlexibleOpenAICompatProxyCandidatePorts, + resolveOpenAICompatProxyPortPreference, +} from './proxy-port-resolver'; export interface OpenAICompatProxyStatus extends Partial { running: boolean; @@ -36,6 +45,37 @@ export interface StartOpenAICompatProxyResult { error?: string; } +interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult { + commitState?: () => void; + stop?: () => Promise; + bindConflict?: boolean; +} + +interface OpenAICompatProxyHealthPayload { + service?: string; + profile?: string; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function verifyProxyProcessOwnership( + pid: number, + profileName?: string +): ReturnType { + 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 +110,59 @@ async function withOpenAICompatProxyLock(operation: () => Promise): Promis } } -async function isPortOccupied(port: number): Promise { - return new Promise((resolve) => { - const socket = net.createConnection({ host: '127.0.0.1', port }); - const finish = (occupied: boolean) => { - socket.removeAllListeners(); - socket.destroy(); - resolve(occupied); - }; - - socket.once('connect', () => finish(true)); - socket.once('error', () => finish(false)); - socket.setTimeout(500, () => { - finish(false); - }); - }); -} - -async function findOpenAICompatProxyPort(): Promise { - for ( - let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT; - candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10; - candidate += 1 - ) { - if (!(await isPortOccupied(candidate))) { - return candidate; - } +async function terminateDaemonProcess(pid?: number): Promise { + if (!pid) { + return; } - return 0; + 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. + } +} + +function listOpenAICompatProxyCandidatePorts( + profileName: string, + preferredPort: number, + exact: boolean, + excludedPorts: ReadonlySet = new Set() +): number[] { + if (exact) { + return excludedPorts.has(preferredPort) ? [] : [preferredPort]; + } + + return listFlexibleOpenAICompatProxyCandidatePorts(profileName, preferredPort, excludedPorts); +} + +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 { @@ -113,7 +177,10 @@ async function resolveDaemonEntrypoint(): Promise { return null; } -export async function isOpenAICompatProxyRunning(port: number): Promise { +export async function isOpenAICompatProxyRunning( + port: number, + expectedProfileName?: string +): Promise { return new Promise((resolve) => { const req = http.request( { hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 }, @@ -127,8 +194,11 @@ export async function isOpenAICompatProxyRunning(port: number): Promise 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 +214,115 @@ export async function isOpenAICompatProxyRunning(port: number): Promise }); } -export async function getOpenAICompatProxyStatus(): Promise { - const session = readOpenAICompatProxySession(); - const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; - const running = await isOpenAICompatProxyRunning(port); +async function getOpenAICompatProxyStatusForProfile( + profileName: string +): Promise { + 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 { + 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 { + 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 { + 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 +334,7 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro } if (ownership === 'not-running') { - removeOpenAICompatProxyPid(); - removeOpenAICompatProxySession(); + removeOpenAICompatProxyState(state, profileName); return { success: true }; } @@ -214,13 +365,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 +480,47 @@ export async function startOpenAICompatProxy( options: { port?: number; host?: string; insecure?: boolean } = {} ): Promise { 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}` }; - } + const portPreference = resolveOpenAICompatProxyPortPreference(profile.profileName); + const explicitPort = typeof options.port === 'number' ? options.port : undefined; + const requiresExactPort = explicitPort !== undefined || portPreference.source === 'profile'; if ( status.running && - status.profileName === profile.profileName && - status.port === port && + explicitPort === undefined && + portPreference.source !== 'profile' && + status.port && (status.host || '127.0.0.1') === host ) { return { success: true, alreadyRunning: true, pid: status.pid, - port, + port: status.port, authToken: status.authToken, }; } - if (status.running) { - if (status.profileName !== profile.profileName) { - return { - success: false, - port, - error: `Proxy already running for profile "${status.profileName}" on port ${status.port}. Stop it before starting a different profile.`, - }; - } - const stopped = await stopOpenAICompatProxyUnlocked(); + const preferredPort = explicitPort ?? portPreference.port; + if (status.running && status.port === preferredPort && (status.host || '127.0.0.1') === host) { + return { + success: true, + alreadyRunning: true, + pid: status.pid, + port: preferredPort, + authToken: status.authToken, + }; + } + 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 +529,212 @@ 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 => + 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 => { + const attemptedPorts = new Set(); + let lastResult: OpenAICompatProxyLaunchResult | null = null; + const candidates = listOpenAICompatProxyCandidatePorts( + profile.profileName, + preferredPort, + requiresExactPort, + attemptedPorts + ); + if (candidates.length === 0) { + return { + success: false, + port: preferredPort, + error: `No free proxy port found in adaptive range ${OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START}-${OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END}`, + }; + } + + 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 adaptive 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); }); } diff --git a/src/proxy/proxy-port-resolver.ts b/src/proxy/proxy-port-resolver.ts new file mode 100644 index 00000000..cd7cd9a3 --- /dev/null +++ b/src/proxy/proxy-port-resolver.ts @@ -0,0 +1,81 @@ +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import { + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END, + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START, + OPENAI_COMPAT_PROXY_LEGACY_DEFAULT_PORT, +} from './proxy-daemon-paths'; + +export interface OpenAICompatProxyPortPreference { + port: number; + source: 'adaptive' | 'profile' | 'shared'; +} + +const ADAPTIVE_PORT_RANGE_SIZE = + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END - OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START + 1; + +function hashProfileName(profileName: string): number { + let hash = 0; + for (const char of profileName.trim()) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + return hash; +} + +export function resolveOpenAICompatProxyAdaptivePort(profileName: string): number { + return ( + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START + + (hashProfileName(profileName) % ADAPTIVE_PORT_RANGE_SIZE) + ); +} + +export function listOpenAICompatProxyCandidatePorts( + profileName: string, + preferredPort: number, + excludedPorts: ReadonlySet = new Set() +): number[] { + const candidates = new Set(); + if (!excludedPorts.has(preferredPort)) { + candidates.add(preferredPort); + } + + const adaptiveStart = resolveOpenAICompatProxyAdaptivePort(profileName); + for (let offset = 0; offset < ADAPTIVE_PORT_RANGE_SIZE; offset += 1) { + const candidate = + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START + + ((adaptiveStart - OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START + offset) % + ADAPTIVE_PORT_RANGE_SIZE); + if (!excludedPorts.has(candidate)) { + candidates.add(candidate); + } + } + + return [...candidates]; +} + +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' }; + } + const sharedPort = config.proxy?.port; + if (typeof sharedPort === 'number') { + if (sharedPort === OPENAI_COMPAT_PROXY_LEGACY_DEFAULT_PORT) { + return { + port: resolveOpenAICompatProxyAdaptivePort(profileName), + source: 'adaptive', + }; + } + return { port: sharedPort, source: 'shared' }; + } + return { + port: resolveOpenAICompatProxyAdaptivePort(profileName), + source: 'adaptive', + }; +} + +export function resolveOpenAICompatProxyPreferredPort(profileName: string): number { + return resolveOpenAICompatProxyPortPreference(profileName).port; +} diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index 95388e00..965e67a3 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -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); diff --git a/src/proxy/server/proxy-server.ts b/src/proxy/server/proxy-server.ts index 4b68f2a1..d5e99d11 100644 --- a/src/proxy/server/proxy-server.ts +++ b/src/proxy/server/proxy-server.ts @@ -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; } diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index c1ebd5d7..58e7969e 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -396,7 +396,7 @@ function mapThinkingToReasoning( }; } -const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']); +const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); function resolveOutputConfigEffort( outputConfig: AnthropicOutputConfig | undefined @@ -416,7 +416,7 @@ function resolveOutputConfigEffort( * for Codex; for generic OpenAI-compat providers we clamp to high. */ function toOpenAIEffort(effort: string): string { - return effort === 'max' ? 'high' : effort; + return effort === 'max' || effort === 'xhigh' ? 'high' : effort; } function transformMessages(messagesValue: unknown): OpenAIMessage[] { @@ -458,9 +458,34 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { if (role === 'user') { const userParts: OpenAIContentPart[] = []; - let sawToolResult = false; + const followUpParts: OpenAIContentPart[] = []; const resolvedToolUseIds = new Set(); + 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, @@ -472,23 +497,17 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { } if (parsed.type === 'text') { - if (sawToolResult) { - throw new Error( - `messages[${messageIndex}].content[${blockIndex}] text is not allowed after tool_result blocks` - ); - } const text = typeof parsed.text === 'string' ? parsed.text : ''; - userParts.push({ type: 'text', text }); + handleUserPart({ type: 'text', text }, blockIndex, 'text'); return; } if (isImageBlock(parsed)) { - if (sawToolResult) { - throw new Error( - `messages[${messageIndex}].content[${blockIndex}] image is not allowed after tool_result blocks` - ); - } - userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`)); + handleUserPart( + toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`), + blockIndex, + 'image' + ); return; } @@ -498,11 +517,6 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { `messages[${messageIndex}].content[${blockIndex}] tool_result requires a preceding assistant tool_use` ); } - if (userParts.length > 0) { - throw new Error( - `messages[${messageIndex}].content[${blockIndex}] tool_result blocks must come before other user content` - ); - } 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` @@ -518,7 +532,6 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { `messages[${messageIndex}].content[${blockIndex}].tool_use_id "${parsed.tool_use_id}" is duplicated` ); } - sawToolResult = true; resolvedToolUseIds.add(parsed.tool_use_id); translatedMessages.push({ role: 'tool', @@ -543,7 +556,7 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { ); }); - if (sawToolResult) { + 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` @@ -551,18 +564,21 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { } pendingToolUseIds = null; hasPendingToolUseIds = false; - return; } if (pendingToolUseIds && pendingToolUseIds.size > 0) { throw new Error( - `messages[${messageIndex}].content must start with tool_result blocks for pending tool_use ids` + `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; } diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index d1e8c57c..397ce6e1 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -12,6 +12,7 @@ import type { ProfileType } from '../types/profile'; import { escapeShellArg, getWindowsEscapedCommandShell, + stripBrowserEnv, stripAnthropicEnv, stripClaudeCodeEnv, } from '../utils/shell-executor'; @@ -60,10 +61,10 @@ export class ClaudeAdapter implements TargetAdapter { ? stripAnthropicEnv(process.env) : process.env; - const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv }; + const env: NodeJS.ProcessEnv = { ...stripBrowserEnv(baseEnv), ...webSearchEnv }; if (creds.envVars) { - Object.assign(env, creds.envVars); + Object.assign(env, stripBrowserEnv(creds.envVars)); } if (creds.browserRuntimeEnv) { Object.assign(env, creds.browserRuntimeEnv); diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 3b74976b..f82aa23b 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -7,6 +7,7 @@ import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { escapeShellArg, getWindowsEscapedCommandShell, + stripBrowserEnv, stripAnthropicEnv, stripCodexSessionEnv, } from '../utils/shell-executor'; @@ -252,7 +253,7 @@ export class CodexAdapter implements TargetAdapter { buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { - ...stripCodexSessionEnv(stripAnthropicEnv(process.env)), + ...stripBrowserEnv(stripCodexSessionEnv(stripAnthropicEnv(process.env))), }; delete env[CODEX_RUNTIME_ENV_KEY]; if (profileType !== 'default') { diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 29a3943e..29f3c92d 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -9,6 +9,9 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as lockfile from 'proper-lockfile'; +import { getModelThinkingSupport } from '../cliproxy/model-catalog'; +import { validateThinking } from '../cliproxy/thinking-validator'; +import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils'; const CCS_MODEL_PREFIX = 'ccs-'; const CCS_DISPLAY_PREFIX = 'CCS '; @@ -144,6 +147,24 @@ function toAnthropicBudget(value: string | number): number { return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high; } +function resolveAnthropicModelId(model: string): string { + return stripModelConfigurationSuffixes(model); +} + +function usesAnthropicAdaptiveThinking(model: string): boolean { + return getModelThinkingSupport('claude', resolveAnthropicModelId(model))?.type === 'levels'; +} + +function toAnthropicAdaptiveEffort(model: string, value: string | number): string | undefined { + const validation = validateThinking('claude', resolveAnthropicModelId(model), value); + if (isReasoningOffValue(validation.value)) { + return undefined; + } + + const normalized = String(validation.value).trim().toLowerCase(); + return normalized === 'auto' ? undefined : normalized; +} + function toReasoningEffort(value: string | number): string { if (typeof value === 'number') { if (value <= 4000) return 'low'; @@ -181,12 +202,35 @@ function applyReasoningOverride( if (isReasoningOffValue(reasoningOverride)) { delete extraArgs.thinking; + delete extraArgs.output_config; + } else if (usesAnthropicAdaptiveThinking(entry.model)) { + const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; + const outputConfig = isObject(extraArgs.output_config) ? { ...extraArgs.output_config } : {}; + const effort = toAnthropicAdaptiveEffort(entry.model, reasoningOverride); + + thinking.type = 'adaptive'; + delete thinking.budget_tokens; + delete thinking.budgetTokens; + + if (effort) { + outputConfig.effort = effort; + } else { + delete outputConfig.effort; + } + + extraArgs.thinking = thinking; + if (Object.keys(outputConfig).length > 0) { + extraArgs.output_config = outputConfig; + } else { + delete extraArgs.output_config; + } } else { const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; thinking.type = 'enabled'; thinking.budget_tokens = toAnthropicBudget(reasoningOverride); delete thinking.budgetTokens; extraArgs.thinking = thinking; + delete extraArgs.output_config; } } else if (provider === 'openai') { delete extraArgs.reasoning_effort; diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index 13b4d636..205ceba4 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -29,6 +29,17 @@ const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES'; const LEGACY_TARGET_ALIAS_ENV_VARS: Partial> = getLegacyTargetAliasEnvVars(); const RESERVED_BIN_NAMES = new Set(['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, 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; } diff --git a/src/utils/browser/browser-policy.ts b/src/utils/browser/browser-policy.ts new file mode 100644 index 00000000..61a20196 --- /dev/null +++ b/src/utils/browser/browser-policy.ts @@ -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; +} diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index a6e24b03..89faa134 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -1,10 +1,10 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { BrowserConfig } from '../../config/unified-config-types'; -import { getCcsDir } from '../config-manager'; +import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types'; +import { getCcsDir, getCcsPathDisplay } from '../config-manager'; import { expandPath } from '../helpers'; -import { getNodePlatformKey } from './platform'; import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; +import { getNodePlatformKey } from './platform'; export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; @@ -15,10 +15,13 @@ export interface EffectiveClaudeBrowserAttachConfig { userDataDir: string; devtoolsPort: number; hasExplicitDevtoolsPort: boolean; + evalMode: BrowserEvalMode; } -export function getRecommendedBrowserUserDataDir(): string { - return path.join(getCcsDir(), 'browser', 'chrome-user-data'); +export interface BrowserLaunchCommands { + darwin: string; + linux: string; + win32: string; } export interface BrowserAttachRuntimeResolution { @@ -39,7 +42,29 @@ export interface ManagedBrowserAttachNotReadyMessage { warning: string; } -function isManagedDefaultBrowserAttach(config: EffectiveClaudeBrowserAttachConfig): boolean { +export function getRecommendedBrowserUserDataDir(): string { + return path.join(getCcsDir(), 'browser', 'chrome-user-data'); +} + +export function resolveBrowserUserDataDir(value?: string): string | undefined { + return value?.trim() ? expandPath(value) : undefined; +} + +export function 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()) @@ -47,25 +72,36 @@ function isManagedDefaultBrowserAttach(config: EffectiveClaudeBrowserAttachConfi } function buildCurrentPlatformLaunchCommand(userDataDir: string, devtoolsPort: number): string { - const quotedPath = JSON.stringify(userDataDir); - switch (getNodePlatformKey()) { - case 'darwin': - return `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`; - case 'win32': - return `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`; - default: - return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`; - } + return buildBrowserLaunchCommands(userDataDir, devtoolsPort)[getNodePlatformKey()]; } -export function resolveBrowserUserDataDir(value?: string): string | undefined { - return value?.trim() ? expandPath(value) : undefined; +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 (!isManagedDefaultBrowserAttach(config)) { + if (!isManagedClaudeBrowserAttachConfig(config)) { return { usesManagedDefaultDir: false, createdProfileDir: false, @@ -110,51 +146,51 @@ export function describeManagedBrowserAttachNotReady( launchCommand?: string; } = {} ): ManagedBrowserAttachNotReadyMessage | undefined { - if (!isManagedDefaultBrowserAttach(config)) { + if (!isManagedClaudeBrowserAttachConfig(config)) { return undefined; } const launchCommand = options.launchCommand ?? - buildCurrentPlatformLaunchCommand(config.userDataDir, config.devtoolsPort); - const continueWithoutTools = - 'CCS will continue without browser tools until the attach session is ready.'; + 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 at ${config.userDataDir}, but no running attach-mode Chrome session is using it yet` - : `No running attach-mode Chrome session is using the managed browser profile at ${config.userDataDir}`; - const nextStep = `Start Chrome with remote debugging and the managed user-data dir. Example: ${launchCommand}`; + ? '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 waiting for a managed Chrome session.', - detail: `${summary}. Diagnostic: ${errorMessage}`, + title: 'Claude Browser Attach is not ready yet.', + detail: `${summary} Diagnostic: ${errorMessage}`, nextStep, - warning: `${summary}. ${nextStep} ${continueWithoutTools}`, + warning: buildManagedBrowserAttachWarning(config), }; } if (errorMessage.includes('Chrome DevTools endpoint')) { - const summary = `CCS could not reach the attach-mode DevTools endpoint for the managed browser profile at ${config.userDataDir}`; - const nextStep = `Restart Chrome in attach mode and retry. Example: ${launchCommand}`; return { state: 'endpoint_unreachable', title: 'Claude Browser Attach could not reach the managed Chrome session.', - detail: `${summary}. Diagnostic: ${errorMessage}`, + detail: `CCS found the managed browser profile, but the DevTools endpoint did not answer successfully. Diagnostic: ${errorMessage}`, nextStep, - warning: `${summary}. ${nextStep} ${continueWithoutTools}`, + warning: buildManagedBrowserAttachWarning(config), }; } if (errorMessage.includes('Chrome profile directory is invalid')) { - const summary = `CCS could not initialize the managed browser profile at ${config.userDataDir}`; - const nextStep = `Confirm the path is writable or reset it to the CCS-managed default, then launch Chrome in attach mode. Example: ${launchCommand}`; return { state: 'path_missing', title: 'Claude Browser Attach could not initialize the managed profile.', - detail: `${summary}. Diagnostic: ${errorMessage}`, + detail: `CCS could not initialize the managed browser profile directory. Diagnostic: ${errorMessage}`, nextStep, - warning: `${summary}. ${nextStep} ${continueWithoutTools}`, + warning: buildManagedBrowserAttachWarning(config), }; } @@ -195,15 +231,17 @@ export function getEffectiveClaudeBrowserAttachConfig( const configUserDataDir = resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir(); const configPort = normalizeDevtoolsPort(config.claude.devtools_port); + const configEvalMode = config.claude.eval_mode ?? 'readonly'; if (override.userDataDir) { return { - enabled: true, + enabled: config.claude.enabled, source: override.source as BrowserOverrideSource, overrideActive: true, userDataDir: override.userDataDir, devtoolsPort: override.devtoolsPort ?? configPort, hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, + evalMode: configEvalMode, }; } @@ -213,10 +251,8 @@ export function getEffectiveClaudeBrowserAttachConfig( overrideActive: false, userDataDir: configUserDataDir, devtoolsPort: configPort, - // Config-backed browser attach always keeps an explicit port so launches - // stay aligned with Settings > Browser, even when the effective value is - // the default 9222. hasExplicitDevtoolsPort: true, + evalMode: configEvalMode, }; } @@ -230,20 +266,20 @@ export async function resolveOptionalBrowserAttachRuntime( const bootstrap = ensureManagedBrowserUserDataDir(config); if (bootstrap.createdProfileDir) { return { - warning: describeManagedBrowserAttachNotReady( - config, - `Chrome reuse metadata not found: ${path.join(config.userDataDir, 'DevToolsActivePort')}`, - { createdProfileDir: true } - )?.warning, + warning: buildManagedBrowserAttachWarning(config), }; } try { + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir: config.userDataDir, + devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined, + }); return { - runtimeEnv: await resolveBrowserRuntimeEnv({ - profileDir: config.userDataDir, - devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined, - }), + runtimeEnv: { + ...runtimeEnv, + CCS_BROWSER_EVAL_MODE: config.evalMode, + }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/utils/browser/browser-setup.ts b/src/utils/browser/browser-setup.ts new file mode 100644 index 00000000..1d23e738 --- /dev/null +++ b/src/utils/browser/browser-setup.ts @@ -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 { + 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; +} diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index 61c7e5f0..2fbe14ef 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -1,24 +1,29 @@ import * as path from 'path'; -import { getBrowserConfig } from '../../config/unified-config-loader'; +import type { + BrowserConfig, + BrowserEvalMode, + 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; + evalMode: BrowserEvalMode; source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; overrideActive: boolean; state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready'; @@ -36,6 +41,8 @@ export interface ClaudeBrowserStatus { export interface CodexBrowserStatus { enabled: boolean; + policy: BrowserToolPolicy; + evalMode: BrowserEvalMode; state: 'disabled' | 'enabled' | 'unsupported_build'; title: string; detail: string; @@ -52,21 +59,78 @@ export interface BrowserStatusPayload { } export async function getBrowserStatus(): Promise { - const browserConfig = getBrowserConfig(); + const browserConfig = getUserFacingBrowserConfig(); return { claude: await buildClaudeBrowserStatus(browserConfig), codex: buildCodexBrowserStatus(browserConfig), }; } +type PersistedBrowserConfig = { + claude?: Partial; + codex?: Partial; +}; + +function resolveSafeBrowserPolicy(policy: BrowserToolPolicy | undefined): BrowserToolPolicy { + return policy === 'auto' || policy === 'manual' ? policy : 'manual'; +} + +function resolveSafeBrowserEvalMode(evalMode: BrowserEvalMode | undefined): BrowserEvalMode { + return evalMode === 'disabled' || evalMode === 'readonly' || evalMode === 'readwrite' + ? evalMode + : 'readonly'; +} + +export function getUserFacingBrowserConfig(): BrowserConfig { + const canonical = getBrowserConfig(); + const persisted = loadUnifiedConfig()?.browser as PersistedBrowserConfig | undefined; + + if (!persisted) { + return { + claude: { + ...canonical.claude, + enabled: false, + policy: 'manual', + eval_mode: resolveSafeBrowserEvalMode(canonical.claude.eval_mode), + }, + codex: { + ...canonical.codex, + enabled: false, + policy: 'manual', + eval_mode: resolveSafeBrowserEvalMode(canonical.codex.eval_mode), + }, + }; + } + + return { + claude: { + ...canonical.claude, + enabled: persisted.claude?.enabled ?? false, + policy: resolveSafeBrowserPolicy(persisted.claude?.policy), + eval_mode: resolveSafeBrowserEvalMode( + persisted.claude?.eval_mode ?? canonical.claude.eval_mode + ), + }, + codex: { + ...canonical.codex, + enabled: persisted.codex?.enabled ?? false, + policy: resolveSafeBrowserPolicy(persisted.codex?.policy), + eval_mode: resolveSafeBrowserEvalMode( + persisted.codex?.eval_mode ?? canonical.codex.eval_mode + ), + }, + }; +} + async function buildClaudeBrowserStatus( - browserConfig = getBrowserConfig() + browserConfig = getUserFacingBrowserConfig() ): Promise { const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig); - const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort); - const managedBootstrap = ensureManagedBrowserUserDataDir(effective); + const launchCommands = buildBrowserLaunchCommands(effective.userDataDir, effective.devtoolsPort); const base: Omit = { enabled: effective.enabled, + policy: browserConfig.claude.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.claude.eval_mode), source: effective.source, overrideActive: effective.overrideActive, effectiveUserDataDir: effective.userDataDir, @@ -83,14 +147,15 @@ 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 managedDefaultMessage = describeManagedBrowserAttachNotReady( + const managedMessage = describeManagedBrowserAttachNotReady( effective, `Chrome reuse metadata not found: ${path.join(effective.userDataDir, 'DevToolsActivePort')}`, { @@ -98,13 +163,13 @@ async function buildClaudeBrowserStatus( launchCommand: launchCommands[getNodePlatformKey()], } ); - if (managedDefaultMessage) { + if (managedMessage) { return { ...base, - state: managedDefaultMessage.state, - title: managedDefaultMessage.title, - detail: managedDefaultMessage.detail, - nextStep: managedDefaultMessage.nextStep, + state: managedMessage.state, + title: managedMessage.title, + detail: managedMessage.detail, + nextStep: managedMessage.nextStep, }; } } @@ -120,23 +185,28 @@ 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 managedDefaultMessage = describeManagedBrowserAttachNotReady(effective, message, { + const managedMessage = describeManagedBrowserAttachNotReady(effective, message, { createdProfileDir: managedBootstrap.createdProfileDir, launchCommand: launchCommands[getNodePlatformKey()], }); - if (managedDefaultMessage) { + if (managedMessage) { return { ...base, - state: managedDefaultMessage.state, - title: managedDefaultMessage.title, - detail: managedDefaultMessage.detail, - nextStep: managedDefaultMessage.nextStep, + state: managedMessage.state, + title: managedMessage.title, + detail: managedMessage.detail, + nextStep: managedMessage.nextStep, }; } @@ -170,15 +240,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, + evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode), 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', supportsConfigOverrides: false, binaryPath: null, @@ -190,6 +263,8 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows if (!binaryInfo || !supportsConfigOverrides) { return { enabled: true, + policy: browserConfig.codex.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode), state: 'unsupported_build', title: 'Codex Browser Tools need a Codex build with --config override support.', detail: binaryInfo @@ -205,10 +280,18 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows return { enabled: true, + policy: browserConfig.codex.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode), 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', supportsConfigOverrides, binaryPath: binaryInfo.path, @@ -216,11 +299,14 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows }; } -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, + evalMode: 'readonly', + }).join('\n'); } diff --git a/src/utils/browser/index.ts b/src/utils/browser/index.ts index 502534a0..dbcf892f 100644 --- a/src/utils/browser/index.ts +++ b/src/utils/browser/index.ts @@ -16,16 +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 { @@ -35,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'; diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index f32b83a5..5bb7d79c 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -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); } /** diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index 1bae3cf8..d45b218d 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -109,9 +109,14 @@ export function getImageAnalysisHookEnv( : resolveImageAnalysisStatus({ profileName: '' }, config); const skipImageAnalysis = !status.supported; const runtimeApiKey = - typeof context === 'object' && context.cliproxyBridge + !skipImageAnalysis && typeof context === 'object' && context.cliproxyBridge ? resolveCliproxyBridgeProfile(context.cliproxyBridge.provider).apiKey : ''; + const runtimePath = skipImageAnalysis ? '' : status.runtimePath || ''; + const runtimeBaseUrl = + skipImageAnalysis || typeof context !== 'object' + ? '' + : context.cliproxyBridge?.currentBaseUrl || ''; return { CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0', @@ -120,9 +125,8 @@ export function getImageAnalysisHookEnv( CCS_CURRENT_PROVIDER: status.backendId || '', CCS_IMAGE_ANALYSIS_BACKEND_ID: status.backendId || '', CCS_IMAGE_ANALYSIS_MODEL: status.model || '', - CCS_IMAGE_ANALYSIS_RUNTIME_PATH: status.runtimePath || '', - CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: - typeof context === 'object' ? context.cliproxyBridge?.currentBaseUrl || '' : '', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: runtimePath, + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: runtimeBaseUrl, ...(runtimeApiKey ? { CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: runtimeApiKey } : {}), CCS_IMAGE_ANALYSIS_PROMPTS_DIR: getPromptsDir(), CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0', diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index b6f26ab2..a6a5fd63 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -26,6 +26,87 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } +const ANTHROPIC_ROUTING_ENV_KEYS = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', +]; +const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS); +const ANTHROPIC_MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_SMALL_FAST_MODEL', +]; +const TMUX_SYNC_ENV_KEYS = [ + 'CLAUDE_CONFIG_DIR', + 'CCS_PROFILE_TYPE', + 'CCS_WEBSEARCH_SKIP', + 'CCS_STRIP_INHERITED_ANTHROPIC_ENV', + 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', + ...ANTHROPIC_MODEL_ENV_KEYS, + ...ANTHROPIC_ROUTING_ENV_KEYS, +]; + +/** + * Strip inherited Anthropic routing/auth env while preserving model intent. + * Used for nested settings-profile Claude launches where `--settings` already + * defines the provider transport and the parent process should only lend model + * defaults or effort hints. + */ +export function stripAnthropicRoutingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const key of Object.keys(env)) { + if (!ANTHROPIC_ROUTING_ENV_KEY_SET.has(key.toUpperCase())) { + result[key] = env[key]; + } + } + return result; +} + +function syncTmuxNestedSessionEnv(env: NodeJS.ProcessEnv, profileType: string | undefined): void { + if (!process.env.TMUX) { + return; + } + + const nestedSessionEnv = + profileType === 'account' || profileType === 'default' + ? stripAnthropicEnv(env) + : profileType === 'settings' + ? stripAnthropicRoutingEnv(env) + : env; + + for (const key of TMUX_SYNC_ENV_KEYS) { + try { + const value = nestedSessionEnv[key]; + if (value !== undefined) { + spawnSync('tmux', ['setenv', key, value], { stdio: 'ignore' }); + } else { + spawnSync('tmux', ['setenv', '-u', key], { stdio: 'ignore' }); + } + } catch { + // tmux setenv can fail if not in a tmux session; safe to ignore + } + } +} + +/** + * Strip inherited browser attach/runtime env vars from a process environment. + * + * Browser capability is opt-in and launch-scoped. Stale CCS_BROWSER_* values + * from the parent process must never bleed into a browser-off child launch. + */ +export function stripBrowserEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const key of Object.keys(env)) { + if (!key.toUpperCase().startsWith('CCS_BROWSER_')) { + result[key] = env[key]; + } + } + return result; +} + /** * Strip Claude Code nested-session guard env var from a process environment. * @@ -137,24 +218,32 @@ export function execClaude( const webSearchEnv = getWebSearchHookEnv(); const claudeLaunchEnv = getClaudeLaunchEnvOverrides(); - // For account/default profiles, strip ANTHROPIC_* from parent env to prevent - // stale proxy config (e.g., from prior CLIProxy sessions) from interfering - // with native Claude API routing. Settings-based profiles explicitly inject - // their own ANTHROPIC_* values, so they don't need this protection. + // Strip inherited ANTHROPIC_* when the launch should not reuse parent routing. + // Account/default profiles need full isolation from prior proxy sessions. + // Settings profiles can selectively strip only routing/auth when `--settings` + // already carries the provider source of truth but the parent model intent + // should still flow into nested Team/subagent launches. const profileType = envVars?.CCS_PROFILE_TYPE; - const baseEnv = - profileType === 'account' || profileType === 'default' - ? stripAnthropicEnv(process.env) + const stripInheritedAnthropicEnv = profileType === 'account' || profileType === 'default'; + const stripInheritedAnthropicRoutingEnv = envVars?.CCS_STRIP_INHERITED_ANTHROPIC_ENV === '1'; + const inheritedEnv = stripInheritedAnthropicEnv + ? stripAnthropicEnv(process.env) + : stripInheritedAnthropicRoutingEnv + ? stripAnthropicRoutingEnv(process.env) : process.env; + const baseEnv = stripBrowserEnv(inheritedEnv); // Prepare environment (merge with base env if envVars provided) const mergedEnv = envVars ? { ...baseEnv, ...claudeLaunchEnv, ...envVars, ...webSearchEnv } : { ...baseEnv, ...claudeLaunchEnv, ...webSearchEnv }; + const effectiveMergedEnv = stripInheritedAnthropicRoutingEnv + ? stripAnthropicRoutingEnv(mergedEnv) + : mergedEnv; // Strip Claude Code nested session guard env var to allow CCS delegation // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) - const env = stripClaudeCodeEnv(mergedEnv); + const env = stripClaudeCodeEnv(effectiveMergedEnv); if (profileType !== 'account') { try { @@ -164,20 +253,9 @@ export function execClaude( } } - // propagate key env vars to tmux session so agent team teammates - // (spawned via tmux split-window) inherit the correct config dir - if (process.env.TMUX && envVars) { - const tmuxPropagateVars = ['CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', 'CCS_WEBSEARCH_SKIP']; - for (const key of tmuxPropagateVars) { - if (envVars[key]) { - try { - spawnSync('tmux', ['setenv', key, envVars[key] ?? ''], { stdio: 'ignore' }); - } catch { - // tmux setenv can fail if not in a tmux session; safe to ignore - } - } - } - } + // Keep tmux teammate panes aligned with the nested-safe Claude runtime env + // rather than the tmux server's original shell environment. + syncTmuxNestedSessionEnv(env, profileType); let child: ChildProcess; if (isPowerShellScript) { diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index c42272a2..c3f04f04 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -3,7 +3,7 @@ * Session-based auth with httpOnly cookies for CCS dashboard. */ -import type { Request, Response, NextFunction } from 'express'; +import type { NextFunction, Request, Response } from 'express'; import session from 'express-session'; import rateLimit from 'express-rate-limit'; import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader'; @@ -84,7 +84,11 @@ export const loginRateLimiter = rateLimit({ /** * Create session middleware configured for CCS dashboard. */ -export function createSessionMiddleware() { +export function createSessionMiddleware(): ( + req: Request, + res: Response, + next: NextFunction +) => void { const authConfig = getDashboardAuthConfig(); const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000; diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index d8e40679..93da148b 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -228,6 +228,13 @@ const PRICING_REGISTRY: Record = { 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 diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts index 14d8bdc3..f25dcc2f 100644 --- a/src/web-server/routes/browser-routes.ts +++ b/src/web-server/routes/browser-routes.ts @@ -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, BrowserEvalMode } 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,14 +12,30 @@ const BROWSER_LOCAL_ACCESS_ERROR = interface BrowserRouteBody { claude?: { enabled?: boolean; + policy?: 'auto' | 'manual'; userDataDir?: string; devtoolsPort?: number; + evalMode?: BrowserEvalMode; }; codex?: { enabled?: boolean; + policy?: 'auto' | 'manual'; + evalMode?: BrowserEvalMode; }; } +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isValidBrowserPolicy(value: string): value is 'auto' | 'manual' { + return value === 'auto' || value === 'manual'; +} + +function isValidBrowserEvalMode(value: string): value is BrowserEvalMode { + return value === 'disabled' || value === 'readonly' || value === 'readwrite'; +} + function isValidDevtoolsPort(value: number): boolean { return Number.isInteger(value) && value >= 1 && value <= 65535; } @@ -30,7 +48,7 @@ router.use((req: Request, res: Response, next) => { router.get('/', async (_req: Request, res: Response): Promise => { try { - const config = getBrowserConfig(); + const config = getUserFacingBrowserConfig(); const status = await getBrowserStatus(); res.json({ config: toBrowserRouteConfig(config), @@ -50,29 +68,64 @@ router.get('/status', async (_req: Request, res: Response): Promise => { }); router.put('/', async (req: Request, res: Response): Promise => { - 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; + 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' && + key !== 'evalMode' + ); + 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' && key !== 'evalMode' + ); + 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; @@ -86,29 +139,58 @@ router.put('/', async (req: Request, res: Response): Promise => { }); return; } + if ( + claude?.evalMode !== undefined && + (typeof claude.evalMode !== 'string' || !isValidBrowserEvalMode(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?.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; + } + if ( + codex?.evalMode !== undefined && + (typeof codex.evalMode !== 'string' || !isValidBrowserEvalMode(codex.evalMode)) + ) { + res.status(400).json({ + error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + 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, + policy: codex?.policy ?? current.codex.policy, + eval_mode: codex?.evalMode ?? current.codex.eval_mode, }, }; }); - const config = getBrowserConfig(); + const config = getUserFacingBrowserConfig(); const status = await getBrowserStatus(); res.json({ success: true, @@ -122,15 +204,19 @@ router.put('/', async (req: Request, res: Response): Promise => { } }); -function toBrowserRouteConfig(config: ReturnType) { +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 ?? 'readonly', }, codex: { enabled: config.codex.enabled, + policy: config.codex.policy, + evalMode: config.codex.eval_mode ?? 'readonly', }, }; } diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 305d3315..f6b9b361 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -1,8 +1,4 @@ -import { - installCliproxyVersion, - resolveLocalBackend, - syncPlusFallbackStateIfNeeded, -} from '../../cliproxy/binary-manager'; +import { installCliproxyVersion, resolveLocalBackend } from '../../cliproxy/binary-manager'; import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/service-manager'; import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker'; import { isCliproxyRunning } from '../../cliproxy/stats-fetcher'; @@ -56,8 +52,7 @@ export async function installDashboardCliproxyVersion( backend: CLIProxyBackend, deps: InstallDashboardCliproxyVersionDeps = defaultDeps ): Promise { - syncPlusFallbackStateIfNeeded(backend); - const effectiveBackend = resolveLocalBackend(backend, { warnOnFallback: true }); + const effectiveBackend = resolveLocalBackend(backend, { notifyOnPlus: true }); const backendLabel = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; const shouldRestoreService = await wasProxyRunning(deps); diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts index 021a0919..4d287221 100644 --- a/tests/e2e/proxy-command.e2e.test.ts +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -21,7 +21,32 @@ function runCli(args: string[], extraEnv: Record = {}) { }); } +function writeJson(filePath: string, data: unknown) { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); +} + +function createProfileConfig(profiles: Record>) { + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const configProfiles = Object.fromEntries( + Object.keys(profiles).map((name) => [name, path.join(ccsDir, `${name}.settings.json`)]) + ); + writeJson(path.join(ccsDir, 'config.json'), { profiles: configProfiles }); + for (const [name, env] of Object.entries(profiles)) { + writeJson(configProfiles[name], { env }); + } +} + +function getRunningPort(statusOutput: string) { + const match = statusOutput.match(/Local URL: http:\/\/127\.0\.0\.1:(\d+)/); + expect(match).not.toBeNull(); + return Number(match?.[1]); +} 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,45 +70,66 @@ afterEach(() => { }); describe('proxy command e2e', () => { - it('starts, reports status, activates, and stops via the built CLI', async () => { - const port = await getPort(); - const ccsDir = path.join(tempDir, '.ccs'); - fs.mkdirSync(ccsDir, { recursive: true }); - const settingsPath = path.join(ccsDir, 'hf.settings.json'); - fs.writeFileSync( - path.join(ccsDir, 'config.json'), - JSON.stringify({ profiles: { hf: settingsPath } }, null, 2), - 'utf8' - ); - fs.writeFileSync( - settingsPath, - JSON.stringify({ - env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', - ANTHROPIC_AUTH_TOKEN: 'ollama', - ANTHROPIC_MODEL: 'qwen3-coder', - CCS_DROID_PROVIDER: 'generic-chat-completion-api', - }, - }), - 'utf8' - ); + 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 [profile] [options]'); + expect(help.stdout).toContain('stop [profile] Stop the running proxy (or all proxies when omitted)'); + expect(help.stdout).toContain('Pin an exact local proxy port for this launch'); + expect(help.stdout).not.toContain('default: 3456'); + }); - const started = runCli(['proxy', 'start', 'hf', '--port', String(port), '--host', '127.0.0.1']); - expect(started.status).toBe(0); + 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('surfaces the actual adaptive port in status and activation output', async () => { + createProfileConfig({ + hf: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }); + expect(runCli(['proxy', 'start', 'hf', '--host', '127.0.0.1']).status).toBe(0); + const status = runCli(['proxy', 'status', 'hf']); + expect(status.status).toBe(0); + const port = getRunningPort(status.stdout); expect(status.stdout).toContain(`Proxy running on port ${port}`); expect(status.stdout).toContain('Host: 127.0.0.1'); expect(status.stdout).toContain('Profile: hf'); - - const activate = runCli(['proxy', 'activate', '--shell', 'bash']); + const activate = runCli(['proxy', 'activate', 'hf', '--shell', 'bash']); expect(activate.stdout).toContain(`export ANTHROPIC_BASE_URL='http://127.0.0.1:${port}'`); expect(activate.stdout).toMatch(/export ANTHROPIC_AUTH_TOKEN='[a-f0-9]{48}'/); expect(activate.stdout).toContain("export DISABLE_TELEMETRY='1'"); expect(activate.stdout).toContain("export DISABLE_COST_WARNINGS='1'"); expect(activate.stdout).toContain("export API_TIMEOUT_MS='600000'"); expect(activate.stdout).toContain("export NO_PROXY='127.0.0.1,localhost'"); - const activateFish = runCli(['proxy', 'activate', '--fish']); expect(activateFish.stdout).toContain(`set -gx ANTHROPIC_BASE_URL 'http://127.0.0.1:${port}'`); @@ -107,4 +153,63 @@ describe('proxy command e2e', () => { const stopped = runCli(['proxy', 'stop']); expect(stopped.status).toBe(0); }, 35000); + + it('respects an explicit --port override in status output', async () => { + const port = await getPort(); + createProfileConfig({ + hf: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }); + const started = runCli(['proxy', 'start', 'hf', '--port', String(port), '--host', '127.0.0.1']); + expect(started.status).toBe(0); + const status = runCli(['proxy', 'status', 'hf']); + expect(status.status).toBe(0); + expect(status.stdout).toContain(`Proxy running on port ${port}`); + expect(status.stdout).toContain(`Local URL: http://127.0.0.1:${port}`); + }, 35000); + + it('rejects malformed explicit port values instead of coercing numeric prefixes', () => { + createProfileConfig({ + hf: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }); + + const invalid = runCli(['proxy', 'start', 'hf', '--port', '3456junk']); + expect(invalid.status).toBe(1); + expect(invalid.stderr).toContain('Invalid port: 3456junk'); + }); + + it('requires an explicit profile when activating with multiple running proxies', async () => { + const firstPort = await getPort(); + createProfileConfig({ + ccg: { + 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', + }, + ccgm: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-ccgm', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }); + 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 ' + ); + }, 35000); }); diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 5a5aa86d..0d694a3c 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -5,10 +5,19 @@ import * as path from 'path'; import getPort from 'get-port'; import { getOpenAICompatProxyStatus, + listOpenAICompatProxyStatuses, startOpenAICompatProxy, stopOpenAICompatProxy, } from '../../../src/proxy/proxy-daemon'; +import { resolveOpenAICompatProxyPreferredPort } from '../../../src/proxy/proxy-port-resolver'; 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 +84,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 +127,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 +149,640 @@ 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('uses an adaptive implicit port instead of defaulting to 3456 for shared defaults', async () => { + const settingsPath = path.join(tempDir, 'adaptive-default.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-adaptive-default', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('adaptive-default', settingsPath, { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-adaptive-default', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!profile) { + throw new Error('Expected adaptive-default OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile); + expect(started.success).toBe(true); + expect(started.port).not.toBe(3456); + }); + + 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 server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch: () => new Response('busy'), + }); + const occupiedPort = server.port; + + 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 server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch: () => new Response('busy'), + }); + const occupiedPort = server.port; + + 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('falls back when a configured shared proxy.port is occupied', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch: () => new Response('busy'), + }); + const occupiedPort = server.port; + + try { + mutateUnifiedConfig((config) => { + config.proxy = { + ...(config.proxy ?? {}), + port: occupiedPort, + }; + }); + + const settingsPath = path.join(tempDir, 'shared-port.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-shared-port', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('shared-port', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-shared-port', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected shared-port OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile); + expect(started.success).toBe(true); + expect(started.port).not.toBe(occupiedPort); + } finally { + server.stop(true); + } + }); + + it('keeps the existing proxy running if replacement startup fails', async () => { + const firstPort = await getPort(); + const busyServer = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch: () => new Response('busy'), + }); + const occupiedPort = busyServer.port; + + 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('returns to the adaptive canonical port after a stale fallback session', async () => { + const stalePort = 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: stalePort, + 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(resolveOpenAICompatProxyPreferredPort('outside-range')); + expect(started.port).not.toBe(stalePort); + }); + + it('does not keep a stopped shared-default profile anchored to legacy port 3456', async () => { + const settingsPath = path.join(tempDir, 'legacy-shared-default.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-legacy-shared-default', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('legacy-shared-default', settingsPath, { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-legacy-shared-default', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!profile) { + throw new Error('Expected legacy-shared-default OpenAI-compatible profile'); + } + + fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath('legacy-shared-default')), { + recursive: true, + }); + fs.writeFileSync( + getOpenAICompatProxySessionPath('legacy-shared-default'), + JSON.stringify( + { + profileName: profile.profileName, + settingsPath: profile.settingsPath, + host: '127.0.0.1', + port: 3456, + 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).not.toBe(3456); + }); + + 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); }); }); diff --git a/tests/integration/proxy/messages-edge-cases.test.ts b/tests/integration/proxy/messages-edge-cases.test.ts index db85114e..faefb833 100644 --- a/tests/integration/proxy/messages-edge-cases.test.ts +++ b/tests/integration/proxy/messages-edge-cases.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import getPort from 'get-port'; import * as fs from 'fs'; import * as http from 'http'; import * as os from 'os'; @@ -16,6 +15,29 @@ let tempDir: string; let originalTimeoutEnv: string | undefined; let originalCcsHome: string | undefined; +function resolveListeningPort(server: http.Server): number { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve server port'); + } + return address.port; +} + +async function waitForServerListening(server: http.Server): Promise { + if (server.listening) { + return resolveListeningPort(server); + } + + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(resolveListeningPort(server)); + }); + }); +} + async function startUpstream( handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise | void ): Promise { @@ -28,9 +50,8 @@ async function startUpstream( upstreamSockets.delete(socket); }); }); - await new Promise((resolve) => - upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve()) - ); + upstreamServer.listen(0, '127.0.0.1'); + upstreamPort = await waitForServerListening(upstreamServer); } async function requestProxy(payload: unknown, signal?: AbortSignal): Promise { @@ -46,8 +67,6 @@ async function requestProxy(payload: unknown, signal?: AbortSignal): Promise { - upstreamPort = await getPort(); - proxyPort = await getPort(); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-edge-')); originalTimeoutEnv = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS; originalCcsHome = process.env.CCS_HOME; @@ -94,9 +113,10 @@ describe('openai proxy message edge cases', () => { }; proxyServer = startOpenAICompatProxyServer({ profile, - port: proxyPort, + port: 0, authToken: 'test-proxy-token', }); + proxyPort = await waitForServerListening(proxyServer); } it('preserves rate-limit errors from the upstream provider', async () => { @@ -224,58 +244,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((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((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((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((resolve, reject) => { + const startedAt = Date.now(); + const timer = setInterval(() => { + if (fs.existsSync(logPath)) { + const content = fs.readFileSync(logPath, 'utf8'); + if (content.includes('"event":"request.disconnect"')) { + clearInterval(timer); + resolve(); + return; + } + } + + if (Date.now() - startedAt > 1500) { + clearInterval(timer); + reject(new Error('proxy did not log disconnect cleanup')); + } + }, 50); + }), + ]); + } + ); }); diff --git a/tests/integration/proxy/messages-endpoint.test.ts b/tests/integration/proxy/messages-endpoint.test.ts index 0c3fe0d0..d8fdf275 100644 --- a/tests/integration/proxy/messages-endpoint.test.ts +++ b/tests/integration/proxy/messages-endpoint.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import getPort from 'get-port'; import * as http from 'http'; import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server'; import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; @@ -10,28 +9,64 @@ let upstreamBody: unknown; let upstreamPort: number; let proxyPort: number; -function startMockUpstream(): Promise { - return new Promise((resolve) => { - upstreamServer = http.createServer(async (req, res) => { - if (req.method !== 'POST' || req.url !== '/v1/chat/completions') { - res.writeHead(404).end(); - return; - } +function resolveListeningPort(server: http.Server): number { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve server port'); + } + return address.port; +} - let body = ''; - for await (const chunk of req) { - body += chunk.toString(); - } - upstreamBody = JSON.parse(body); - const parsed = upstreamBody as { stream?: boolean }; +async function waitForServerListening(server: http.Server): Promise { + if (server.listening) { + return resolveListeningPort(server); + } - if (parsed.stream) { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(resolveListeningPort(server)); + }); + }); +} + +async function startMockUpstream(): Promise { + upstreamServer = http.createServer(async (req, res) => { + if (req.method !== 'POST' || req.url !== '/v1/chat/completions') { + res.writeHead(404).end(); + return; + } + + let body = ''; + for await (const chunk of req) { + body += chunk.toString(); + } + upstreamBody = JSON.parse(body); + const parsed = upstreamBody as { + stream?: boolean; + 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":{"role":"assistant","content":"Hello"}}]}\n\n' + '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":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n' + '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' @@ -40,25 +75,38 @@ function startMockUpstream(): Promise { return; } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - id: 'chatcmpl_1', - model: 'hf-model', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Plain answer' }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 2, completion_tokens: 3 }, - }) + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n' ); - }); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n' + ); + res.end('data: [DONE]\n\n'); + return; + } - upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'chatcmpl_1', + model: 'hf-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Plain answer' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 3 }, + }) + ); }); + + upstreamServer.listen(0, '127.0.0.1'); + upstreamPort = await waitForServerListening(upstreamServer); } async function requestProxy(payload: unknown): Promise { @@ -74,8 +122,6 @@ async function requestProxy(payload: unknown): Promise { } beforeEach(async () => { - upstreamPort = await getPort(); - proxyPort = await getPort(); upstreamBody = undefined; await startMockUpstream(); const profile: OpenAICompatProfileConfig = { @@ -88,9 +134,10 @@ beforeEach(async () => { }; proxyServer = startOpenAICompatProxyServer({ profile, - port: proxyPort, + port: 0, authToken: 'test-proxy-token', }); + proxyPort = await waitForServerListening(proxyServer); }); afterEach(async () => { @@ -297,4 +344,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); + }); }); diff --git a/tests/integration/proxy/request-routing.test.ts b/tests/integration/proxy/request-routing.test.ts index b9f7afe8..080405b2 100644 --- a/tests/integration/proxy/request-routing.test.ts +++ b/tests/integration/proxy/request-routing.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import getPort from 'get-port'; import * as fs from 'fs'; import * as http from 'http'; import * as os from 'os'; @@ -13,40 +12,61 @@ let proxyServer: http.Server; let upstreamServers: http.Server[] = []; let proxyPort: number; -function startMockUpstream( - port: number, +function resolveListeningPort(server: http.Server): number { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve server port'); + } + return address.port; +} + +async function waitForServerListening(server: http.Server): Promise { + if (server.listening) { + return resolveListeningPort(server); + } + + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(resolveListeningPort(server)); + }); + }); +} + +async function startMockUpstream( hitLabel: string, hits: string[], bodies: Array<{ label: string; body: unknown }> -): Promise { - return new Promise((resolve) => { - const server = http.createServer(async (req, res) => { - let body = ''; - for await (const chunk of req) { - body += chunk.toString(); - } - hits.push(hitLabel); - bodies.push({ label: hitLabel, body: JSON.parse(body) }); +): Promise { + const server = http.createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) { + body += chunk.toString(); + } + hits.push(hitLabel); + bodies.push({ label: hitLabel, body: JSON.parse(body) }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - id: `chatcmpl_${hitLabel}`, - model: hitLabel, - choices: [ - { - index: 0, - message: { role: 'assistant', content: `Reply from ${hitLabel}` }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 2, completion_tokens: 3 }, - }) - ); - }); - upstreamServers.push(server); - server.listen(port, '127.0.0.1', () => resolve()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: `chatcmpl_${hitLabel}`, + model: hitLabel, + choices: [ + { + index: 0, + message: { role: 'assistant', content: `Reply from ${hitLabel}` }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 3 }, + }) + ); }); + upstreamServers.push(server); + server.listen(0, '127.0.0.1'); + return waitForServerListening(server); } function writeSettings(profileName: string, env: Record): string { @@ -71,7 +91,7 @@ beforeEach(async () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-routing-')); fs.mkdirSync(path.join(tempDir, '.ccs'), { recursive: true }); process.env.CCS_HOME = tempDir; - proxyPort = await getPort(); + proxyPort = 0; }); afterEach(async () => { @@ -97,12 +117,10 @@ afterEach(async () => { describe('openai proxy request routing', () => { it('routes explicit profile:model selectors to the matching upstream profile', async () => { - const primaryPort = await getPort(); - const secondaryPort = await getPort(); const hits: string[] = []; const bodies: Array<{ label: string; body: unknown }> = []; - await startMockUpstream(primaryPort, 'primary', hits, bodies); - await startMockUpstream(secondaryPort, 'secondary', hits, bodies); + const primaryPort = await startMockUpstream('primary', hits, bodies); + const secondaryPort = await startMockUpstream('secondary', hits, bodies); const primarySettings = writeSettings('hf', { ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`, @@ -133,9 +151,10 @@ describe('openai proxy request routing', () => { }; proxyServer = startOpenAICompatProxyServer({ profile, - port: proxyPort, + port: 0, authToken: 'test-proxy-token', }); + proxyPort = await waitForServerListening(proxyServer); const response = await requestProxy({ model: 'deepseek:deepseek-reasoner', @@ -150,12 +169,10 @@ describe('openai proxy request routing', () => { }); it('routes thinking requests through the configured think scenario', async () => { - const primaryPort = await getPort(); - const thinkPort = await getPort(); const hits: string[] = []; const bodies: Array<{ label: string; body: unknown }> = []; - await startMockUpstream(primaryPort, 'primary', hits, bodies); - await startMockUpstream(thinkPort, 'thinker', hits, bodies); + const primaryPort = await startMockUpstream('primary', hits, bodies); + const thinkPort = await startMockUpstream('thinker', hits, bodies); const primarySettings = writeSettings('hf', { ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`, @@ -197,9 +214,10 @@ describe('openai proxy request routing', () => { }; proxyServer = startOpenAICompatProxyServer({ profile, - port: proxyPort, + port: 0, authToken: 'test-proxy-token', }); + proxyPort = await waitForServerListening(proxyServer); const response = await requestProxy({ model: 'hf-default', @@ -216,12 +234,10 @@ describe('openai proxy request routing', () => { }); 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 primaryPort = await startMockUpstream('primary', hits, bodies); + const thinkPort = await startMockUpstream('thinker', hits, bodies); const primarySettings = writeSettings('hf', { ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`, @@ -263,9 +279,10 @@ describe('openai proxy request routing', () => { }; proxyServer = startOpenAICompatProxyServer({ profile, - port: proxyPort, + port: 0, authToken: 'test-proxy-token', }); + proxyPort = await waitForServerListening(proxyServer); const response = await requestProxy({ model: 'hf-default', diff --git a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts index 4ad5145f..e688e009 100644 --- a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts +++ b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts @@ -17,6 +17,8 @@ import { handleQuotaExhaustion, writeQuotaWarning, maskEmail, + pauseAccountForQuotaCooldown, + restoreExpiredQuotaPauses, } from '../../../src/cliproxy/account-safety'; import { sanitizeEmail } from '../../../src/cliproxy/auth-utils'; @@ -84,6 +86,12 @@ function writeClaudeAuth(accountId: string, accessToken: string): void { ); } +function writeAuthToken(tokenFile: string, payload: Record): void { + const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(payload, null, 2)); +} + describe('Quota Exhaustion Handlers', () => { describe('writeQuotaWarning', () => { it('should write to stderr with box format', async () => { @@ -247,10 +255,13 @@ describe('Quota Exhaustion Handlers', () => { }); const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10); + const { getAccount } = await import('../../../src/cliproxy/account-manager'); // Should return gracefully with null switched expect(result.switchedTo).toBeNull(); expect(result.reason).toContain('no alternatives'); + expect(getAccount('agy', 'only@gmail.com')?.paused).not.toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false); }); it('should switch Claude accounts when fallback quota endpoint returns 404', async () => { @@ -305,6 +316,18 @@ describe('Quota Exhaustion Handlers', () => { expect(result.switchedTo).toBe('fallback@example.com'); expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com'); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(true); + expect( + fs.existsSync( + path.join( + tmpDir, + '.ccs', + 'cliproxy', + 'auth-paused', + `claude-${sanitizeEmail('exhausted@example.com')}.json` + ) + ) + ).toBe(true); }); it('should write warning to stderr', async () => { @@ -389,5 +412,121 @@ describe('Quota Exhaustion Handlers', () => { expect(result).toBeDefined(); expect(result.switchedTo).toBeNull(); }); + + it('auto-resumes quota-paused accounts after cooldown expiry', async () => { + writeRegistry({ + agy: { + default: 'cooldown@gmail.com', + accounts: { + 'cooldown@gmail.com': { + email: 'cooldown@gmail.com', + tokenFile: 'agy-cooldown.json', + }, + }, + }, + }); + writeAuthToken('agy-cooldown.json', { + type: 'agy', + email: 'cooldown@gmail.com', + access_token: 'token', + }); + + const now = Date.now(); + expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true); + + const { getAccount } = await import('../../../src/cliproxy/account-manager'); + expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true); + + const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000); + + expect(resumed).toBe(1); + expect(getAccount('agy', 'cooldown@gmail.com')?.paused).not.toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false); + }); + + it('does not auto-resume quota-paused accounts when pausedAt metadata is missing', async () => { + writeRegistry({ + agy: { + default: 'cooldown@gmail.com', + accounts: { + 'cooldown@gmail.com': { + email: 'cooldown@gmail.com', + tokenFile: 'agy-cooldown.json', + }, + }, + }, + }); + writeAuthToken('agy-cooldown.json', { + type: 'agy', + email: 'cooldown@gmail.com', + access_token: 'token', + }); + + const now = Date.now(); + expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true); + + const registryPath = path.join(tmpDir, '.ccs', 'cliproxy', 'accounts.json'); + const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as { + providers: { + agy: { + default: string; + accounts: Record; + }; + }; + }; + delete registry.providers.agy.accounts['cooldown@gmail.com']?.pausedAt; + fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2)); + + const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000); + const { getAccount } = await import('../../../src/cliproxy/account-manager'); + + expect(resumed).toBe(0); + expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json')) + ).toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false); + }); + + it('keeps quota-paused entries when auto-resume fails and retries later', async () => { + writeRegistry({ + agy: { + default: 'cooldown@gmail.com', + accounts: { + 'cooldown@gmail.com': { + email: 'cooldown@gmail.com', + tokenFile: 'agy-cooldown.json', + }, + }, + }, + }); + writeAuthToken('agy-cooldown.json', { + type: 'agy', + email: 'cooldown@gmail.com', + access_token: 'token', + }); + + const now = Date.now(); + expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true); + + fs.rmSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth'), { + recursive: true, + force: true, + }); + + const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000); + const { getAccount } = await import('../../../src/cliproxy/account-manager'); + const quotaPausedPath = path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'); + const quotaPaused = JSON.parse(fs.readFileSync(quotaPausedPath, 'utf8')) as { + entries?: Array<{ accountId?: string }>; + }; + + expect(resumed).toBe(0); + expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json')) + ).toBe(true); + expect(quotaPaused.entries?.map((entry) => entry.accountId)).toContain('cooldown@gmail.com'); + }); }); }); diff --git a/tests/unit/cliproxy/backend-selection.test.js b/tests/unit/cliproxy/backend-selection.test.js index 2a36c93e..8f46fb3a 100644 --- a/tests/unit/cliproxy/backend-selection.test.js +++ b/tests/unit/cliproxy/backend-selection.test.js @@ -20,7 +20,7 @@ describe('Backend Selection', () => { it('has correct configuration for plus backend', () => { const config = platformDetector.BACKEND_CONFIG.plus; - assert.strictEqual(config.repo, 'router-for-me/CLIProxyAPIPlus'); + assert.strictEqual(config.repo, 'kaitranntt/CLIProxyAPIPlus'); assert.strictEqual(config.binaryPrefix, 'CLIProxyAPIPlus'); assert.strictEqual(config.executable, 'cli-proxy-api-plus'); assert(config.fallbackVersion.match(/^\d+\.\d+\.\d+-\d+$/), 'plus version has -0 suffix'); @@ -28,7 +28,7 @@ describe('Backend Selection', () => { }); describe('DEFAULT_BACKEND', () => { - it('defaults to original backend (Plus upstream deleted, issue #1062)', () => { + it('defaults to original backend', () => { assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'original'); }); }); @@ -45,7 +45,7 @@ describe('Backend Selection', () => { assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_')); }); - it('uses original backend by default (Plus upstream deleted, issue #1062)', () => { + it('uses original backend by default', () => { const info = platformDetector.detectPlatform(); assert(info.binaryName.startsWith('CLIProxyAPI_')); assert(!info.binaryName.includes('CLIProxyAPIPlus')); @@ -77,7 +77,7 @@ describe('Backend Selection', () => { assert.strictEqual(name, expected); }); - it('defaults to original backend (Plus upstream deleted, issue #1062)', () => { + it('defaults to original backend', () => { const name = platformDetector.getExecutableName(); const expected = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api'; assert.strictEqual(name, expected); @@ -93,10 +93,10 @@ describe('Backend Selection', () => { it('uses correct repo for plus backend', () => { const url = platformDetector.getDownloadUrl('6.6.51-0', 'plus'); - assert(url.includes('router-for-me/CLIProxyAPIPlus/releases')); + assert(url.includes('kaitranntt/CLIProxyAPIPlus/releases')); }); - it('defaults to original backend (Plus upstream deleted, issue #1062)', () => { + it('defaults to original backend', () => { const url = platformDetector.getDownloadUrl(); assert(url.includes('router-for-me/CLIProxyAPI/releases')); assert(!url.includes('CLIProxyAPIPlus')); diff --git a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts index c8fe0d1d..5670eecf 100644 --- a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts +++ b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts @@ -19,9 +19,12 @@ import { } from '../../../ui/src/lib/default-ports'; import { CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS, + CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_PROVIDERS, DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS, + PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS, PROVIDER_METADATA as UI_PROVIDER_METADATA, } from '../../../ui/src/lib/provider-config'; +import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types'; function sorted(values: readonly string[]): string[] { return [...values].sort((a, b) => a.localeCompare(b)); @@ -44,6 +47,17 @@ describe('Default Port Sync', () => { expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code'))); }); + test('plus-extra providers are synced between backend and UI', () => { + expect(sorted(UI_PLUS_EXTRA_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_PLUS_ONLY_PROVIDERS)); + expect(sorted(UI_CORE_CLIPROXY_PROVIDERS)).toEqual( + sorted( + BACKEND_CLIPROXY_PROVIDER_IDS.filter( + (provider) => !BACKEND_PLUS_ONLY_PROVIDERS.includes(provider) + ) + ) + ); + }); + test('Provider display names are synced between backend and UI', () => { for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) { expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider)); diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index d76f1fdd..dfe781f5 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -25,7 +25,7 @@ afterEach(() => { }); describe('installCliproxyVersion', () => { - it('degrades explicit plus backend requests to original before install flows run', async () => { + it('keeps explicit plus backend requests on plus before install flows run', async () => { let seenBackend: string | undefined; const binaryManager = await import( @@ -38,7 +38,7 @@ describe('installCliproxyVersion', () => { return { isBinaryInstalled: () => false, deleteBinary: () => undefined, - ensureBinary: async () => '/tmp/ccs-bin/original/cliproxy', + ensureBinary: async () => '/tmp/ccs-bin/plus/cliproxy', }; }, stopProxyFn: async () => ({ stopped: false, error: 'No active CLIProxy session found' }), @@ -48,10 +48,10 @@ describe('installCliproxyVersion', () => { getInstalledVersion: () => '6.6.80', }); - expect(seenBackend).toBe('original'); + expect(seenBackend).toBe('plus'); }); - it('returns original and emits a real warning when plus backend is resolved locally', async () => { + it('returns plus and emits an informational notice when plus backend is resolved locally', async () => { const binaryManager = await import( `../../../src/cliproxy/binary-manager?binary-manager-warning=${Date.now()}` ); @@ -64,16 +64,17 @@ describe('installCliproxyVersion', () => { }) as typeof process.stderr.write; try { - expect(binaryManager.resolveLocalBackend('plus', { warnOnFallback: true })).toBe('original'); + expect(binaryManager.resolveLocalBackend('plus', { notifyOnPlus: true })).toBe('plus'); } finally { process.stderr.write = originalWrite; } - expect(writes.join('')).toContain('CLIProxyAPIPlus upstream repo is currently unavailable'); + expect(writes.join('')).toContain('optional community-maintained CLIProxyAPIPlus backend'); expect(writes.join('')).toContain('backend: original'); + expect(writes.join('')).toContain('kaitranntt/CLIProxyAPIPlus'); }); - it('reuses plus binary and pin state when local runtime falls back to original', async () => { + it('uses plus binary and pin state when local runtime is configured for plus', async () => { const { createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'); const { saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'); const { savePinnedVersion } = await import('../../../src/cliproxy/binary/version-cache'); @@ -96,7 +97,45 @@ describe('installCliproxyVersion', () => { expect(status.installed).toBe(true); expect(status.pinnedVersion).toBe('6.6.80-0'); - expect(status.binaryPath).toContain('/original/'); + expect(status.binaryPath).toContain('/plus/'); + }); + + it('copies plus binary and pin state when legacy deleted upstream fallback is active', async () => { + const { createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'); + const { saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'); + const { savePinnedVersion } = await import('../../../src/cliproxy/binary/version-cache'); + const platformDetector = await import('../../../src/cliproxy/platform-detector'); + const binaryService = await import( + `../../../src/cliproxy/services/binary-service?binary-service-legacy-plus-fallback=${Date.now()}` + ); + + const plusConfig = platformDetector.BACKEND_CONFIG.plus as unknown as { repo: string }; + const originalRepo = plusConfig.repo; + + try { + plusConfig.repo = 'router-for-me/CLIProxyAPIPlus'; + + const config = createEmptyUnifiedConfig(); + config.cliproxy = { ...config.cliproxy, backend: 'plus' }; + saveUnifiedConfig(config); + + const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus'); + fs.mkdirSync(plusBinDir, { recursive: true }); + fs.writeFileSync( + path.join(plusBinDir, platformDetector.getExecutableName('plus')), + 'fake-binary' + ); + fs.writeFileSync(path.join(plusBinDir, '.version'), '6.6.80-0'); + savePinnedVersion('6.6.80-0', 'plus'); + + const status = binaryService.getBinaryStatus(); + + expect(status.installed).toBe(true); + expect(status.pinnedVersion).toBe('6.6.80-0'); + expect(status.binaryPath).toContain('/original/'); + } finally { + plusConfig.repo = originalRepo; + } }); it('attempts to stop the proxy even when there is no tracked running session', async () => { diff --git a/tests/unit/cliproxy/composite-thinking.test.ts b/tests/unit/cliproxy/composite-thinking.test.ts index 6fb3758b..6a9de3fd 100644 --- a/tests/unit/cliproxy/composite-thinking.test.ts +++ b/tests/unit/cliproxy/composite-thinking.test.ts @@ -538,6 +538,20 @@ describe('applyThinkingConfig - composite variant integration', () => { expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)'); }); + it('rewrites legacy budget suffixes for claude level-based models', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-4-7(32768)', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7(32768)', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyThinkingConfig(envVars, 'claude' as CLIProxyProvider, 'max'); + + expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-7(max)'); + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7(max)'); + }); + it('should use codex effort suffix style when provider is codex', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gpt-5.3-codex', diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 7f96cde9..acb41cca 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -40,7 +40,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { fs.rmSync(tempHome, { recursive: true, force: true }); }); - it('rewrites local root URL to provider endpoint', () => { + it('rewrites local root URL to provider endpoint without stripping codex effort suffixes', () => { writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', @@ -52,18 +52,18 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { const env = getEffectiveEnvVars('codex', 8317, settingsPath); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); - expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); - expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; }; - expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); }); it('rewrites wrong local provider path to the requested provider', () => { @@ -162,7 +162,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.presets?.[0]?.haiku).toBe('claude-sonnet-4-6'); }); - it('migrates codex preset model mappings to canonical IDs', () => { + it('preserves codex preset effort suffixes while loading provider env vars', () => { writeSettings( settingsPath, { @@ -191,10 +191,10 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { presets: Array>; }; - expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex'); - expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex'); - expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex'); - expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini'); + expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high'); + expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini-medium'); }); it('migrates iflow placeholder model IDs to a supported default', () => { @@ -424,7 +424,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-6'); }); - it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => { + it('preserves codex effort-suffixed IDs during ensureProviderSettings', () => { process.env.CCS_HOME = tempHome; const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true }); @@ -461,14 +461,14 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { env?: Record; presets?: Array>; }; - expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); - expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini'); + expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex-high'); + expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini-medium'); }); it('recovers malformed provider settings files by writing defaults and backup copy', () => { diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 39ee4e6d..2d8dee54 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -215,6 +215,42 @@ describe('resolveCliproxyImageAnalysisEnv', () => { expect(result.warning).toContain('native Read'); }); + it('strips browser env injected through settings before building Claude runtime env', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-env-strip-')); + tempDirs.push(tempDir); + + const settingsPath = path.join(tempDir, 'browser-env.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'gemini-2.5-pro', + CCS_BROWSER_USER_DATA_DIR: '/tmp/embedded-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/embedded-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env', + }, + }, + null, + 2 + ) + '\n' + ); + + const env = buildClaudeEnvironment({ + provider: 'agy', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + verbose: false, + }); + + expect(env.CCS_BROWSER_USER_DATA_DIR).toBeUndefined(); + expect(env.CCS_BROWSER_PROFILE_DIR).toBeUndefined(); + expect(env.CCS_BROWSER_DEVTOOLS_WS_URL).toBeUndefined(); + }); + it('keeps cliproxy image analysis active when the execution target is reachable', async () => { const result = await resolveCliproxyImageAnalysisEnv( { diff --git a/tests/unit/cliproxy/executor-option-value.test.ts b/tests/unit/cliproxy/executor-option-value.test.ts index b6ff278e..d8259047 100644 --- a/tests/unit/cliproxy/executor-option-value.test.ts +++ b/tests/unit/cliproxy/executor-option-value.test.ts @@ -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(); + } + }); +}); diff --git a/tests/unit/cliproxy/https-tunnel-policy.test.ts b/tests/unit/cliproxy/https-tunnel-policy.test.ts new file mode 100644 index 00000000..a0beaa13 --- /dev/null +++ b/tests/unit/cliproxy/https-tunnel-policy.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'bun:test'; +import { shouldStartHttpsTunnel } from '../../../src/cliproxy/executor/https-tunnel-policy'; + +describe('HTTPS tunnel startup policy', () => { + it('skips the tunnel for single-provider Codex remote HTTPS launches', () => { + expect( + shouldStartHttpsTunnel({ + provider: 'codex', + useRemoteProxy: true, + protocol: 'https', + host: 'remote.example.test', + }) + ).toBe(false); + }); + + it('keeps the tunnel for non-Codex remote HTTPS launches', () => { + expect( + shouldStartHttpsTunnel({ + provider: 'gemini', + useRemoteProxy: true, + protocol: 'https', + host: 'remote.example.test', + }) + ).toBe(true); + }); + + it('keeps the tunnel for composite HTTPS launches', () => { + expect( + shouldStartHttpsTunnel({ + provider: 'codex', + useRemoteProxy: true, + protocol: 'https', + host: 'remote.example.test', + isComposite: true, + }) + ).toBe(true); + }); + + it('does not start a tunnel without a remote HTTPS target', () => { + expect( + shouldStartHttpsTunnel({ + provider: 'codex', + useRemoteProxy: true, + protocol: 'http', + host: 'remote.example.test', + }) + ).toBe(false); + expect( + shouldStartHttpsTunnel({ + provider: 'codex', + useRemoteProxy: false, + protocol: 'https', + host: 'remote.example.test', + }) + ).toBe(false); + }); +}); diff --git a/tests/unit/cliproxy/model-catalog-compat.test.ts b/tests/unit/cliproxy/model-catalog-compat.test.ts index b6fc21cc..0590a44b 100644 --- a/tests/unit/cliproxy/model-catalog-compat.test.ts +++ b/tests/unit/cliproxy/model-catalog-compat.test.ts @@ -58,4 +58,24 @@ describe('model-catalog compatibility lookups', () => { expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']); }); + + it('preserves static maxLevel when live thinking metadata omits it', () => { + const catalog = mergeCatalog('claude', [ + { + id: 'claude-opus-4-7', + display_name: 'Claude Opus 4.7', + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + dynamic_allowed: true, + }, + }, + ]); + + expect(catalog?.models[0]?.thinking).toMatchObject({ + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', + dynamicAllowed: true, + }); + }); }); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 3e8f3751..704c5764 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -142,6 +142,19 @@ 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'); + // Opus 4.7 requires adaptive thinking (type: 'levels'); manual budget_tokens + // is rejected by the Anthropic API with 400. Extended (1M) context is available. + assert.strictEqual(opus47.thinking.type, 'levels'); + assert.deepStrictEqual(opus47.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']); + assert.strictEqual(opus47.thinking.maxLevel, 'max'); + 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 +279,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'); diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index d7e969cf..638ad419 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -83,7 +83,9 @@ describe('model-id-normalizer', () => { }); it('applies provider canonicalization for codex and antigravity', () => { - expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe('gpt-5.3-codex'); + expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe( + 'gpt-5.3-codex-xhigh' + ); expect(canonicalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe( 'claude-sonnet-4-6' ); @@ -94,7 +96,7 @@ describe('model-id-normalizer', () => { it('trims and canonicalizes provider model IDs with surrounding whitespace', () => { expect(canonicalizeModelIdForProvider(' gpt-5.3-codex-high ', 'codex')).toBe( - 'gpt-5.3-codex' + 'gpt-5.3-codex-high' ); expect(canonicalizeModelIdForProvider(' claude-sonnet-4.6-thinking ', 'agy')).toBe( 'claude-sonnet-4-6' @@ -120,9 +122,11 @@ describe('model-id-normalizer', () => { it('normalizes legacy codex aliases to the current supported model IDs', () => { expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4'); expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]'); + expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high'); + expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]'); expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2'); expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini'); - expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4'); + expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high'); }); }); diff --git a/tests/unit/cliproxy/remote-proxy-chain-regression.test.ts b/tests/unit/cliproxy/remote-proxy-chain-regression.test.ts new file mode 100644 index 00000000..b7e64245 --- /dev/null +++ b/tests/unit/cliproxy/remote-proxy-chain-regression.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'bun:test'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import { CodexReasoningProxy } from '../../../src/cliproxy/codex-reasoning-proxy'; +import { ToolSanitizationProxy } from '../../../src/cliproxy/tool-sanitization-proxy'; + +const SELF_SIGNED_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGH5Akfta94U5O +X7LZIeoRN9pOKiXuqiTnIrOh/dW/RZJp1Fg0M+t+Gv/WFu/KOMrUwpsPZ8ectOCy +cwdv+ME/+4RlAiGmALLinlDqCPv0Z8gRiZpfEeOinzrnBR26fphtebdlRF4Xy0rI +QcnMsC6Wr1V484CP5iM6GAdVP+gXoYINV2VtooXS/ViptmxLMqMqhjDhz6ZUfEP4 +XmQU73eM8/OaQSRIqJx1UyByERjgz0m/ySHAStS/IIKyjZVB9i0iZtwqO8Jl63ib +rzxKehVNDB0iCW9BawpQx6tqoO/hXKIUNPQaBQ8GfLwM7lCvGVSNuPLsB5mf1V65 +6UqvNVEfAgMBAAECggEAQ6Nm7G65FV4kA7G8N1DSvkoZJ3JQPOitbJN2TCmCnag8 +0hCChF/tV8IT8Z0nBBzbckN1+I5voVpHE+Udduav4w4VJv7RSgEXETMHYL7sdYYw +0XvuzeInms9Npq0idxbdJxLUv6fVj74Bq9h58n17ikoVnhNToszo3d5yMnJ85APP +tTHPJrv4oQYcrYfxksuL3RRqy4VpH/E8zA2AlakbuxfrwcqJ/kqvHCBFd/l3qfnf +U06ZqxZDYvZW9GYMvao+e2XLxT44A50EsTH0C3XIMsGGRVO+4ZzhbUBSDOgxx9pl +Lg75MrpKpFZSGO5EqjT1SX4E3eZijerHaqBS3P5sQQKBgQDnJgYyToRUq3Fb4pHF +XelW5cVg93XA/XtFWcx9ndwGRtwFYVs+b63MhILGGyDzsDzsmUjyNK013ldMTp0F +6TpMUFsc0mpyf63khNUyw7J/le447zZgwAEv/IaDf0CepCfJ149a7Y7jbZPG2R43 +ldFOxV1t8ERIiZnDd4iZjG9O7wKBgQDbbJMT8p6CGws5ZqKu0DYRbWoPvSFvMt3K +f5rXKl+PhhSOkljYXAzCi2Cjp/s/fU5cirtxf/If7ZqfR+VJwrJyzLvS/P4JDahI +HOiTulauN7QWmgHCifmHGX33Rwupo2ctUDXvMmrN53tLTKkQJVKODMmnlaQ8SDfu +CVTjyQgg0QKBgQDeND52s+YgYuNJeme5fMrof1+cFKc9TC/pfibHhy5RVmMCRRHq +1n8UATqZ6NBnkr8ujziBpcPw6fmv4E6wtQEXBZRhA6HSygzHhi5Ra7E3V1E3qDX1 +Ef7SO4av+G+NUa7yKOeleIMI/Hi4ClYzBzG78J0dJ4Ds3mJTdqB5Q3hpPwKBgGSR +8R+Vdl0/s/dNOYdSW1XFhnVMRITJFar4rams/Du2QrnODfasyhRo+ZRJK/k/n6j+ +1UcCU6Gar+INe+RA3rWLbRMZbf4MSnMy/M6o+43CbkJluCTIRJxNbNTntUq9YE7+ +kpndy+IwFOzAaETENoBxEARFrA8NMsVhdY0p2RXhAoGBAMKLDojNYSoF+Wp2nsSJ +Ahdwyrsk4eJSiFqr2g4wI1Ehhk/mOtYrDlmZZWQ3i0FC2xmAXDUKrBNgcNqoWvJg +tIXz8cRN0dmAMNeAjJ4dCBFnGUAV9D6KdUHPqhLhlMHpX3uc7h1/ya6/9gfzZNC5 +EQfxhCAaAvIvuZUSu4hJvM5U +-----END PRIVATE KEY-----`; + +const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUHdVZVRUzHman7tQL4dHoBEiRYkcwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQyNDAyMzEwNFoXDTM2MDQy +MTAyMzEwNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAxh+QJH7WveFOTl+y2SHqETfaTiol7qok5yKzof3Vv0WS +adRYNDPrfhr/1hbvyjjK1MKbD2fHnLTgsnMHb/jBP/uEZQIhpgCy4p5Q6gj79GfI +EYmaXxHjop865wUdun6YbXm3ZUReF8tKyEHJzLAulq9VePOAj+YjOhgHVT/oF6GC +DVdlbaKF0v1YqbZsSzKjKoYw4c+mVHxD+F5kFO93jPPzmkEkSKicdVMgchEY4M9J +v8khwErUvyCCso2VQfYtImbcKjvCZet4m688SnoVTQwdIglvQWsKUMeraqDv4Vyi +FDT0GgUPBny8DO5QrxlUjbjy7AeZn9VeuelKrzVRHwIDAQABo28wbTAdBgNVHQ4E +FgQUBqHCiP54ZRWvDOanxmdG9ZORLCYwHwYDVR0jBBgwFoAUBqHCiP54ZRWvDOan +xmdG9ZORLCYwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAIdjGd8iL83z+6tv0cglBBXwJb8LZQT0 +u++ArvAgjxEiCjojfKYyRkfKPPAxaidKZ2MLZMGnrxEg2vCBeTvmpR57HE8VL67N +Jijusc0dD3dRCqQHY7IgyxJELN4scSAzAes/LJiP3GgSvqt+RN/ltkJkXMCdeOoV +61S8jUD36NeqOPLkbCUwr2gqRfnzt27BH+9LCUufg2VlV1fBkIImqn37h80/HZtc +q7VFWuoYwwxog/mqnr9LtIzrfEzrB4V5lf4ZF9ZWtjSC0z++y2RFX2WFnFRLT1Xh +JpvCX2cYs9gOGe/Ite2Qt35tJNZYDm4UJlCb2EJHQJsDnB4rqvNFRb0= +-----END CERTIFICATE-----`; + +function listen(server: http.Server | https.Server): Promise { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve((server.address() as AddressInfo).port); + }); + }); +} + +function close(server: http.Server | https.Server): Promise { + return new Promise((resolve) => { + const fallback = setTimeout(resolve, 100); + server.close(() => { + clearTimeout(fallback); + resolve(); + }); + server.closeAllConnections?.(); + server.closeIdleConnections?.(); + }); +} + +describe('remote proxy chain regression', () => { + it('lets the local Codex reasoning proxy connect directly to self-signed HTTPS upstreams', async () => { + let forwardedPath = ''; + let forwardedReasoningEffort: unknown; + const upstream = https.createServer( + { key: SELF_SIGNED_KEY, cert: SELF_SIGNED_CERT }, + (req, res) => { + forwardedPath = req.url || ''; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + forwardedReasoningEffort = parsed.reasoning?.effort; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ content: [{ type: 'text', text: 'ok' }] })); + }); + } + ); + const upstreamPort = await listen(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `https://localhost:${upstreamPort}`, + allowSelfSigned: true, + stripPathPrefix: '/api/provider/codex', + defaultEffort: 'medium', + modelMap: { + defaultModel: 'gpt-5.4', + }, + }); + + try { + const proxyPort = await proxy.start(); + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.4', messages: [] }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ content: [{ type: 'text', text: 'ok' }] }); + expect(forwardedPath).toBe('/v1/messages'); + expect(forwardedReasoningEffort).toBe('xhigh'); + } finally { + proxy.stop(); + await close(upstream); + } + }); + + it('routes Codex through tool sanitization to a self-signed remote HTTPS proxy', async () => { + let forwardedPath = ''; + let forwardedReasoningEffort: unknown; + const upstream = https.createServer( + { key: SELF_SIGNED_KEY, cert: SELF_SIGNED_CERT }, + (req, res) => { + forwardedPath = req.url || ''; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + forwardedReasoningEffort = parsed.reasoning?.effort; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ content: [{ type: 'text', text: 'ok' }] })); + }); + } + ); + const upstreamPort = await listen(upstream); + const toolProxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `https://localhost:${upstreamPort}`, + allowSelfSigned: true, + warnOnSanitize: false, + }); + let codexProxy: CodexReasoningProxy | null = null; + + try { + const toolPort = await toolProxy.start(); + codexProxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${toolPort}`, + stripPathPrefix: '/api/provider/codex', + defaultEffort: 'medium', + modelMap: { + defaultModel: 'gpt-5.4', + }, + }); + const codexPort = await codexProxy.start(); + const response = await fetch(`http://127.0.0.1:${codexPort}/api/provider/codex/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.4', + messages: [], + tools: [{ name: 'read', input_schema: {}, cache_control: { type: 'ephemeral' } }], + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ content: [{ type: 'text', text: 'ok' }] }); + expect(forwardedPath).toBe('/v1/messages'); + expect(forwardedReasoningEffort).toBe('xhigh'); + } finally { + codexProxy?.stop(); + toolProxy.stop(); + await close(upstream); + } + }); +}); diff --git a/tests/unit/cliproxy/thinking-validator.test.ts b/tests/unit/cliproxy/thinking-validator.test.ts index d48679be..59dfccbd 100644 --- a/tests/unit/cliproxy/thinking-validator.test.ts +++ b/tests/unit/cliproxy/thinking-validator.test.ts @@ -29,6 +29,7 @@ describe('Thinking Validator', () => { expect(VALID_THINKING_LEVELS).toContain('medium'); expect(VALID_THINKING_LEVELS).toContain('high'); expect(VALID_THINKING_LEVELS).toContain('xhigh'); + expect(VALID_THINKING_LEVELS).toContain('max'); expect(VALID_THINKING_LEVELS).toContain('auto'); }); @@ -38,6 +39,24 @@ describe('Thinking Validator', () => { expect(THINKING_LEVEL_BUDGETS.medium).toBe(8192); expect(THINKING_LEVEL_BUDGETS.high).toBe(24576); expect(THINKING_LEVEL_BUDGETS.xhigh).toBe(32768); + // `max` sits above xhigh — unconstrained thinking on Opus 4.7 / Mythos + expect(THINKING_LEVEL_BUDGETS.max).toBeGreaterThan(THINKING_LEVEL_BUDGETS.xhigh); + }); + + it('should treat max as a distinct top tier on models that list it (Opus 4.7)', () => { + // Opus 4.7 exposes both xhigh and max; max must not collapse into xhigh. + const result = validateThinking('claude', 'claude-opus-4-7', 'max'); + expect(result.valid).toBe(true); + expect(result.value).toBe('max'); + expect(result.warning).toBeUndefined(); + }); + + it('should still alias max -> xhigh for models without a max level (backcompat)', () => { + // Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'. + // User input "max" should map down to xhigh rather than be rejected. + const result = validateThinking('codex', 'gpt-5.4', 'max'); + expect(result.valid).toBe(true); + expect(result.value).toBe('xhigh'); }); it('should export off values', () => { diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index abd2039c..96df7f0e 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -81,6 +81,12 @@ cliproxy: } }); + function setConfiguredBackend(backend: 'original' | 'plus'): void { + const configPath = path.join(tmpDir, 'config.yaml'); + const current = fs.readFileSync(configPath, 'utf-8'); + fs.writeFileSync(configPath, current.replace(/backend: (original|plus)/, `backend: ${backend}`)); + } + it('rejects provider change without model update', () => { const result = updateVariant('demo', { provider: 'codex' }); expect(result.success).toBe(false); @@ -96,13 +102,17 @@ cliproxy: expect(result.error).toContain('denylist'); }); - it('reports plus-only providers as temporarily unavailable on local CLIProxy', () => { + it('reports plus-only providers as requiring the optional plus backend', () => { + setConfiguredBackend('original'); + const error = validateProviderBackend('ghcp'); - expect(error).toContain('currently supports only `backend: original`'); - expect(error).toContain('issues/1062'); + expect(error).toContain('`backend: plus`'); + expect(error).toContain('kaitranntt/CLIProxyAPIPlus'); }); it('leaves the settings file unchanged when a plus-only provider update is rejected', () => { + setConfiguredBackend('original'); + const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json'); const before = fs.readFileSync(settingsPath, 'utf-8'); diff --git a/tests/unit/commands/browser-command.test.ts b/tests/unit/commands/browser-command.test.ts index c7c55412..df83e075 100644 --- a/tests/unit/commands/browser-command.test.ts +++ b/tests/unit/commands/browser-command.test.ts @@ -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', @@ -54,6 +74,7 @@ describe('browser command', () => { }, codex: { enabled: true, + policy: 'manual', state: 'enabled', title: 'Codex Browser Tools are enabled.', detail: 'CCS can inject the managed Playwright MCP overrides.', @@ -73,8 +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('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(); @@ -91,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', @@ -106,6 +135,7 @@ describe('browser command', () => { }, 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.', @@ -121,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 ); @@ -140,6 +171,7 @@ describe('browser command', () => { const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ claude: { enabled: false, + policy: 'auto', source: 'config', overrideActive: false, state: 'disabled', @@ -162,6 +194,7 @@ 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.', @@ -182,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); + }); }); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index 60d3a814..b683043e 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -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( diff --git a/tests/unit/commands/config-thinking-command.test.ts b/tests/unit/commands/config-thinking-command.test.ts index e98efedb..3c9f7f4e 100644 --- a/tests/unit/commands/config-thinking-command.test.ts +++ b/tests/unit/commands/config-thinking-command.test.ts @@ -35,6 +35,7 @@ describe('config thinking override normalization', () => { it('accepts valid levels', () => { expect(parseThinkingOverrideInput('High')).toEqual({ value: 'high' }); + expect(parseThinkingOverrideInput('Max')).toEqual({ value: 'max' }); }); it('validates numeric bounds', () => { diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 2a493d4c..100741df 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -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 () => { diff --git a/tests/unit/commands/proxy-command.test.ts b/tests/unit/commands/proxy-command.test.ts new file mode 100644 index 00000000..a3db75b1 --- /dev/null +++ b/tests/unit/commands/proxy-command.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts index 92a8ec3a..0beb80f1 100644 --- a/tests/unit/config/migration-manager.test.ts +++ b/tests/unit/config/migration-manager.test.ts @@ -177,6 +177,41 @@ 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, + eval_mode: 'readonly', + }, + codex: { + enabled: false, + policy: 'manual', + eval_mode: 'readonly', + }, + }); + }); + it('normalizes valid legacy shared groups and drops invalid ones during migration', async () => { fs.writeFileSync( path.join(ccsDir, 'profiles.json'), diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index 9da161fe..2fce792c 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -90,6 +90,16 @@ describe('translateAnthropicRequest', () => { ]); }); + it('maps adaptive anthropic thinking into Cursor reasoning effort', () => { + const translated = translateAnthropicRequest({ + thinking: { type: 'adaptive' }, + output_config: { effort: 'xhigh' }, + messages: [{ role: 'user', content: 'hello' }], + }); + + expect(translated.reasoning_effort).toBe('high'); + }); + it('preserves mixed user text around tool_result blocks in order', () => { const translated = translateAnthropicRequest({ messages: [ diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index ce6d6635..dddea9e8 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -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', () => { diff --git a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts new file mode 100644 index 00000000..bb6d42a4 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts @@ -0,0 +1,1288 @@ +import { describe, expect, it } from 'bun:test'; +import { bundledServerPath, runMcpRequests, getResponseText, getMockDropzoneState, mkdtempSync, readFileSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - advanced interactions', () => { + it('drags local files onto normal, frame, and shadow dropzones', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); + const invoicePath = join(tempDir, 'invoice.pdf'); + const receiptPath = join(tempDir, 'receipt.png'); + writeFileSync(invoicePath, 'invoice'); + writeFileSync(receiptPath, 'receipt'); + + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Uploads', + currentUrl: 'https://example.com/uploads', + dropzones: { + '#dropzone': { accepted: true }, + '#cancel-dropzone': { acceptedByCancel: true }, + }, + frames: [ + { + selector: '#upload-frame', + dropzones: { + '#frame-dropzone': { accepted: true }, + }, + }, + ], + shadowRoots: [ + { + hostSelector: 'upload-panel', + dropzones: { + '#shadow-dropzone': { accepted: true }, + }, + }, + ], + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 901, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 902, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#frame-dropzone', + files: [invoicePath], + frameSelector: '#upload-frame', + }, + }, + }, + { + jsonrpc: '2.0', + id: 903, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#shadow-dropzone', + files: [receiptPath], + pierceShadow: true, + }, + }, + }, + { + jsonrpc: '2.0', + id: 904, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#cancel-dropzone', + files: [invoicePath], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped'); + expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedEventTypes).toEqual([ + 'dragenter', + 'dragover', + 'drop', + ]); + expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedFiles).toEqual([ + { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, + { name: 'receipt.png', size: 7, type: 'image/png' }, + ]); + expect( + getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' })?.receivedFiles + ).toEqual([{ name: 'invoice.pdf', size: 7, type: 'application/pdf' }]); + expect( + getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles + ).toEqual([{ name: 'receipt.png', size: 7, type: 'image/png' }]); + expect(getResponseText(responses.find((message) => message.id === 904))).toContain( + 'status: files-dropped' + ); + expect(getMockDropzoneState(pages[0]!, '#cancel-dropzone')?.receivedFiles).toEqual([ + { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, + ]); + }); + + it('rejects browser_drag_files for missing files, page conflicts, missing targets, and rejected drops', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); + const okPath = join(tempDir, 'ok.txt'); + const missingPath = join(tempDir, 'missing.txt'); + writeFileSync(okPath, 'ok'); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Uploads', + currentUrl: 'https://example.com/uploads', + dropzones: { + '#reject-dropzone': { accepted: false }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 904, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#reject-dropzone', files: [missingPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 905, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { pageIndex: 0, pageId: 'page-1', selector: '#reject-dropzone', files: [okPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 906, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#missing-dropzone', files: [okPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 907, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#reject-dropzone', files: [okPath] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 904))).toContain('file does not exist'); + expect(getResponseText(responses.find((message) => message.id === 905))).toContain( + 'pageIndex and pageId cannot be used together' + ); + expect(getResponseText(responses.find((message) => message.id === 906))).toContain( + 'element not found for selector: #missing-dropzone' + ); + expect(getResponseText(responses.find((message) => message.id === 907))).toContain( + 'drop target rejected files' + ); + }); + + it('drags an element to another element and to explicit coordinates', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Drag Page', + currentUrl: 'https://example.com/drag', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 20, + y: 40, + width: 100, + height: 60, + top: 40, + right: 120, + bottom: 100, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#lane-b': { + exists: true, + connected: true, + rect: { + x: 240, + y: 60, + width: 120, + height: 80, + top: 60, + right: 360, + bottom: 140, + left: 240, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + { + id: 'page-2', + title: 'Second Drag Page', + currentUrl: 'https://example.com/drag-2', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 10, + y: 20, + width: 40, + height: 20, + top: 20, + right: 50, + bottom: 40, + left: 10, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#lane-b': { + exists: true, + connected: true, + rect: { + x: 110, + y: 80, + width: 60, + height: 30, + top: 80, + right: 170, + bottom: 110, + left: 110, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 908, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetSelector: '#lane-b', steps: 3 }, + }, + }, + { + jsonrpc: '2.0', + id: 909, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetX: 420, targetY: 180, steps: 2 }, + }, + }, + { + jsonrpc: '2.0', + id: 916, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { pageId: 'page-2', selector: '#card-a', targetSelector: '#lane-b', steps: 1 }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 908))).toContain('status: dragged'); + expect(getResponseText(responses.find((message) => message.id === 908))).toContain('targetSelector: #lane-b'); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain('status: dragged'); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetX: 420'); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetY: 180'); + expect(getResponseText(responses.find((message) => message.id === 916))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 916))).toContain('targetSelector: #lane-b'); + expect(pages[0]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, + { type: 'mousePressed', x: 70, y: 70, button: 'left' }, + { type: 'mouseMoved', x: 147, y: 80, button: 'left' }, + { type: 'mouseMoved', x: 223, y: 90, button: 'left' }, + { type: 'mouseMoved', x: 300, y: 100, button: 'left' }, + { type: 'mouseReleased', x: 300, y: 100, button: 'left' }, + { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, + { type: 'mousePressed', x: 70, y: 70, button: 'left' }, + { type: 'mouseMoved', x: 245, y: 125, button: 'left' }, + { type: 'mouseMoved', x: 420, y: 180, button: 'left' }, + { type: 'mouseReleased', x: 420, y: 180, button: 'left' }, + ]); + expect(pages[1]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 30, y: 30, button: 'none' }, + { type: 'mousePressed', x: 30, y: 30, button: 'left' }, + { type: 'mouseMoved', x: 140, y: 95, button: 'left' }, + { type: 'mouseReleased', x: 140, y: 95, button: 'left' }, + ]); + }); + + it('rejects invalid browser_drag_element target combinations, page conflicts, and missing elements', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Drag Failures', + currentUrl: 'https://example.com/drag', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 20, + y: 40, + width: 100, + height: 60, + top: 40, + right: 120, + bottom: 100, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 910, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetSelector: '#lane-b', targetX: 400, targetY: 200 }, + }, + }, + { + jsonrpc: '2.0', + id: 911, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#missing-source', targetX: 400, targetY: 200 }, + }, + }, + { + jsonrpc: '2.0', + id: 912, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetSelector: '#missing-target' }, + }, + }, + { + jsonrpc: '2.0', + id: 917, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { pageIndex: 0, pageId: 'page-1', selector: '#card-a', targetX: 400, targetY: 200 }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 910))).toContain( + 'targetSelector and targetX/targetY cannot be used together' + ); + expect(getResponseText(responses.find((message) => message.id === 911))).toContain( + 'source element not found' + ); + expect(getResponseText(responses.find((message) => message.id === 912))).toContain( + 'target element not found' + ); + expect(getResponseText(responses.find((message) => message.id === 917))).toContain( + 'pageIndex and pageId cannot be used together' + ); + }); + + it('runs minimal pointer sequences with browser_pointer_action', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Pointer Page', + currentUrl: 'https://example.com/pointer', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 30, + y: 50, + width: 40, + height: 20, + top: 50, + right: 70, + bottom: 70, + left: 30, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + { + id: 'page-2', + title: 'Second Pointer Page', + currentUrl: 'https://example.com/pointer-2', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 100, + y: 200, + width: 20, + height: 20, + top: 200, + right: 120, + bottom: 220, + left: 100, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 913, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'down' }, + { type: 'move', x: 220, y: 160 }, + { type: 'pause', durationMs: 5 }, + { type: 'up' }, + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 918, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + pageId: 'page-2', + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'down' }, + { type: 'up' }, + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 913))).toContain('status: pointer-actions-completed'); + expect(getResponseText(responses.find((message) => message.id === 918))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 918))).toContain('status: pointer-actions-completed'); + expect(pages[0]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 50, y: 60, button: 'none' }, + { type: 'mousePressed', x: 50, y: 60, button: 'left' }, + { type: 'mouseMoved', x: 220, y: 160, button: 'left' }, + { type: 'mouseReleased', x: 220, y: 160, button: 'left' }, + ]); + expect(pages[1]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 110, y: 210, button: 'none' }, + { type: 'mousePressed', x: 110, y: 210, button: 'left' }, + { type: 'mouseReleased', x: 110, y: 210, button: 'left' }, + ]); + }); + + it('rejects invalid browser_pointer_action sequences and page conflicts', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Pointer Failures', + currentUrl: 'https://example.com/pointer', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 30, + y: 50, + width: 40, + height: 20, + top: 50, + right: 70, + bottom: 70, + left: 30, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 914, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [{ type: 'up' }], + }, + }, + }, + { + jsonrpc: '2.0', + id: 915, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [{ type: 'move', selector: '#missing-handle' }], + }, + }, + }, + { + jsonrpc: '2.0', + id: 919, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + pageIndex: 0, + pageId: 'page-1', + actions: [{ type: 'move', x: 10, y: 10 }], + }, + }, + }, + { + jsonrpc: '2.0', + id: 920, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'down', button: 'left' }, + { type: 'up', button: 'right' }, + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error'); + expect(getResponseText(responses.find((message) => message.id === 915))).toContain('drag coordinates unavailable'); + expect(getResponseText(responses.find((message) => message.id === 919))).toContain( + 'pageIndex and pageId cannot be used together' + ); + expect(getResponseText(responses.find((message) => message.id === 920))).toContain( + 'pointer state error' + ); + }); + + it('rejects mutating browser tools when the selected page becomes stale', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-stale-selection-')); + const patchedServerPath = join(tempDir, 'ccs-browser-server.cjs'); + writeFileSync( + patchedServerPath, + readFileSync(bundledServerPath, 'utf8').replace( + "let selectedPageId = '';", + "let selectedPageId = 'page-2';" + ) + ); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Primary Page', + currentUrl: 'https://example.com/primary', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 30, + y: 50, + width: 40, + height: 20, + top: 50, + right: 70, + bottom: 70, + left: 30, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 923, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [{ type: 'move', selector: '#handle' }], + }, + }, + }, + ], + { serverPath: patchedServerPath } + ); + + expect(getResponseText(responses.find((message) => message.id === 923))).toContain( + 'Selected page is no longer available; specify pageIndex or pageId explicitly.' + ); + }); + + it('waits for a matching navigation event with browser_wait_for_event', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Event Page', + currentUrl: 'https://example.com/', + events: { + navigations: [{ url: 'https://example.com/checkout' }], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 69, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + timeoutMs: 1000, + event: { kind: 'navigation', urlIncludes: '/checkout' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 69))).toContain( + 'status: observed' + ); + }); + + it('ignores child-frame navigations when waiting for a page navigation event', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Event Page', + currentUrl: 'https://example.com/', + events: { + navigations: [ + { url: 'https://example.com/embedded-checkout', parentId: 'frame-1' }, + { url: 'https://example.com/checkout' }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 58, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + timeoutMs: 1000, + event: { kind: 'navigation', urlIncludes: '/checkout' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 58)); + expect(text).toContain('status: observed'); + expect(text).toContain('"url":"https://example.com/checkout"'); + expect(text).not.toContain('embedded-checkout'); + }); + + it('filters download events by the selected page frame when pageIndex is provided', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'First Page', + currentUrl: 'https://example.com/one', + events: { + downloads: [ + { + url: 'https://example.com/first-report.csv', + suggestedFilename: 'first-report.csv', + frameId: 'frame-page-1', + }, + ], + }, + }, + { + id: 'page-2', + title: 'Second Page', + currentUrl: 'https://example.com/two', + events: { + downloads: [ + { + url: 'https://example.com/second-report.csv', + suggestedFilename: 'second-report.csv', + frameId: 'frame-page-2', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 59, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + pageIndex: 1, + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'second-report' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 59)); + expect(text).toContain('status: observed'); + expect(text).toContain('"suggestedFilename":"second-report.csv"'); + expect(text).not.toContain('first-report.csv'); + }); + + it('matches download events from child frames within the selected page', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Framed Page', + currentUrl: 'https://example.com/frame-host', + frameTree: { + frame: { id: 'frame-page-1' }, + childFrames: [{ frame: { id: 'frame-page-1-child' } }], + }, + events: { + downloads: [ + { + url: 'https://example.com/embedded-report.csv', + suggestedFilename: 'embedded-report.csv', + frameId: 'frame-page-1-child', + }, + ], + }, + }, + { + id: 'page-2', + title: 'Other Page', + currentUrl: 'https://example.com/other', + events: { + downloads: [ + { + url: 'https://example.com/other-report.csv', + suggestedFilename: 'other-report.csv', + frameId: 'frame-page-2', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 60, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + pageIndex: 0, + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'embedded-report' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 60)); + expect(text).toContain('status: observed'); + expect(text).toContain('"suggestedFilename":"embedded-report.csv"'); + expect(text).not.toContain('other-report.csv'); + }); + + it('refreshes frame ids when a download comes from a newly attached child frame', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Dynamic Frame Page', + currentUrl: 'https://example.com/dynamic', + frameTreeSequence: [ + { frame: { id: 'frame-page-1' } }, + { + frame: { id: 'frame-page-1' }, + childFrames: [{ frame: { id: 'frame-page-1-late-child' } }], + }, + ], + events: { + downloads: [ + { + url: 'https://example.com/late-report.csv', + suggestedFilename: 'late-report.csv', + frameId: 'frame-page-1-late-child', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 61, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + pageIndex: 0, + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'late-report' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 61)); + expect(text).toContain('status: observed'); + expect(text).toContain('"suggestedFilename":"late-report.csv"'); + }); + + it('captures element screenshots using the post-scroll visible clip and reports failures', async () => { + const screenshotPlan: MockPageState['screenshot'] = { + data: 'ZWxlbWVudC1zaG90', + requireScrolledMeasurement: true, + expectedClip: { + x: 0, + y: 12, + width: 50, + height: 28, + scale: 1, + }, + }; + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Element Screenshot Page', + currentUrl: 'https://example.com/', + query: { + '.edit-button': { + exists: true, + connected: true, + innerText: 'Edit', + textContent: 'Edit', + rect: { + x: -14, + y: 12, + width: 64, + height: 28, + top: 12, + right: 50, + bottom: 40, + left: -14, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.zero-sized': { + exists: true, + connected: true, + rect: { + x: 10, + y: 10, + width: 0, + height: 0, + top: 10, + right: 10, + bottom: 10, + left: 10, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + screenshot: screenshotPlan, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.edit-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.missing-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.zero-sized' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('selector: .edit-button'); + expect(text).toContain('format: png'); + expect(text).toContain('data: ZWxlbWVudC1zaG90'); + + expect(getResponseText(responses.find((message) => message.id === 3))).toContain( + 'Browser MCP failed: element not found for selector: .missing-button' + ); + expect(getResponseText(responses.find((message) => message.id === 4))).toContain( + 'Browser MCP failed: element has empty bounds for selector: .zero-sized' + ); + + const emptyPayloadResponses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Element Screenshot Empty Payload Page', + currentUrl: 'https://example.com/', + query: { + '.edit-button': { + exists: true, + connected: true, + rect: { + x: 220, + y: 80, + width: 64, + height: 28, + top: 80, + right: 284, + bottom: 108, + left: 220, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + screenshot: { data: '' }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.edit-button' }, + }, + }, + ] + ); + + expect(getResponseText(emptyPayloadResponses.find((message) => message.id === 2))).toContain( + 'Browser MCP failed: screenshot capture failed' + ); + + const iframeScreenshotResponses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Iframe Screenshot Page', + currentUrl: 'https://example.com/', + frames: [ + { + selector: '#details-frame', + query: { + '#details-frame': { + exists: true, + connected: true, + rect: { + x: 40, + y: 100, + width: 300, + height: 200, + top: 100, + right: 340, + bottom: 300, + left: 40, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.save-button': { + exists: true, + connected: true, + rect: { + x: 10, + y: 20, + width: 60, + height: 24, + top: 20, + right: 70, + bottom: 44, + left: 10, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + screenshot: { + data: 'aWZyYW1lLXNob3Q=', + requireScrolledMeasurement: true, + expectedClip: { + x: 50, + y: 120, + width: 60, + height: 24, + scale: 1, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.save-button', frameSelector: '#details-frame' }, + }, + }, + ] + ); + + expect( + getResponseText(iframeScreenshotResponses.find((message) => message.id === 5)) + ).toContain('data: aWZyYW1lLXNob3Q='); + }); + + it('captures screenshots and reports empty payload failures', async () => { + const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + screenshot: screenshotPlan, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: { fullPage: true }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('pageIndex: 0'); + expect(text).toContain('format: png'); + expect(text).toContain('fullPage: true'); + expect(text).toContain('data: c2NyZWVuc2hvdA=='); + expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true); + + const errorResponses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + screenshot: { data: '' }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: {}, + }, + }, + ] + ); + + const errorResponse = errorResponses.find((message) => message.id === 2); + expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(errorResponse)).toContain( + 'Browser MCP failed: screenshot capture failed' + ); + }); + + it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + type: { + 'input[name="email"]': { + kind: 'input', + inputType: 'email', + value: 'old@example.com', + expectedValueWhenAppend: 'old@example.comhi@example.com', + requireFocus: true, + }, + '#notes': { + kind: 'textarea', + value: 'old note', + expectedValueWhenClearFirst: '', + requireFocus: true, + }, + '#editor': { + kind: 'contenteditable', + value: 'old content', + expectedValueWhenAppend: 'old contentrich text', + requireFocus: true, + }, + '#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' }, + '#plain': { kind: 'noneditable', value: 'plain' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: 'input[name="email"]', text: 'hi@example.com' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#notes', text: '', clearFirst: true }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#editor', text: 'rich text' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#color', text: 'ignored' }, + }, + }, + { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#plain', text: 'ignored' }, + }, + }, + ] + ); + + const typed = getResponseText(responses.find((message) => message.id === 2)); + expect(typed).toContain('status: typed'); + expect(typed).toContain('selector: input[name="email"]'); + expect(typed).toContain('typedLength: 29'); + + const clearFirst = getResponseText(responses.find((message) => message.id === 3)); + expect(clearFirst).toContain('status: typed'); + expect(clearFirst).toContain('selector: #notes'); + expect(clearFirst).toContain('typedLength: 0'); + + const contenteditable = getResponseText(responses.find((message) => message.id === 4)); + expect(contenteditable).toContain('status: typed'); + expect(contenteditable).toContain('selector: #editor'); + expect(contenteditable).toContain('typedLength: 20'); + + const unsupported = responses.find((message) => message.id === 5); + expect((unsupported?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(unsupported)).toContain( + 'element is not text-editable for selector: #color' + ); + + const nonEditable = responses.find((message) => message.id === 6); + expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(nonEditable)).toContain( + 'element is not text-editable for selector: #plain' + ); + }); +}); diff --git a/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts b/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts new file mode 100644 index 00000000..dae29085 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - downloads and file inputs', () => { + it('applies browser-scoped download behavior, records download summaries, and cancels an in-progress download', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Reports', + currentUrl: 'https://example.com/reports', + browser: {}, + events: { + downloads: [ + { + guid: 'download-guid-1', + url: 'https://example.com/files/report.csv', + suggestedFilename: 'report.csv', + progress: [{ receivedBytes: 5, totalBytes: 10, state: 'inProgress' }], + }, + ], + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 57, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { behavior: 'accept', eventsEnabled: true }, + }, + }, + { + jsonrpc: '2.0', + id: 58, + method: 'tools/call', + params: { name: 'browser_list_downloads', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 59, + method: 'tools/call', + params: { + name: 'browser_cancel_download', + arguments: { guid: 'download-guid-1' }, + }, + }, + { + jsonrpc: '2.0', + id: 60, + method: 'tools/call', + params: { name: 'browser_list_downloads', arguments: {} }, + }, + ], + { responseTimeoutMs: 12000 } + ); + + expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser'); + expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv'); + expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled'); + expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow'); + expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1'); + }); + + it('rejects browser_set_download_behavior when behavior is deny and downloadPath is provided', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports' }], + [ + { + jsonrpc: '2.0', + id: 61, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { + behavior: 'deny', + downloadPath: '/tmp/blocked-downloads', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 61); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: downloadPath is only allowed when behavior=accept' + ); + }); + + it('rejects browser_cancel_download for completed downloads', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Reports', + currentUrl: 'https://example.com/reports', + browser: {}, + events: { + downloads: [ + { + guid: 'download-guid-complete', + url: 'https://example.com/files/report.csv', + suggestedFilename: 'report.csv', + progress: [{ receivedBytes: 10, totalBytes: 10, state: 'completed' }], + }, + ], + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 610, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { behavior: 'accept', eventsEnabled: true }, + }, + }, + { + jsonrpc: '2.0', + id: 611, + method: 'tools/call', + params: { name: 'browser_list_downloads', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 612, + method: 'tools/call', + params: { + name: 'browser_cancel_download', + arguments: { guid: 'download-guid-complete' }, + }, + }, + ], + { responseTimeoutMs: 12000 } + ); + + expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed'); + const response = responses.find((message) => message.id === 612); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: download is not cancelable in status: completed' + ); + expect(pages[0]?.browser?.canceledDownloadGuids).toBeUndefined(); + }); + + it('waits for a matching download event with browser_wait_for_event after Phase 8 changes', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Event Page', + currentUrl: 'https://example.com/', + events: { + downloads: [ + { + guid: 'download-guid-2', + url: 'https://example.com/files/export.zip', + suggestedFilename: 'export.zip', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 62, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'export.zip' }, + }, + }, + }, + ], + { responseTimeoutMs: 12000 } + ); + + expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed'); + }); + + it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); + const invoicePath = join(tempDir, 'invoice.pdf'); + const receiptPath = join(tempDir, 'receipt.png'); + writeFileSync(invoicePath, 'invoice'); + writeFileSync(receiptPath, 'receipt'); + + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Root Uploads', + currentUrl: 'https://example.com/root', + fileInputs: { + '#root-upload': { kind: 'file', multiple: true }, + }, + frames: [ + { + selector: '#upload-frame', + fileInputs: { + '#frame-upload': { kind: 'file', multiple: true }, + }, + }, + ], + shadowRoots: [ + { + hostSelector: 'upload-panel', + fileInputs: { + '#shadow-upload': { kind: 'file' }, + }, + }, + ], + }, + { + id: 'page-2', + title: 'Selected Uploads', + currentUrl: 'https://example.com/selected', + fileInputs: { + '#selected-upload': { kind: 'file', multiple: true }, + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 63, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 64, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#selected-upload', files: [invoicePath, receiptPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 65, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { + pageIndex: 0, + selector: '#frame-upload', + files: [invoicePath], + frameSelector: '#upload-frame', + }, + }, + }, + { + jsonrpc: '2.0', + id: 66, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { + pageIndex: 0, + selector: '#shadow-upload', + files: [receiptPath], + pierceShadow: true, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame'); + expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true'); + + expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([ + invoicePath, + receiptPath, + ]); + expect( + (pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles + ).toEqual([invoicePath]); + expect( + (pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles + ).toEqual([receiptPath]); + }); + + it('uses pageId for browser_set_file_input when provided', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); + const assetPath = join(tempDir, 'asset.txt'); + writeFileSync(assetPath, 'asset'); + + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Selected Uploads', + currentUrl: 'https://example.com/selected', + fileInputs: { + '#selected-upload': { kind: 'file' }, + }, + }, + { + id: 'page-2', + title: 'Explicit Uploads', + currentUrl: 'https://example.com/explicit', + fileInputs: { + '#pageid-upload': { kind: 'file' }, + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 67, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageId: 'page-1' } }, + }, + { + jsonrpc: '2.0', + id: 68, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1'); + expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([ + assetPath, + ]); + expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined(); + }); + + it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); + const okPath = join(tempDir, 'ok.txt'); + const missingPath = join(tempDir, 'missing.txt'); + writeFileSync(okPath, 'ok'); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Upload Errors', + currentUrl: 'https://example.com/', + fileInputs: { + '#real-file-input': { kind: 'file' }, + '#not-file-input': { kind: 'nonfile' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 69, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#not-file-input', files: [okPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 70, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#real-file-input', files: [missingPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 71, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 69))).toContain( + 'element is not a file input for selector: #not-file-input' + ); + expect(getResponseText(responses.find((message) => message.id === 70))).toContain( + `file does not exist: ${missingPath}` + ); + expect(getResponseText(responses.find((message) => message.id === 71))).toContain( + 'pageIndex and pageId cannot be used together' + ); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts b/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts new file mode 100644 index 00000000..bbd09250 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts @@ -0,0 +1,1696 @@ +import { describe, expect, it } from 'bun:test'; +import { bundledServerPath, cpSync, getMockFrame, getMockShadowRoot, getResponseText, join, mkdtempSync, resolveNodeModulesPath, rmSync, runMcpRequests, tmpdir, writeFileSync } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - navigation and query', () => { + it('works from an installed copy when global WebSocket is unavailable and NODE_PATH supplies package dependencies', async () => { + const installDir = mkdtempSync(join(tmpdir(), 'ccs-browser-installed-copy-')); + const installedServerPath = join(installDir, 'ccs-browser-server.cjs'); + const bootstrapServerPath = join(installDir, 'bootstrap.cjs'); + + try { + cpSync(bundledServerPath, installedServerPath); + writeFileSync( + bootstrapServerPath, + 'delete globalThis.WebSocket;\nrequire("./ccs-browser-server.cjs");\n', + 'utf8' + ); + + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Installed Copy', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_get_url_and_title', arguments: {} }, + }, + ], + { + serverPath: bootstrapServerPath, + childEnv: { + NODE_PATH: resolveNodeModulesPath(), + }, + responseTimeoutMs: 12000, + } + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(response)).toContain('title: Installed Copy'); + expect(getResponseText(response)).toContain('url: https://example.com/'); + } finally { + rmSync(installDir, { recursive: true, force: true }); + } + }, 10000); + + it('navigates successfully after readiness polling', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + navigate: { + 'https://example.com/next': { + finalUrl: 'https://example.com/next', + readyStates: ['loading', 'interactive'], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/next' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('pageIndex: 0'); + expect(text).toContain('url: https://example.com/next'); + expect(text).toContain('status: navigated'); + }); + + it('returns a handled error when navigation readiness times out', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + navigate: { + 'https://example.com/slow': { + finalUrl: 'https://example.com/', + readyStates: new Array(60).fill('loading'), + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/slow' }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: navigation did not complete'); + }, 8000); + + it('returns handled errors for missing URL, malformed URL, invalid page index, and out-of-range page index', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { url: 'file:///tmp/example' } }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { pageIndex: 1.5, url: 'https://example.com/next' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { pageIndex: 9, url: 'https://example.com/next' }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'Browser MCP failed: url is required' + ); + expect(getResponseText(responses.find((message) => message.id === 3))).toContain( + 'Browser MCP failed: url must be an absolute http or https URL' + ); + expect(getResponseText(responses.find((message) => message.id === 4))).toContain( + 'Browser MCP failed: pageIndex must be a non-negative integer' + ); + expect(getResponseText(responses.find((message) => message.id === 5))).toContain( + 'page index 9 is out of range' + ); + }); + + it('surfaces Page.navigate CDP failures as handled errors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/start', + navigate: { + 'https://example.com/fail': { + finalUrl: 'https://example.com/start', + errorText: 'net::ERR_ABORTED', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/fail' }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: navigation failed for URL: https://example.com/fail: net::ERR_ABORTED' + ); + }); + + it('treats redirects as successful navigation', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/start', + navigate: { + 'https://example.com/redirect': { + finalUrl: 'https://example.com/final', + readyStates: ['loading', 'interactive'], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/redirect' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('status: navigated'); + expect(text).toContain('url: https://example.com/final'); + }); + + it('clicks matching elements and reports selector failures', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#submit': {}, + '#disabled': { disabled: true }, + '#throws': { error: 'click exploded' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#submit' } }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#missing' } }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#disabled' } }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#throws' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'status: clicked' + ); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'selector: #submit' + ); + + const selectorMiss = responses.find((message) => message.id === 3); + expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(selectorMiss)).toContain( + 'element index 0 is out of range for selector: #missing' + ); + + const disabledError = responses.find((message) => message.id === 4); + expect((disabledError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(disabledError)).toContain('element is disabled for selector: #disabled'); + + const pageSideError = responses.find((message) => message.id === 5); + expect((pageSideError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(pageSideError)).toContain('click exploded'); + }); + + it('clicks the requested zero-based match and rejects out-of-range nth', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Click Page', + currentUrl: 'https://example.com/', + click: { + '.menu-item': [{ label: 'first' }, { label: 'second' }], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 20, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '.menu-item', nth: 1 } }, + }, + { + jsonrpc: '2.0', + id: 21, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '.menu-item', nth: 3 } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 20))).toContain( + 'status: clicked' + ); + expect(getResponseText(responses.find((message) => message.id === 20))).toContain('nth: 1'); + expect(getResponseText(responses.find((message) => message.id === 21))).toContain( + 'Browser MCP failed: element index 3 is out of range for selector: .menu-item' + ); + }); + + it('reports detached and hidden click targets as handled errors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#hidden': { hidden: true }, + '#detached': { detached: true }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#hidden' } }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#detached' } }, + }, + ] + ); + + const hiddenError = responses.find((message) => message.id === 2); + expect((hiddenError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(hiddenError)).toContain( + 'element is hidden or not interactable for selector: #hidden' + ); + + const detachedError = responses.find((message) => message.id === 3); + expect((detachedError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(detachedError)).toContain('element is detached for selector: #detached'); + }); + + it('uses a mouse sequence when the target requires it', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#menu-trigger': { requireMouseSequence: true }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#menu-trigger' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'status: clicked' + ); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'selector: #menu-trigger' + ); + }); + + it('preserves mouse sequence preparation without dispatching a synthetic click event', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#click-event': { + requireMouseSequence: true, + forbidSyntheticClickEvent: true, + requireNativeClick: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#click-event' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'status: clicked' + ); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'selector: #click-event' + ); + }); + + it('preserves native click activation after the mouse sequence', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#native-click': { + requireMouseSequence: true, + requireNativeClick: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#native-click' } }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 2); + expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(clickResponse)).toContain('status: clicked'); + expect(getResponseText(clickResponse)).toContain('selector: #native-click'); + }); + + it('does not force activation when mousedown cancels the interaction', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#cancel-mousedown': { + requireMouseSequence: true, + cancelMouseDown: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#cancel-mousedown' } }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 2); + expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(clickResponse)).toContain('status: clicked'); + expect(getResponseText(clickResponse)).toContain('selector: #cancel-mousedown'); + }); + + it('rechecks connectivity before native activation after the mouse sequence', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#detached-during-click': { + requireMouseSequence: true, + requireNativeClick: true, + detachAfterMouseDown: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#detached-during-click' } }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 2); + expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(clickResponse)).toContain('status: clicked'); + expect(getResponseText(clickResponse)).toContain('selector: #detached-during-click'); + }); + + it('falls back to click when mouse sequence dispatch fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#fallback': { mouseSequenceError: 'mouse dispatch exploded' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#fallback' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'status: clicked' + ); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'selector: #fallback' + ); + }); + + it('clicks with element-relative offsets and richer click options', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Click Page', + currentUrl: 'https://example.com/', + click: { + '#offset-target': { + requireMouseSequence: true, + expectedOffset: { x: 12, y: 8 }, + expectedButton: 'right', + expectedClickCount: 2, + }, + '#double-left': { + requireMouseSequence: true, + expectedButton: 'left', + expectedClickCount: 2, + requireDoubleClickEvent: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 70, + method: 'tools/call', + params: { + name: 'browser_click', + arguments: { + selector: '#offset-target', + offsetX: 12, + offsetY: 8, + button: 'right', + clickCount: 2, + }, + }, + }, + { + jsonrpc: '2.0', + id: 700, + method: 'tools/call', + params: { + name: 'browser_click', + arguments: { + selector: '#double-left', + clickCount: 2, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 70)); + expect(text).toContain('status: clicked'); + expect(text).toContain('offsetX: 12'); + expect(text).toContain('offsetY: 8'); + expect(text).toContain('button: right'); + expect(text).toContain('clickCount: 2'); + + const doubleText = getResponseText(responses.find((message) => message.id === 700)); + expect(doubleText).toContain('status: clicked'); + expect(doubleText).toContain('button: left'); + expect(doubleText).toContain('clickCount: 2'); + }); + + it('presses a key combination with browser_press_key', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Keyboard Page', + currentUrl: 'https://example.com/', + keyboard: { + expectedKey: 'k', + expectedModifiers: ['Meta'], + expectedRepeat: 2, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 71, + method: 'tools/call', + params: { + name: 'browser_press_key', + arguments: { + key: 'k', + modifiers: ['Meta'], + repeat: 2, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 71)); + expect(text).toContain('status: key-pressed'); + expect(text).toContain('key: k'); + expect(text).toContain('modifiers: Meta'); + expect(text).toContain('repeat: 2'); + }); + + it('supports common special keys with browser_press_key', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Special Key Page', + currentUrl: 'https://example.com/', + keyboard: { + expectedKey: 'Enter', + expectedRepeat: 1, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 711, + method: 'tools/call', + params: { + name: 'browser_press_key', + arguments: { + key: 'Enter', + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 711)); + expect(text).toContain('status: key-pressed'); + expect(text).toContain('key: Enter'); + }); + + it('rejects unsupported modifiers for browser_press_key', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Invalid Modifier Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 712, + method: 'tools/call', + params: { + name: 'browser_press_key', + arguments: { + key: 'k', + modifiers: ['Cmd'], + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 712); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'modifiers must only contain: Alt, Control, Meta, Shift' + ); + }); + + it('scrolls an element into view with browser_scroll', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Scroll Page', + currentUrl: 'https://example.com/', + scroll: { + '#results': { + expectedBehavior: 'into-view', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 72, + method: 'tools/call', + params: { + name: 'browser_scroll', + arguments: { + selector: '#results', + behavior: 'into-view', + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 72)); + expect(text).toContain('status: scrolled'); + expect(text).toContain('selector: #results'); + expect(text).toContain('behavior: into-view'); + }); + + it('scrolls an iframe page by offset with browser_scroll', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Iframe Scroll Page', + currentUrl: 'https://example.com/', + frames: [ + { + selector: '#preview-frame', + query: { + '#preview-frame': { + exists: true, + }, + }, + }, + ], + }, + ], + [ + { + jsonrpc: '2.0', + id: 73, + method: 'tools/call', + params: { + name: 'browser_scroll', + arguments: { + frameSelector: '#preview-frame', + behavior: 'by-offset', + deltaX: 5, + deltaY: 40, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 73)); + expect(text).toContain('status: scrolled'); + expect(text).toContain('behavior: by-offset'); + expect(text).toContain('deltaX: 5'); + expect(text).toContain('deltaY: 40'); + }); + + it('requires real mouse movement for hover-only targets and reports hover failures', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Hover Page', + currentUrl: 'https://example.com/', + hover: { + '.draft-card': { requireCdpMouseMove: true }, + '.missing-bounds': { zeroSized: true }, + }, + query: { + '.draft-card': { + exists: true, + connected: true, + rect: { + x: 100, + y: 40, + width: 120, + height: 48, + top: 40, + right: 220, + bottom: 88, + left: 100, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.missing-bounds': { + exists: true, + connected: true, + rect: { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_hover', + arguments: { selector: '.draft-card' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_hover', + arguments: { selector: '.missing-bounds' }, + }, + }, + ] + ); + + const hoverResponse = responses.find((message) => message.id === 2); + expect((hoverResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(hoverResponse)).toContain('status: hovered'); + expect(getResponseText(hoverResponse)).toContain('selector: .draft-card'); + + const hoverError = responses.find((message) => message.id === 3); + expect((hoverError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(hoverError)).toContain( + 'element is hidden or not interactable for selector: .missing-bounds' + ); + }); + + it('queries element diagnostics and validates query fields', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.edit-button': { + exists: true, + connected: true, + innerText: 'Edit', + textContent: 'Edit', + rect: { + x: 123, + y: 45, + width: 48, + height: 24, + top: 45, + right: 171, + bottom: 69, + left: 123, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.missing-button': { + exists: false, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.edit-button', + fields: ['display', 'visibility', 'opacity'], + }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.missing-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button', fields: 'display' }, + }, + }, + { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button', fields: ['displayMode'] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'exists: true' + ); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'boundingClientRect: {"x":123' + ); + expect(getResponseText(responses.find((message) => message.id === 3))).toContain( + 'display: block' + ); + expect(getResponseText(responses.find((message) => message.id === 3))).not.toContain( + 'innerText:' + ); + + const missingResponse = responses.find((message) => message.id === 4); + expect((missingResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(missingResponse)).toContain('exists: false'); + + expect(getResponseText(responses.find((message) => message.id === 5))).toContain( + 'Browser MCP failed: fields must be an array of strings' + ); + expect(getResponseText(responses.find((message) => message.id === 6))).toContain( + 'Browser MCP failed: unknown query field: displayMode' + ); + }); + + it('reports count, nth-aware fields, href, and onclick for multi-match selectors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.hover-action': [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + href: 'https://example.com/open', + onclick: 'openCard()', + }, + { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '0.95', + href: 'https://example.com/archive', + onclick: 'archiveCard()', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 30, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 1, + fields: ['count', 'exists', 'innerText', 'href', 'onclick'], + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 30)); + expect(text).toContain('count: 2'); + expect(text).toContain('exists: true'); + expect(text).toContain('innerText: Archive'); + expect(text).toContain('href: https://example.com/archive'); + expect(text).toContain('onclick: archiveCard()'); + expect(text).toContain('nth: 1'); + }); + + it('keeps count and exists available when nth is out of range, but rejects target fields', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.hover-action': [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 31, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 3, + fields: ['count', 'exists'], + }, + }, + }, + { + jsonrpc: '2.0', + id: 32, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 3, + fields: ['count', 'innerText'], + }, + }, + }, + ] + ); + + const countOnly = responses.find((message) => message.id === 31); + expect((countOnly?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(countOnly)).toContain('count: 1'); + expect(getResponseText(countOnly)).toContain('exists: false'); + + expect(getResponseText(responses.find((message) => message.id === 32))).toContain( + 'Browser MCP failed: element index 3 is out of range for selector: .hover-action' + ); + }); + + it('waits for selector visibility with opacity threshold', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Page', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + { exists: false }, + { + exists: true, + connected: true, + display: 'block', + visibility: 'visible', + opacity: '0.95', + rect: { + x: 10, + y: 10, + width: 40, + height: 20, + top: 10, + right: 50, + bottom: 30, + left: 10, + }, + }, + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 40, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 40))).toContain( + 'status: satisfied' + ); + }); + + it('waits for page text includes and returns timeout summaries', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Timeout', + currentUrl: 'https://example.com/', + wait: { + pageTextSequence: ['loading', 'loading', 'loaded archive menu'], + selectorSnapshots: { + '.hover-action': [ + { + exists: true, + connected: true, + display: 'block', + visibility: 'visible', + opacity: '0.2', + rect: { + x: 10, + y: 10, + width: 40, + height: 20, + top: 10, + right: 50, + bottom: 30, + left: 10, + }, + }, + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 41, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'text', includes: 'archive menu' }, + }, + }, + }, + { + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 30, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 41))).toContain( + 'status: satisfied' + ); + expect(getResponseText(responses.find((message) => message.id === 42))).toContain( + 'Browser MCP failed: wait condition timed out' + ); + expect(getResponseText(responses.find((message) => message.id === 42))).toContain( + 'opacity=0.2' + ); + }); + + it('waits for selector text includes', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Text', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + [ + { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 45, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'text', includes: 'Archive' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 45))).toContain( + 'status: satisfied' + ); + }); + + it('lets nth become valid later during polling and rejects unsupported page-level wait kinds', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Nth', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 10, + y: 10, + width: 40, + height: 20, + top: 10, + right: 50, + bottom: 30, + left: 10, + }, + }, + ], + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 43, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + nth: 1, + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible' }, + }, + }, + }, + { + jsonrpc: '2.0', + id: 44, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + timeoutMs: 100, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 43))).toContain( + 'status: satisfied' + ); + expect(getResponseText(responses.find((message) => message.id === 44))).toContain( + 'Browser MCP failed: page-level wait only supports text conditions in Phase 1' + ); + }); + + it('defaults browser_eval to readonly and enforces readwrite gating', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Eval Page', + currentUrl: 'https://example.com/', + eval: { + 'JSON.stringify({ hovered: true, label: "Archive" })': { + result: { hovered: true, label: 'Archive' }, + }, + 'document.body.dataset.test = "1"': { + error: 'EvalError: Possible side-effect in debug-evaluate', + }, + 'throw new Error("boom")': { + error: 'boom', + }, + Infinity: { + unserializableValue: 'Infinity', + }, + window: { + nonSerializable: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 50, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'JSON.stringify({ hovered: true, label: "Archive" })' }, + }, + }, + { + jsonrpc: '2.0', + id: 51, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"' }, + }, + }, + { + jsonrpc: '2.0', + id: 52, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, + }, + }, + { + jsonrpc: '2.0', + id: 53, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'throw new Error("boom")' }, + }, + }, + { + jsonrpc: '2.0', + id: 54, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'Infinity' }, + }, + }, + { + jsonrpc: '2.0', + id: 55, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'window' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readonly' } } + ); + + expect(getResponseText(responses.find((message) => message.id === 50))).toContain( + 'mode: readonly' + ); + expect(getResponseText(responses.find((message) => message.id === 50))).toContain( + 'value: {"hovered":true,"label":"Archive"}' + ); + expect(getResponseText(responses.find((message) => message.id === 51))).toContain( + 'Browser MCP failed: EvalError: Possible side-effect in debug-evaluate' + ); + expect(getResponseText(responses.find((message) => message.id === 52))).toContain( + 'Browser MCP failed: browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=readonly' + ); + expect(getResponseText(responses.find((message) => message.id === 53))).toContain( + 'Browser MCP failed: boom' + ); + expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'value: "Infinity"' + ); + expect(getResponseText(responses.find((message) => message.id === 55))).toContain( + 'Browser MCP failed: evaluation result is not JSON-serializable' + ); + }); + + it('allows readwrite browser_eval when configured', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Eval Page', + currentUrl: 'https://example.com/', + eval: { + 'document.body.dataset.test = "1"': { + result: 'ok', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 54, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readwrite' } } + ); + + expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'mode: readwrite' + ); + expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'value: "ok"' + ); + }); + + it('hides browser_eval when eval mode is disabled', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Eval Disabled Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 56, + method: 'tools/list', + params: {}, + }, + { + jsonrpc: '2.0', + id: 57, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: '1 + 1' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'disabled' } } + ); + + const tools = (responses.find((message) => message.id === 56)?.result as { tools?: Array<{ name?: string }> }) + ?.tools || []; + expect(tools.some((tool) => tool.name === 'browser_eval')).toBe(false); + expect((responses.find((message) => message.id === 57)?.error as { message?: string })?.message).toBe( + 'Unknown tool: browser_eval' + ); + }); + + it('queries inside an iframe selected by frameSelector', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Frame Page', + currentUrl: 'https://example.com/', + frames: [ + { + selector: '#details-frame', + query: { + '.save-button': { + exists: true, + connected: true, + innerText: 'Save', + textContent: 'Save', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + }, + ], + [ + { + jsonrpc: '2.0', + id: 55, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.save-button', + frameSelector: '#details-frame', + fields: ['exists', 'innerText'], + }, + }, + }, + ] + ); + + expect( + getMockFrame( + { + frames: [{ selector: '#details-frame' }], + id: 'x', + title: 'x', + currentUrl: 'https://example.com/', + }, + '#details-frame' + ) + ).toBeDefined(); + expect(getResponseText(responses.find((message) => message.id === 55))).toContain( + 'innerText: Save' + ); + }); + + it('queries through open shadow roots when pierceShadow is true', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Shadow Page', + currentUrl: 'https://example.com/', + shadowRoots: [ + { + hostSelector: 'app-toolbar', + query: { + 'button[action="archive"]': { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + }, + ], + [ + { + jsonrpc: '2.0', + id: 56, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: 'button[action="archive"]', + pierceShadow: true, + fields: ['exists', 'innerText'], + }, + }, + }, + ] + ); + + expect( + getMockShadowRoot({ + shadowRoots: [{ hostSelector: 'app-toolbar' }], + id: 'x', + title: 'x', + currentUrl: 'https://example.com/', + }) + ).toBeDefined(); + expect(getResponseText(responses.find((message) => message.id === 56))).toContain( + 'innerText: Archive' + ); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts b/tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts new file mode 100644 index 00000000..4ba36192 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts @@ -0,0 +1,1765 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText, createReplayStep, createOrchestrationBlock, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - orchestration and artifacts', () => { + it('starts orchestration, reports progress, and completes a wait_then_click flow', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Orchestration Page', + currentUrl: 'https://example.com/orchestration', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#menu': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1201, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { + selector: '#menu', + condition: { kind: 'visibility' }, + timeoutMs: 1000, + }, + click: { + selector: '#menu', + }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1202, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1201))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('completedBlocks: 1'); + expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('status: completed'); + }); + + it('cancels orchestration before later blocks run', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Orchestration Cancel Page', + currentUrl: 'https://example.com/orchestration-cancel', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 40, + height: 40, + top: 30, + right: 60, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#status': { + exists: true, + connected: true, + innerText: 'ready', + textContent: 'ready', + rect: { + x: 80, + y: 30, + width: 80, + height: 20, + top: 30, + right: 160, + bottom: 50, + left: 80, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1202_1, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'run_replay_sequence', + args: { + steps: [ + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'pause', durationMs: 400 }, + ], + }, + }), + ], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'equals', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1202_2, + method: 'tools/call', + params: { name: 'browser_cancel_orchestration', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1202_3, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1202_1))).toContain( + 'status: running' + ); + const text = getResponseText(responses.find((message) => message.id === 1202_3)); + expect(text).toContain('status: canceled'); + expect(text).not.toContain('completedBlocks: 2'); + }); + + it('completes a wait_then_type flow', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Type Orchestration Page', + currentUrl: 'https://example.com/type-orchestration', + wait: { + selectorSnapshots: { + '#email': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + type: { + '#email': { kind: 'input', inputType: 'email', value: '' }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1203, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'wait_for_then_type', + args: { + wait: { selector: '#email', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + type: { selector: '#email', text: 'walker@example.com', clearFirst: true }, + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('completedBlocks: 1'); + }); + + it('completes a run_replay_sequence block', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Sequence Orchestration Page', + currentUrl: 'https://example.com/orchestration-replay', + click: { '#submit': {} }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1211, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'run_replay_sequence', + args: { + steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1211))).toContain('status: completed'); + }); + + it('completes an assert_query block', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Query Page', + currentUrl: 'https://example.com/assert-query', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready', + textContent: 'ready', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1212, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assert: { field: 'innerText', equals: 'ready' }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1212))).toContain('status: completed'); + }); + + it('fails orchestration on the first failing block', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Failure Page', currentUrl: 'https://example.com/orchestration-failure' }], + [ + { + jsonrpc: '2.0', + id: 1213, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assert: { field: 'exists', equals: 'true' }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1214, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1214)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + }); + + it('runs a single-layer sequence with two successful blocks', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Page', + currentUrl: 'https://example.com/sequence', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#menu': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1401, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('completedBlocks: 1'); + }); + + it('reports failedSequenceStepIndex when a sequence step fails', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Failure Page', + currentUrl: 'https://example.com/sequence-failure', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1402, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1403, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1403)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + expect(text).toContain('failedSequenceStepIndex: 1'); + }); + + it('rejects empty sequence steps and nested sequence blocks', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Sequence Validation Page', currentUrl: 'https://example.com/sequence-validation' }], + [ + { + jsonrpc: '2.0', + id: 1404, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1405, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1404))).toContain('sequence steps must be a non-empty array'); + expect(getResponseText(responses.find((message) => message.id === 1405))).toContain('sequence does not support nested sequence blocks'); + }); + + it('runs a top-level block followed by a sequence in order', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Mixed Sequence Page', + currentUrl: 'https://example.com/mixed-sequence', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1406, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('completedBlocks: 2'); + }); + + it('passes assert_query with multiple structured assertions', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Query Page', + currentUrl: 'https://example.com/assert-query', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '0.95', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1301, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['exists', 'innerText', 'opacity'] }, + assertions: [ + { field: 'exists', op: 'equals', value: true }, + { field: 'innerText', op: 'contains', value: 'ready' }, + { field: 'opacity', op: 'gte', value: 0.9 }, + ], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1301))).toContain('status: completed'); + }); + + it('returns failedAssertionIndex, expected, and actual when assert_query fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Failure Page', + currentUrl: 'https://example.com/assert-failure', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'loading', + textContent: 'loading', + display: 'block', + visibility: 'visible', + opacity: '0.42', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1302, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText', 'opacity'] }, + assertions: [ + { field: 'innerText', op: 'contains', value: 'ready' }, + { field: 'opacity', op: 'gte', value: 0.9 }, + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1303, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1303)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + expect(text).toContain('failedAssertionIndex: 0'); + expect(text).toContain('field: innerText'); + expect(text).toContain('expected: "ready"'); + expect(text).toContain('actual: "loading"'); + }); + + it('fails when numeric comparison is used on a non-number-like field or when op is unsupported', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Type Error Page', + currentUrl: 'https://example.com/assert-type-error', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'loading', + textContent: 'loading', + opacity: '0.42', + display: 'block', + visibility: 'visible', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1304, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'gte', value: 1 }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1305, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['opacity'] }, + assertions: [{ field: 'opacity', op: 'matches', value: '.*' }], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1304))).toContain('numeric comparison expects a number-like field: innerText'); + expect(getResponseText(responses.find((message) => message.id === 1305))).toContain('unsupported assertion operator'); + }); + + it('passes numeric gte and lt assertions', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Numeric Assertion Page', + currentUrl: 'https://example.com/assert-numeric', + query: { + '#status': { + exists: true, + connected: true, + opacity: '0.95', + display: 'block', + visibility: 'visible', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1306, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['opacity'] }, + assertions: [ + { field: 'opacity', op: 'gte', value: 0.9 }, + { field: 'opacity', op: 'lt', value: 1.0 }, + ], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1306))).toContain('status: completed'); + }); + + it('continues to the next top-level block when continueOnError is true', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Policy Page', + currentUrl: 'https://example.com/policy', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1501, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1502, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1502)); + expect(text).toContain('status: completed_with_failures'); + expect(text).toContain('completedBlocks: 2'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].blockIndex: 0'); + }); + + it('continues inside sequence when a step has continueOnError', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Policy Page', + currentUrl: 'https://example.com/sequence-policy', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1503, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1504, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1504)); + expect(text).toContain('status: completed_with_failures'); + expect(text).toContain('completedBlocks: 1'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].sequenceStepIndex: 0'); + }); + + it('still stops immediately when continueOnError is not enabled', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Strict Stop Page', currentUrl: 'https://example.com/strict-stop' }], + [ + { + jsonrpc: '2.0', + id: 1505, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1505)); + expect(text).toContain('status: failed'); + expect(text).toContain('completedBlocks: 0'); + }); + + it('continues to the next top-level block when a sequence block itself allows continueOnError', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Top-Level Policy Page', + currentUrl: 'https://example.com/sequence-top-policy', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1506, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + continueOnError: true, + args: { + steps: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + ], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1507, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1507)); + expect(text).toContain('status: completed_with_failures'); + expect(text).toContain('completedBlocks: 2'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].sequenceStepIndex: 0'); + }); + + it('exports recording and replay artifacts and lists them', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Page', + currentUrl: 'https://example.com/artifact', + recording: { + events: [ + { + kind: 'click', + selector: '#submit', + timestamp: 1710000000000, + }, + ], + }, + click: { + '#submit': {}, + }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1601, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1602, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { + jsonrpc: '2.0', + id: 1603, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'rec-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1604, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1605, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'replay', name: 'replay-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1606, + method: 'tools/call', + params: { name: 'browser_list_artifacts', arguments: {} }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1603))).toContain('status: exported'); + expect(getResponseText(responses.find((message) => message.id === 1605))).toContain('status: exported'); + const listText = getResponseText(responses.find((message) => message.id === 1606)); + expect(listText).toContain('name: rec-smoke'); + expect(listText).toContain('name: replay-smoke'); + }); + + it('imports and deletes orchestration artifacts', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Page', + currentUrl: 'https://example.com/artifact', + click: { '#submit': {} }, + wait: { + selectorSnapshots: { + '#submit': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1607, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1608, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'orchestration', name: 'orc-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1609, + method: 'tools/call', + params: { name: 'browser_list_artifacts', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1610, + method: 'tools/call', + params: { name: 'browser_import_artifact', arguments: { path: 'artifact:orc-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1611, + method: 'tools/call', + params: { name: 'browser_delete_artifact', arguments: { name: 'orc-smoke' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1608))).toContain('status: exported'); + expect(getResponseText(responses.find((message) => message.id === 1610))).toContain('status: imported'); + expect(getResponseText(responses.find((message) => message.id === 1611))).toContain('status: deleted'); + }); + + it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-artifact-failures-')); + const oversizedArtifactPath = join(tempDir, 'oversized-artifact.json'); + writeFileSync(oversizedArtifactPath, Buffer.alloc(5 * 1024 * 1024 + 1, 'a')); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Failure Page', + currentUrl: 'https://example.com/artifact-failure', + recording: { + events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000000 }], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1612, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1613, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1614, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, + { jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, + { jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } }, + { jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } }, + { jsonrpc: '2.0', id: 1618, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: '../escape' } } }, + { jsonrpc: '2.0', id: 1619, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:../escape' } } }, + { jsonrpc: '2.0', id: 1620, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: oversizedArtifactPath } } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists'); + expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found'); + expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found'); + expect(getResponseText(responses.find((message) => message.id === 1618))).toContain( + 'artifact name must start with a letter or number' + ); + expect(getResponseText(responses.find((message) => message.id === 1619))).toContain( + 'artifact name must start with a letter or number' + ); + expect(getResponseText(responses.find((message) => message.id === 1620))).toContain( + 'artifact file exceeds maximum size' + ); + }); + + it('runs select_page_then_run and executes the inner single-page block on the selected page', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1701, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 1 }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1701))).toContain('status: completed'); + }); + + it('runs open_page_then_run and executes the inner block on the newly opened page', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1702, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'open_page_then_run', + args: { + open: { url: 'https://example.com/docs' }, + run: { + type: 'wait_for_then_press_key', + args: { + wait: { condition: { kind: 'text', includes: 'New page visible text' }, timeoutMs: 1000 }, + pressKey: { key: 'Enter' }, + }, + }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1702))).toContain('status: completed'); + }); + + it('runs close_page_then_continue and then continues with the remaining blocks', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1703, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageId: 'page-2' } } }, + { + jsonrpc: '2.0', + id: 1704, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'close_page_then_continue', + args: { close: { pageId: 'page-2' } }, + }), + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 0 }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + continueOnError: true, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1704))).toContain('completedBlocks: 2'); + }); + + it('rejects missing run payloads and nested cross-page blocks', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1705, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [createOrchestrationBlock({ type: 'select_page_then_run', args: { select: { pageIndex: 0 } } })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1706, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 0 }, + run: createOrchestrationBlock({ + type: 'open_page_then_run', + args: { open: { url: 'https://example.com' }, run: { type: 'assert_query', args: {} } }, + }), + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1705))).toContain('cross-page run block is required'); + expect(getResponseText(responses.find((message) => message.id === 1706))).toContain('nested cross-page blocks are not supported'); + }); + + it('fails when select_page_then_run targets a missing page', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1707, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageId: 'page-9' }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1708, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1708)); + expect(text).toContain('status: failed'); + expect(text).toContain('page not found: page-9'); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-recording-and-replay.test.ts b/tests/unit/hooks/browser-mcp-recording-and-replay.test.ts new file mode 100644 index 00000000..782108d4 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-recording-and-replay.test.ts @@ -0,0 +1,626 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText, createReplayStep } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - recording and replay', () => { + it('starts, stops, reads, and clears a recording session', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Recording Page', + currentUrl: 'https://example.com/recording', + recording: { + events: [ + { + kind: 'click', + selector: '#submit', + button: 'left', + clickCount: 1, + offsetX: 12, + offsetY: 8, + timestamp: 1710000000000, + }, + ], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1001, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1002, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1003, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1004, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1001))).toContain('status: recording'); + expect(getResponseText(responses.find((message) => message.id === 1002))).toContain('status: stopped'); + expect(getResponseText(responses.find((message) => message.id === 1003))).toContain('type: click'); + expect(getResponseText(responses.find((message) => message.id === 1004))).toContain('status: cleared'); + }); + + it('rejects invalid recording lifecycle operations', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Recording Page', + currentUrl: 'https://example.com/recording', + }, + ], + [ + { jsonrpc: '2.0', id: 1011, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1012, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1013, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1014, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1015, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1016, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1012))).toContain('recording already active'); + expect(getResponseText(responses.find((message) => message.id === 1014))).toContain('no active recording'); + expect(getResponseText(responses.find((message) => message.id === 1016))).toContain('no recording available'); + }); + + it('routes recording start by pageId and rejects page conflicts', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'First', currentUrl: 'https://example.com/1' }, + { id: 'page-2', title: 'Second', currentUrl: 'https://example.com/2' }, + ], + [ + { + jsonrpc: '2.0', + id: 1017, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 1018, + method: 'tools/call', + params: { name: 'browser_clear_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: { pageIndex: 0, pageId: 'page-1' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1017))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together'); + }); + + it('cleans up recording state when stop finalization fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Broken Stop Recording Page', + currentUrl: 'https://example.com/broken-stop-recording', + recording: { + finalizeError: 'recording finalize failed', + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1019_1, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019_2, + method: 'tools/call', + params: { name: 'browser_stop_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019_3, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: {} }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1019_2))).toContain( + 'recording finalize failed' + ); + expect(getResponseText(responses.find((message) => message.id === 1019_3))).toContain( + 'status: recording' + ); + }); + + it('rolls back recording state when recorder injection fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Broken Recording Page', + currentUrl: 'https://example.com/broken-recording', + recording: { + injectionError: 'recording injection failed', + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1020, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1020_1, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1020))).toContain('recording injection failed'); + expect(getResponseText(responses.find((message) => message.id === 1020_1))).toContain('no recording available'); + }); + + it('normalizes type, press_key, scroll, and warnings in a recording result', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Normalize Page', + currentUrl: 'https://example.com/normalize', + recording: { + events: [ + { kind: 'type', selector: '#email', text: 'walker@example.com', timestamp: 1710000000100 }, + { kind: 'press_key', key: 'Enter', modifiers: ['Shift'], timestamp: 1710000000200 }, + { kind: 'scroll', selector: '#results', deltaX: 0, deltaY: 320, timestamp: 1710000000300 }, + ], + warnings: [{ message: 'cross-origin frame events were skipped' }], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1021, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1022, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1023, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1023)); + expect(text).toContain('type: type'); + expect(text).toContain('selector: #email'); + expect(text).toContain('type: press_key'); + expect(text).toContain('type: scroll'); + expect(text).toContain('cross-origin frame events were skipped'); + }); + + it('normalizes drag_element and pointer_action recordings', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Drag Recording Page', + currentUrl: 'https://example.com/drag-recording', + recording: { + events: [ + { + kind: 'drag_element', + selector: '#card-a', + targetSelector: '#lane-b', + timestamp: 1710000000400, + }, + { + kind: 'pointer_action', + actions: [ + { type: 'move', x: 10, y: 20 }, + { type: 'down', button: 'left' }, + { type: 'up', button: 'left' }, + ], + timestamp: 1710000000500, + }, + ], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1031, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1032, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1033, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1033)); + expect(text).toContain('type: drag_element'); + expect(text).toContain('selector: #card-a'); + expect(text).toContain('targetSelector: "#lane-b"'); + expect(text).toContain('type: pointer_action'); + expect(text).toContain('actions:'); + }); + + it('stops recording with a warning when the target page becomes unavailable', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Closing Page', + currentUrl: 'https://example.com/closing', + recording: { + events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000600 }], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1034, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1035, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-1' } } }, + { jsonrpc: '2.0', id: 1036, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1036)); + expect(text).toContain('status: stopped'); + expect(text).toContain('recording stopped because target page was closed'); + }); + + it('starts a replay, reports progress, and completes basic steps', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Page', + currentUrl: 'https://example.com/replay', + click: { + '#submit': {}, + }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + type: { + '#email': { kind: 'input', inputType: 'email', value: '' }, + }, + scroll: { + '#results': { expectedBehavior: 'by-offset', expectedDeltaX: 0, expectedDeltaY: 240 }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1101, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ + type: 'click', + pageId: 'page-1', + selector: '#submit', + nth: 0, + args: { button: 'left', clickCount: 1, offsetX: 12, offsetY: 8 }, + }), + createReplayStep({ + type: 'type', + pageId: 'page-1', + selector: '#email', + nth: 0, + args: { text: 'walker@example.com' }, + }), + createReplayStep({ + type: 'scroll', + pageId: 'page-1', + selector: '#results', + args: { deltaX: 0, deltaY: 240 }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1102, + method: 'tools/call', + params: { name: 'browser_get_replay', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1101))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('completedSteps: 3'); + expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('status: completed'); + }); + + it('rejects invalid replay payloads before execution starts', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Replay Page', currentUrl: 'https://example.com/replay' }], + [ + { + jsonrpc: '2.0', + id: 1111, + method: 'tools/call', + params: { name: 'browser_start_replay', arguments: { steps: [] } }, + }, + { + jsonrpc: '2.0', + id: 1112, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [createReplayStep({ type: 'unknown-step', pageId: 'page-1' })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1113, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [createReplayStep({ type: 'click', pageId: 'page-2', selector: '#submit', args: {} })], + pageId: 'page-1', + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1111))).toContain('steps must be a non-empty array'); + expect(getResponseText(responses.find((message) => message.id === 1112))).toContain('unsupported replay step type'); + expect(getResponseText(responses.find((message) => message.id === 1113))).toContain('replay step pageId mismatch'); + }); + + it('fails replay on the first failing step and reports failedStepIndex', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Replay Failure Page', + currentUrl: 'https://example.com/replay-failure', + click: { + '#submit': {}, + }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1114, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} }), + createReplayStep({ type: 'click', pageId: 'page-1', selector: '#missing', args: {} }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1115, + method: 'tools/call', + params: { name: 'browser_get_replay', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1115)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedStepIndex: 1'); + expect(text).toContain('element index 0 is out of range for selector: #missing'); + }); + + it('cancels a replay before later steps run', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Cancel Page', + currentUrl: 'https://example.com/replay-cancel', + click: { + '#submit': {}, + }, + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 40, + height: 40, + top: 30, + right: 60, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#submit': { + exists: true, + connected: true, + rect: { + x: 120, + y: 30, + width: 100, + height: 40, + top: 30, + right: 220, + bottom: 70, + left: 120, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1116, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'pause', durationMs: 400 }, + ], + }, + }), + createReplayStep({ + type: 'click', + pageId: 'page-1', + selector: '#submit', + args: {}, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1117, + method: 'tools/call', + params: { name: 'browser_cancel_replay', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1118, + method: 'tools/call', + params: { name: 'browser_get_replay', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1116))).toContain( + 'status: running' + ); + const text = getResponseText(responses.find((message) => message.id === 1118)); + expect(text).toContain('status: canceled'); + expect(text).not.toContain('completedSteps: 2'); + }); + + it('replays drag_element and pointer_action steps successfully', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Drag Page', + currentUrl: 'https://example.com/replay-drag', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 20, + y: 40, + width: 100, + height: 60, + top: 40, + right: 120, + bottom: 100, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#lane-b': { + exists: true, + connected: true, + rect: { + x: 240, + y: 60, + width: 120, + height: 80, + top: 60, + right: 360, + bottom: 140, + left: 240, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1121, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ + type: 'drag_element', + pageId: 'page-1', + selector: '#card-a', + args: { targetSelector: '#lane-b' }, + }), + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', x: 10, y: 20 }, + { type: 'down', button: 'left' }, + { type: 'up', button: 'left' }, + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('completedSteps: 2'); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts new file mode 100644 index 00000000..554a25c7 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts @@ -0,0 +1,1817 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - session and interception', () => { + it('lists browser tools including navigate, click, type, and screenshot', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], + [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }] + ); + + const tools = ( + responses.find((message) => message.id === 2)?.result as { + tools: Array<{ + name: string; + description?: string; + inputSchema?: { properties?: Record }; + }>; + } + ).tools; + + expect(tools.map((tool) => tool.name)).toEqual([ + 'browser_get_session_info', + 'browser_get_url_and_title', + 'browser_get_visible_text', + 'browser_get_dom_snapshot', + 'browser_navigate', + 'browser_click', + 'browser_type', + 'browser_press_key', + 'browser_scroll', + 'browser_select_page', + 'browser_open_page', + 'browser_close_page', + 'browser_add_intercept_rule', + 'browser_remove_intercept_rule', + 'browser_list_intercept_rules', + 'browser_list_requests', + 'browser_set_download_behavior', + 'browser_list_downloads', + 'browser_cancel_download', + 'browser_set_file_input', + 'browser_drag_files', + 'browser_drag_element', + 'browser_pointer_action', + 'browser_start_recording', + 'browser_stop_recording', + 'browser_get_recording', + 'browser_clear_recording', + 'browser_start_replay', + 'browser_get_replay', + 'browser_cancel_replay', + 'browser_start_orchestration', + 'browser_get_orchestration', + 'browser_cancel_orchestration', + 'browser_export_artifact', + 'browser_import_artifact', + 'browser_list_artifacts', + 'browser_delete_artifact', + 'browser_take_screenshot', + 'browser_wait_for', + 'browser_eval', + 'browser_hover', + 'browser_query', + 'browser_take_element_screenshot', + 'browser_wait_for_event', + ]); + + const clickTool = tools.find((tool) => tool.name === 'browser_click'); + expect(clickTool?.description).toContain('mouse event chain'); + expect(clickTool?.description).not.toContain('synthetic element.click()'); + expect(clickTool?.inputSchema?.properties?.offsetX).toMatchObject({ type: 'number' }); + expect(clickTool?.inputSchema?.properties?.offsetY).toMatchObject({ type: 'number' }); + expect(clickTool?.inputSchema?.properties?.button).toMatchObject({ type: 'string' }); + expect(clickTool?.inputSchema?.properties?.clickCount).toMatchObject({ + type: 'integer', + minimum: 1, + }); + + const keyTool = tools.find((tool) => tool.name === 'browser_press_key'); + expect(keyTool?.inputSchema?.properties?.key).toMatchObject({ type: 'string' }); + expect(keyTool?.inputSchema?.properties?.modifiers).toMatchObject({ type: 'array' }); + + const scrollTool = tools.find((tool) => tool.name === 'browser_scroll'); + expect(scrollTool?.inputSchema?.properties?.deltaX).toMatchObject({ type: 'number' }); + expect(scrollTool?.inputSchema?.properties?.deltaY).toMatchObject({ type: 'number' }); + + const selectTool = tools.find((tool) => tool.name === 'browser_select_page'); + expect(selectTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(selectTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const openTool = tools.find((tool) => tool.name === 'browser_open_page'); + expect(openTool?.inputSchema?.properties?.url).toMatchObject({ type: 'string' }); + + const closeTool = tools.find((tool) => tool.name === 'browser_close_page'); + expect(closeTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(closeTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const addRuleTool = tools.find((tool) => tool.name === 'browser_add_intercept_rule'); + expect(addRuleTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(addRuleTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.urlIncludes).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.method).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.resourceType).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.urlPattern).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.urlRegex).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.headerMatchers).toMatchObject({ type: 'array' }); + expect(addRuleTool?.inputSchema?.properties?.priority).toMatchObject({ type: 'integer' }); + expect(addRuleTool?.inputSchema?.properties?.action).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.statusCode).toMatchObject({ type: 'integer' }); + expect(addRuleTool?.inputSchema?.properties?.responseHeaders).toMatchObject({ type: 'array' }); + expect(addRuleTool?.inputSchema?.properties?.headers).toBeUndefined(); + expect(addRuleTool?.inputSchema?.properties?.body).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.contentType).toMatchObject({ type: 'string' }); + + const removeRuleTool = tools.find((tool) => tool.name === 'browser_remove_intercept_rule'); + expect(removeRuleTool?.inputSchema?.properties?.ruleId).toMatchObject({ type: 'string' }); + + const listRulesTool = tools.find((tool) => tool.name === 'browser_list_intercept_rules'); + expect(listRulesTool?.inputSchema).toMatchObject({ type: 'object' }); + + const listRequestsTool = tools.find((tool) => tool.name === 'browser_list_requests'); + expect(listRequestsTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(listRequestsTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + expect(listRequestsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' }); + + const setDownloadTool = tools.find((tool) => tool.name === 'browser_set_download_behavior'); + expect(setDownloadTool?.inputSchema?.properties?.behavior).toMatchObject({ type: 'string' }); + expect(setDownloadTool?.inputSchema?.properties?.downloadPath).toMatchObject({ type: 'string' }); + expect(setDownloadTool?.inputSchema?.properties?.eventsEnabled).toMatchObject({ type: 'boolean' }); + + const listDownloadsTool = tools.find((tool) => tool.name === 'browser_list_downloads'); + expect(listDownloadsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' }); + expect(listDownloadsTool?.inputSchema?.properties?.pageId).toBeUndefined(); + + const cancelDownloadTool = tools.find((tool) => tool.name === 'browser_cancel_download'); + expect(cancelDownloadTool?.inputSchema?.properties?.downloadId).toMatchObject({ type: 'string' }); + expect(cancelDownloadTool?.inputSchema?.properties?.guid).toMatchObject({ type: 'string' }); + + const uploadTool = tools.find((tool) => tool.name === 'browser_set_file_input'); + expect(uploadTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); + expect(uploadTool?.inputSchema?.properties?.files).toMatchObject({ type: 'array' }); + expect(uploadTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(uploadTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + expect(uploadTool?.inputSchema?.properties?.nth).toMatchObject({ type: 'integer' }); + expect(uploadTool?.inputSchema?.properties?.frameSelector).toMatchObject({ type: 'string' }); + expect(uploadTool?.inputSchema?.properties?.pierceShadow).toMatchObject({ type: 'boolean' }); + + const dragFilesTool = tools.find((tool) => tool.name === 'browser_drag_files'); + expect(dragFilesTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); + expect(dragFilesTool?.inputSchema?.properties?.files).toMatchObject({ type: 'array' }); + expect(dragFilesTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(dragFilesTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + expect(dragFilesTool?.inputSchema?.properties?.nth).toMatchObject({ type: 'integer' }); + expect(dragFilesTool?.inputSchema?.properties?.frameSelector).toMatchObject({ type: 'string' }); + expect(dragFilesTool?.inputSchema?.properties?.pierceShadow).toMatchObject({ type: 'boolean' }); + + const dragElementTool = tools.find((tool) => tool.name === 'browser_drag_element'); + expect(dragElementTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); + expect(dragElementTool?.inputSchema?.properties?.targetSelector).toMatchObject({ type: 'string' }); + expect(dragElementTool?.inputSchema?.properties?.targetX).toMatchObject({ type: 'number' }); + expect(dragElementTool?.inputSchema?.properties?.targetY).toMatchObject({ type: 'number' }); + expect(dragElementTool?.inputSchema?.properties?.steps).toMatchObject({ type: 'integer' }); + + const pointerTool = tools.find((tool) => tool.name === 'browser_pointer_action'); + expect(pointerTool?.inputSchema?.properties?.actions).toMatchObject({ type: 'array' }); + + const startRecordingTool = tools.find((tool) => tool.name === 'browser_start_recording'); + expect(startRecordingTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(startRecordingTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const stopRecordingTool = tools.find((tool) => tool.name === 'browser_stop_recording'); + expect(stopRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); + + const getRecordingTool = tools.find((tool) => tool.name === 'browser_get_recording'); + expect(getRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); + + const clearRecordingTool = tools.find((tool) => tool.name === 'browser_clear_recording'); + expect(clearRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); + + const startReplayTool = tools.find((tool) => tool.name === 'browser_start_replay'); + expect(startReplayTool?.inputSchema?.properties?.steps).toMatchObject({ type: 'array' }); + expect(startReplayTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(startReplayTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const getReplayTool = tools.find((tool) => tool.name === 'browser_get_replay'); + expect(getReplayTool?.inputSchema).toMatchObject({ type: 'object' }); + + const cancelReplayTool = tools.find((tool) => tool.name === 'browser_cancel_replay'); + expect(cancelReplayTool?.inputSchema).toMatchObject({ type: 'object' }); + + const startOrchestrationTool = tools.find((tool) => tool.name === 'browser_start_orchestration'); + expect(startOrchestrationTool?.inputSchema?.properties?.blocks).toMatchObject({ type: 'array' }); + expect(startOrchestrationTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(startOrchestrationTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const getOrchestrationTool = tools.find((tool) => tool.name === 'browser_get_orchestration'); + expect(getOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); + + const cancelOrchestrationTool = tools.find((tool) => tool.name === 'browser_cancel_orchestration'); + expect(cancelOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); + + const exportArtifactTool = tools.find((tool) => tool.name === 'browser_export_artifact'); + expect(exportArtifactTool?.inputSchema?.properties?.kind).toMatchObject({ type: 'string' }); + expect(exportArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); + + const importArtifactTool = tools.find((tool) => tool.name === 'browser_import_artifact'); + expect(importArtifactTool?.inputSchema?.properties?.path).toMatchObject({ type: 'string' }); + + const listArtifactsTool = tools.find((tool) => tool.name === 'browser_list_artifacts'); + expect(listArtifactsTool?.inputSchema).toMatchObject({ type: 'object' }); + + const deleteArtifactTool = tools.find((tool) => tool.name === 'browser_delete_artifact'); + expect(deleteArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); + + const queryTool = tools.find((tool) => tool.name === 'browser_query'); + expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ + type: 'array', + }); + + for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { + expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ + type: 'integer', + minimum: 0, + }); + } + }); + + it('marks the selected page in browser_get_session_info', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 801, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 802, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 802)); + expect(text).toContain('selected: true'); + expect(text).toContain('1. Docs'); + }); + + it('uses the selected page when pageIndex is omitted', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + visibleText: 'Home text', + }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + visibleText: 'Docs text', + }, + ], + [ + { + jsonrpc: '2.0', + id: 811, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 812, + method: 'tools/call', + params: { name: 'browser_get_visible_text', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 812)); + expect(text).toContain('Docs text'); + }); + + it('opens a page and makes it selected', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 821, + method: 'tools/call', + params: { + name: 'browser_open_page', + arguments: { url: 'https://example.com/new' }, + }, + }, + { + jsonrpc: '2.0', + id: 822, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const openText = getResponseText(responses.find((message) => message.id === 821)); + expect(openText).toContain('status: opened'); + expect(openText).toContain('url: https://example.com/new'); + + const listText = getResponseText(responses.find((message) => message.id === 822)); + expect(listText).toContain('https://example.com/new'); + expect(listText).toContain('selected: true'); + }); + + it('closes the selected page and falls back deterministically', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 831, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 832, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 833, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const closeText = getResponseText(responses.find((message) => message.id === 832)); + expect(closeText).toContain('status: closed'); + + const listText = getResponseText(responses.find((message) => message.id === 833)); + expect(listText).toContain('0. Home'); + expect(listText).toContain('selected: true'); + expect(listText).not.toContain('Docs'); + }); + + it('keeps the selected page when closing a different page', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + visibleText: 'Home text', + }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + visibleText: 'Docs text', + }, + { + id: 'page-3', + title: 'Pricing', + currentUrl: 'https://example.com/pricing', + visibleText: 'Pricing text', + }, + ], + [ + { + jsonrpc: '2.0', + id: 834, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, + }, + { + jsonrpc: '2.0', + id: 835, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: { pageId: 'page-3' } }, + }, + { + jsonrpc: '2.0', + id: 836, + method: 'tools/call', + params: { name: 'browser_get_visible_text', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 837, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const visibleText = getResponseText(responses.find((message) => message.id === 836)); + expect(visibleText).toContain('Home text'); + + const listText = getResponseText(responses.find((message) => message.id === 837)); + expect(listText).toContain('0. Home'); + expect(listText).toContain('selected: true'); + expect(listText).not.toContain('Pricing'); + }); + + it('reconciles a stale selected page when browser_get_session_info is called', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 841, + method: 'tools/call', + params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' } }, + }, + { + jsonrpc: '2.0', + id: 842, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 843, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 843)); + expect(listText).toContain('0. Home'); + expect(listText).toContain('selected: true'); + }); + + it('closes a page by pageId', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 851, + method: 'tools/call', + params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' } }, + }, + { + jsonrpc: '2.0', + id: 852, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 853, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const closeText = getResponseText(responses.find((message) => message.id === 852)); + expect(closeText).toContain('pageId: page-2'); + expect(closeText).toContain('status: closed'); + + const listText = getResponseText(responses.find((message) => message.id === 853)); + expect(listText).toContain('0. Home'); + expect(listText).not.toContain('https://example.com/new'); + }); + + it('adds an interception rule and lists it by bound pageId', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 901, + method: 'tools/call', + params: { + name: 'browser_select_page', + arguments: { pageIndex: 1 }, + }, + }, + { + jsonrpc: '2.0', + id: 902, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 903, + method: 'tools/call', + params: { + name: 'browser_list_intercept_rules', + arguments: {}, + }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 903)); + expect(listText).toContain('pageId: page-2'); + expect(listText).toContain('urlIncludes: /api'); + expect(listText).toContain('method: GET'); + expect(listText).toContain('action: continue'); + }); + + it('keeps an existing rule bound to the original page after selected page changes', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 911, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, + }, + { + jsonrpc: '2.0', + id: 912, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'POST', action: 'fail' }, + }, + }, + { + jsonrpc: '2.0', + id: 913, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 914, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 914)); + expect(listText).toContain('pageId: page-1'); + expect(listText).toContain('method: POST'); + expect(listText).toContain('action: fail'); + }); + + it('keeps richer matching rules bound to the original page after selected page changes', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 1003, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 1004, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + resourceType: 'XHR', + priority: 7, + action: 'continue', + }, + }, + }, + { + jsonrpc: '2.0', + id: 1005, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, + }, + { + jsonrpc: '2.0', + id: 1006, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 1006)); + expect(listText).toContain('pageId: page-2'); + expect(listText).toContain('resourceType: XHR'); + expect(listText).toContain('priority: 7'); + }); + + it('removes an interception rule by ruleId', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 921, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 922, + method: 'tools/call', + params: { + name: 'browser_remove_intercept_rule', + arguments: { ruleId: 'rule-1' }, + }, + }, + { + jsonrpc: '2.0', + id: 923, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const removeText = getResponseText(responses.find((message) => message.id === 922)); + expect(removeText).toContain('ruleId: rule-1'); + expect(removeText).toContain('status: removed'); + + const listText = getResponseText(responses.find((message) => message.id === 923)); + expect(listText).toBe('status: empty'); + }); + + it('records recent requests with matched rule action summaries', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-1', + url: 'https://example.com/api/users', + method: 'GET', + resourceType: 'XHR', + }, + { + requestId: 'req-2', + url: 'https://example.com/assets/app.js', + method: 'GET', + resourceType: 'Script', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 931, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'fail' }, + }, + }, + { + jsonrpc: '2.0', + id: 932, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 932)); + expect(listText).toContain('requestId: req-1'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(listText).toContain('action: fail'); + expect(listText).toContain('requestId: req-2'); + expect(listText).toContain('action: continue'); + }); + + it('removes rules and recent requests bound to a page after that page is closed', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + intercept: { + pausedRequests: [{ requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 941, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 942, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 943, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 944, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 945, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 946, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const preCloseRequestsText = getResponseText(responses.find((message) => message.id === 943)); + expect(preCloseRequestsText).toContain('requestId: req-closed'); + + const listRulesText = getResponseText(responses.find((message) => message.id === 945)); + expect(listRulesText).not.toContain('pageId: page-2'); + + const postCloseRequestsText = getResponseText(responses.find((message) => message.id === 946)); + expect(postCloseRequestsText).not.toContain('requestId: req-closed'); + expect(postCloseRequestsText).not.toContain('pageId: page-2'); + }); + + it('rejects browser_add_intercept_rule when pageIndex and pageId are both provided', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 951, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + pageIndex: 0, + pageId: 'page-1', + urlIncludes: '/api', + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 951); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: pageIndex and pageId cannot be used together'); + }); + + it('rejects browser_add_intercept_rule when action is invalid', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 952, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api', + action: 'mock', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 952); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: action must be one of: continue, fail'); + }); + + it('rejects intercept rules when urlPattern and urlRegex are both provided', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 981, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlPattern: 'https://example.com/api/*', + urlRegex: '^https://example\\.com/api/', + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 981); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: urlPattern and urlRegex cannot be used together'); + }); + + it('rejects intercept rules when priority is not an integer', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 982, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api', + priority: 1.5, + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 982); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: priority must be an integer'); + }); + + it('rejects intercept rules when headerMatchers is not an array', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 983, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: 'x', + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 983); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers must be an array'); + }); + + it('rejects intercept rules when a header matcher is missing name', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 984, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: [{ valueIncludes: 'staging' }], + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 984); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.name is required'); + }); + + it('rejects intercept rules when a header matcher has no value matcher', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 985, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: [{ name: 'x-env' }], + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 985); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers entry must include valueIncludes or valueRegex'); + }); + + it('rejects intercept rules when no matching condition is provided', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 986, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + priority: 10, + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 986); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: at least one matching condition is required'); + }); + + it('adds a resourceType interception rule and lists its richer matching summary', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 987, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + resourceType: 'XHR', + priority: 10, + action: 'continue', + }, + }, + }, + { + jsonrpc: '2.0', + id: 988, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 988)); + expect(listText).toContain('resourceType: XHR'); + expect(listText).toContain('priority: 10'); + }); + + it('prefers the higher-priority matched rule over an earlier lower-priority rule', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-priority', + url: 'https://example.com/api/orders', + method: 'GET', + resourceType: 'XHR', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 989, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api', + action: 'fulfill', + statusCode: 201, + body: 'low', + }, + }, + }, + { + jsonrpc: '2.0', + id: 990, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + resourceType: 'XHR', + priority: 10, + action: 'fulfill', + statusCode: 202, + body: 'high', + }, + }, + }, + { + jsonrpc: '2.0', + id: 991, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 991)); + expect(listText).toContain('requestId: req-priority'); + expect(listText).toContain('matchedRuleId: rule-2'); + expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(202); + }); + + it('keeps creation order when matched rules have the same priority', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-same-priority', + url: 'https://example.com/api/orders', + method: 'GET', + resourceType: 'XHR', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 9911, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api', + priority: 5, + action: 'fulfill', + statusCode: 201, + body: 'first', + }, + }, + }, + { + jsonrpc: '2.0', + id: 9912, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + resourceType: 'XHR', + priority: 5, + action: 'fulfill', + statusCode: 202, + body: 'second', + }, + }, + }, + { + jsonrpc: '2.0', + id: 9913, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 9913)); + expect(listText).toContain('requestId: req-same-priority'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(201); + }); + + it('matches urlPattern rules with wildcard syntax', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-pattern', + url: 'https://example.com/api/v1/users', + method: 'GET', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 992, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlPattern: 'https://example.com/api/*', + action: 'fail', + }, + }, + }, + { + jsonrpc: '2.0', + id: 993, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 993)); + expect(listText).toContain('requestId: req-pattern'); + expect(listText).toContain('action: fail'); + expect(pages[0]?.intercept?.failedRequests?.[0]?.requestId).toBe('req-pattern'); + }); + + it('matches urlRegex rules', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-regex', + url: 'https://example.com/api/users', + method: 'GET', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 994, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlRegex: '^https://example\\.com/api/(users|teams)$', + action: 'continue', + }, + }, + }, + { + jsonrpc: '2.0', + id: 995, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 995)); + expect(listText).toContain('requestId: req-regex'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(pages[0]?.intercept?.continuedRequestIds).toContain('req-regex'); + }); + + it('matches headerMatchers using valueIncludes and case-insensitive header names', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-header-includes', + url: 'https://example.com/api/header-includes', + method: 'GET', + requestHeaders: { + 'X-Env': 'staging-us', + }, + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 996, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: [{ name: 'x-env', valueIncludes: 'staging' }], + action: 'fulfill', + statusCode: 207, + body: 'matched-includes', + }, + }, + }, + { + jsonrpc: '2.0', + id: 997, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 997)); + expect(listText).toContain('requestId: req-header-includes'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(207); + }); + + it('matches headerMatchers using valueRegex', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-header-regex', + url: 'https://example.com/api/header-regex', + method: 'GET', + requestHeaders: { + 'x-tenant': 'acme-prod', + }, + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 998, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: [{ name: 'X-Tenant', valueRegex: '^acme-' }], + action: 'continue', + }, + }, + }, + { + jsonrpc: '2.0', + id: 999, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 999)); + expect(listText).toContain('requestId: req-header-regex'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(pages[0]?.intercept?.continuedRequestIds).toContain('req-header-regex'); + }); + + it('adds a fulfill interception rule and lists its response summary', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 961, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + method: 'GET', + action: 'fulfill', + statusCode: 202, + contentType: 'application/json', + body: '{"ok":true}', + }, + }, + }, + { + jsonrpc: '2.0', + id: 962, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 962)); + expect(listText).toContain('action: fulfill'); + expect(listText).toContain('statusCode: 202'); + expect(listText).toContain('contentType: application/json'); + }); + + it('fulfills a paused request with the configured mock response', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-fulfill-1', + url: 'https://example.com/api/mock/users', + method: 'GET', + resourceType: 'XHR', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 963, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + method: 'GET', + action: 'fulfill', + statusCode: 200, + contentType: 'application/json', + body: '{"users":[1]}', + }, + }, + }, + { + jsonrpc: '2.0', + id: 964, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 964)); + expect(listText).toContain('requestId: req-fulfill-1'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(listText).toContain('action: fulfill'); + expect(pages[0]?.intercept?.fulfilledRequests).toEqual([ + expect.objectContaining({ + requestId: 'req-fulfill-1', + responseCode: 200, + }), + ]); + }); + + it('passes custom response headers to Fetch.fulfillRequest', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-fulfill-2', + url: 'https://example.com/api/mock/headers', + method: 'GET', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 965, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock/headers', + action: 'fulfill', + statusCode: 201, + responseHeaders: [ + { name: 'Cache-Control', value: 'no-store' }, + { name: 'X-Mocked-By', value: 'ccs-browser' }, + ], + body: 'ok', + }, + }, + }, + { + jsonrpc: '2.0', + id: 966, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 966)); + expect(listText).toContain('action: fulfill'); + expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseHeaders).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Cache-Control', value: 'no-store' }), + expect.objectContaining({ name: 'X-Mocked-By', value: 'ccs-browser' }), + ]) + ); + }); + + it('allows fulfill rules with an empty response body', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 967, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/empty', + action: 'fulfill', + statusCode: 204, + contentType: 'text/plain', + body: '', + }, + }, + }, + ] + ); + + const addText = getResponseText(responses.find((message) => message.id === 967)); + expect(addText).toContain('action: fulfill'); + expect(addText).toContain('statusCode: 204'); + }); + + it('rejects fulfill rules when statusCode is out of range', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 968, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + statusCode: 99, + body: 'x', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 968); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: statusCode must be an integer between 100 and 599'); + }); + + it('rejects fulfill rules when responseHeaders is not an array', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 969, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + responseHeaders: 'x', + body: 'x', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 969); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders must be an array'); + }); + + it('rejects fulfill rules when a responseHeaders entry is missing name', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 970, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + responseHeaders: [{ value: 'x' }], + body: 'x', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 970); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders.name is required'); + }); + + it('rejects fulfill rules when body is not a string', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 971, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + body: 123, + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 971); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: body must be a string'); + }); + + it('rejects intercept rules when urlRegex is invalid', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1001, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlRegex: '[', + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 1001); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: urlRegex must be a valid regular expression'); + }); + + it('rejects intercept rules when headerMatchers.valueRegex is invalid', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1002, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: [{ name: 'x-env', valueRegex: '[' }], + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 1002); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.valueRegex must be a valid regular expression'); + }); + + it('removes fulfill rules and request summaries after the bound page is closed', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { + id: 'page-2', + title: 'Mocked', + currentUrl: 'https://example.com/mocked', + intercept: { + pausedRequests: [ + { requestId: 'req-fulfill-close', url: 'https://example.com/api/mock/close', method: 'GET' }, + ], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 972, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } } }, + { + jsonrpc: '2.0', + id: 973, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock/close', + action: 'fulfill', + statusCode: 200, + body: 'done', + }, + }, + }, + { jsonrpc: '2.0', id: 974, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, + { jsonrpc: '2.0', id: 975, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } } }, + { jsonrpc: '2.0', id: 976, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} } }, + { jsonrpc: '2.0', id: 977, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, + ], + { + responseTimeoutMs: 12000, + } + ); + + expect(getResponseText(responses.find((message) => message.id === 974))).toContain('requestId: req-fulfill-close'); + expect(getResponseText(responses.find((message) => message.id === 976))).not.toContain('pageId: page-2'); + expect(getResponseText(responses.find((message) => message.id === 977))).not.toContain('requestId: req-fulfill-close'); + }); + + it('returns only the requested number of recent requests', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { requestId: 'req-1', url: 'https://example.com/api/one', method: 'GET', resourceType: 'XHR' }, + { requestId: 'req-2', url: 'https://example.com/api/two', method: 'GET', resourceType: 'XHR' }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 953, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 954, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: { limit: 1 } }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 954)); + expect(listText).toContain('requestId: req-2'); + expect(listText).not.toContain('requestId: req-1'); + }); + + it('fails browser_add_intercept_rule when Fetch.enable fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + enableError: 'Fetch.enable blocked', + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 955, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 956, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const addResponse = responses.find((message) => message.id === 955); + expect((addResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(addResponse)).toContain('Browser MCP failed: Fetch.enable blocked'); + + const listText = getResponseText(responses.find((message) => message.id === 956)); + expect(listText).toBe('status: empty'); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-test-harness.ts b/tests/unit/hooks/browser-mcp-test-harness.ts new file mode 100644 index 00000000..fd78c81f --- /dev/null +++ b/tests/unit/hooks/browser-mcp-test-harness.ts @@ -0,0 +1,1861 @@ +// Shared harness extracted from the Browser MCP hook suite. +// Keep helpers here and split domain assertions into smaller *.test.ts files. + +import { spawn } from 'child_process'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import * as http from 'node:http'; +import { WebSocketServer } from 'ws'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type JsonRpcMessage = Record; + +type MockRect = { + x: number; + y: number; + width: number; + height: number; + top: number; + right: number; + bottom: number; + left: number; +}; + +type MockQueryState = { + exists?: boolean; + connected?: boolean; + innerText?: string; + textContent?: string; + rect?: MockRect; + display?: string; + visibility?: string; + opacity?: string; + href?: string; + onclick?: string; + error?: string; +}; + +type MockQueryPlan = MockQueryState | MockQueryState[]; + +type MockClickState = { + error?: string; + disabled?: boolean; + detached?: boolean; + hidden?: boolean; + requireMouseSequence?: boolean; + requireNativeClick?: boolean; + forbidSyntheticClickEvent?: boolean; + cancelMouseDown?: boolean; + cancelMouseUp?: boolean; + detachAfterMouseDown?: boolean; + mouseSequenceError?: string; + label?: string; + expectedOffset?: { x: number; y: number }; + expectedButton?: 'left' | 'middle' | 'right'; + expectedClickCount?: number; + requireDoubleClickEvent?: boolean; +}; + +type MockClickPlan = MockClickState | MockClickState[]; + +type MockHoverState = { + error?: string; + detached?: boolean; + hidden?: boolean; + zeroSized?: boolean; + requireCdpMouseMove?: boolean; + lastMouseMove?: { + x: number; + y: number; + }; +}; + +type MockWaitPlan = { + selectorSnapshots?: Record; + pageTextSequence?: string[]; +}; + +type MockDownloadProgressState = { + receivedBytes: number; + totalBytes: number; + state: 'inProgress' | 'completed' | 'canceled'; + filePath?: string; +}; + +type MockDownloadState = { + guid?: string; + url: string; + suggestedFilename: string; + frameId?: string; + progress?: MockDownloadProgressState[]; +}; + +type MockBrowserState = { + setDownloadBehaviorCalls?: Array<{ + behavior: string; + downloadPath?: string; + eventsEnabled?: boolean; + }>; + canceledDownloadGuids?: string[]; +}; + +type MockPageEventPlan = { + dialogs?: Array<{ type: string; message: string }>; + navigations?: Array<{ url: string; parentId?: string }>; + requests?: Array<{ url: string; method: string }>; + downloads?: MockDownloadState[]; +}; + +type MockFileInputState = { + kind: 'file' | 'nonfile'; + multiple?: boolean; + assignedFiles?: string[]; +}; + +type MockFileInputPlan = MockFileInputState | MockFileInputState[]; + +type MockDropzoneState = { + accepted?: boolean; + acceptedByCancel?: boolean; + requireFiles?: boolean; + receivedEventTypes?: string[]; + receivedFiles?: Array<{ name: string; size: number; type: string }>; + error?: string; +}; + +type MockDropzonePlan = MockDropzoneState | MockDropzoneState[]; + +type MockPointerActionRecord = { + type: string; + x?: number; + y?: number; + button?: string; +}; + +type MockDragPlan = { + recordedActions?: MockPointerActionRecord[]; +}; + +type MockRecordedEvent = + | { + kind: 'click'; + selector: string; + nth?: number; + frameSelector?: string; + pierceShadow?: boolean; + button?: 'left' | 'middle' | 'right'; + clickCount?: number; + offsetX?: number; + offsetY?: number; + timestamp?: number; + } + | { + kind: 'type'; + selector: string; + text: string; + nth?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'press_key'; + key: string; + modifiers?: string[]; + timestamp?: number; + } + | { + kind: 'scroll'; + selector?: string; + deltaX?: number; + deltaY?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'drag_element'; + selector: string; + targetSelector?: string; + targetX?: number; + targetY?: number; + nth?: number; + targetNth?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'pointer_action'; + actions: Array<{ + type: 'move' | 'down' | 'up' | 'pause'; + selector?: string; + x?: number; + y?: number; + button?: 'left' | 'middle' | 'right'; + durationMs?: number; + }>; + timestamp?: number; + }; + +type MockRecordingWarning = { + message: string; +}; + +type MockRecordingPlan = { + events?: MockRecordedEvent[]; + warnings?: MockRecordingWarning[]; + injectionError?: string; + finalizeError?: string; +}; + +type MockFrameState = { + selector: string; + query?: Record; + visibleText?: string; + fileInputs?: Record; + dropzones?: Record; +}; + +type MockShadowRootState = { + hostSelector: string; + query?: Record; + fileInputs?: Record; + dropzones?: Record; +}; + +type MockEvalPlan = Record< + string, + { + result?: unknown; + error?: string; + nonSerializable?: boolean; + unserializableValue?: string; + } +>; + +type MockInterceptRuleMatch = { + url: string; + method: string; + resourceType?: string; + requestId?: string; + requestHeaders?: Record; +}; + +type MockFulfilledRequest = { + requestId: string; + responseCode?: number; + responseHeaders?: Array<{ name: string; value: string }>; + body?: string; +}; + +type MockInterceptState = { + pausedRequests?: MockInterceptRuleMatch[]; + continuedRequestIds?: string[]; + failedRequests?: Array<{ requestId: string; errorReason?: string }>; + fulfilledRequests?: MockFulfilledRequest[]; + fetchEnabledPatterns?: unknown[]; + enableError?: string; + pauseDispatchDelayMs?: number; +}; + +type MockFrameTree = { + frame: { id: string }; + childFrames?: MockFrameTree[]; +}; + +type MockPageState = { + id: string; + title: string; + currentUrl: string; + fileInputs?: Record; + browser?: MockBrowserState; + frameTree?: MockFrameTree; + frameTreeSequence?: MockFrameTree[]; + readyStateSequence?: string[]; + visibleText?: string; + domSnapshot?: string; + navigate?: Record; + click?: Record; + hover?: Record; + query?: Record; + wait?: MockWaitPlan; + eval?: MockEvalPlan; + frames?: MockFrameState[]; + shadowRoots?: MockShadowRootState[]; + dropzones?: Record; + drag?: MockDragPlan; + recording?: MockRecordingPlan; + events?: MockPageEventPlan; + intercept?: MockInterceptState; + screenshot?: { + expectedClip?: { + x: number; + y: number; + width: number; + height: number; + scale: number; + }; + requireScrolledMeasurement?: boolean; + scrolledSelectors?: string[]; + data?: string; + lastCaptureBeyondViewport?: boolean; + lastClip?: { + x: number; + y: number; + width: number; + height: number; + scale: number; + }; + }; + type?: Record< + string, + { + kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable'; + inputType?: string; + value?: string; + expectedValueWhenClearFirst?: string; + expectedValueWhenAppend?: string; + requireFocus?: boolean; + focused?: boolean; + } + >; + keyboard?: { + expectedKey?: string; + expectedModifiers?: string[]; + expectedRepeat?: number; + _seenKeyDownCount?: number; + }; + scroll?: Record< + string, + { + expectedBehavior?: 'into-view' | 'by-offset'; + expectedDeltaX?: number; + expectedDeltaY?: number; + } + >; +}; + +const bundledServerPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); + +type RunMcpRequestsOptions = { + serverPath?: string; + childEnv?: NodeJS.ProcessEnv; + responseTimeoutMs?: number; +}; + +function encodeMessage(message: unknown): string { + return `${JSON.stringify(message)}\n`; +} + +function collectResponses( + child: ReturnType, + expectedCount: number, + timeoutMs = 7000 +): Promise>> { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + let stderrBuffer = ''; + let settled = false; + const responses: Array> = []; + const fail = (error: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + }; + const finish = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(responses); + }; + const timer = setTimeout(() => { + const details = stderrBuffer.trim(); + fail( + new Error( + details + ? `Timed out waiting for MCP responses\n${details}` + : 'Timed out waiting for MCP responses' + ) + ); + }, timeoutMs); + + function tryParse(): void { + while (true) { + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + const body = buffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + buffer = buffer.subarray(newlineIndex + 1); + if (!body) { + continue; + } + + responses.push(JSON.parse(body) as Record); + if (responses.length >= expectedCount) { + finish(); + return; + } + } + } + + if (!child.stdout) { + fail(new Error('MCP child stdout is unavailable')); + return; + } + + child.stdout.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + try { + tryParse(); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); + + child.stderr?.on('data', (chunk: Buffer | string) => { + stderrBuffer += chunk.toString(); + }); + + child.on('error', (error) => { + fail(error); + }); + + child.on('exit', (code, signal) => { + if (settled || responses.length >= expectedCount) { + return; + } + const details = stderrBuffer.trim(); + const suffix = details ? `\n${details}` : ''; + fail( + new Error( + `MCP child exited before all responses arrived (code=${code}, signal=${signal})${suffix}` + ) + ); + }); + }); +} + +function getResponseText(message: Record | undefined): string { + const result = (message?.result as { content?: Array<{ text?: string }> }) || {}; + return result.content?.[0]?.text || ''; +} + +function createReplayStep(step: Record): Record { + return step; +} + +function createOrchestrationBlock(block: Record): Record { + return block; +} + +function parseJsonArgument(expression: string, key: string): string | undefined { + const marker = `const ${key} = JSON.parse(`; + const start = expression.indexOf(marker); + if (start === -1) { + return undefined; + } + + const quoteStart = start + marker.length; + const quote = expression[quoteStart]; + if (quote !== '"' && quote !== "'") { + return undefined; + } + + let index = quoteStart + 1; + while (index < expression.length) { + if (expression[index] === '\\') { + index += 2; + continue; + } + if (expression[index] === quote) { + const encoded = expression.slice(quoteStart, index + 1); + const decoded = JSON.parse(encoded) as string; + if (!decoded.startsWith('"') && !decoded.startsWith("'")) { + return undefined; + } + return JSON.parse(decoded) as string; + } + index += 1; + } + + return undefined; +} + +function parseNumberArgument(expression: string, key: string): number | undefined { + const match = expression.match(new RegExp(`const ${key} = ([0-9]+|undefined);`)); + if (!match?.[1] || match[1] === 'undefined') { + return undefined; + } + return Number.parseInt(match[1], 10); +} + +function parseParsedJsonArrayArgument(expression: string, key: string): T[] { + const marker = `const ${key} = JSON.parse(`; + const start = expression.indexOf(marker); + if (start === -1) { + return []; + } + + const quoteStart = start + marker.length; + const quote = expression[quoteStart]; + if (quote !== '"' && quote !== "'") { + return []; + } + + let index = quoteStart + 1; + while (index < expression.length) { + if (expression[index] === '\\') { + index += 2; + continue; + } + if (expression[index] === quote) { + const encoded = expression.slice(quoteStart, index + 1); + const decoded = JSON.parse(encoded) as string; + return JSON.parse(decoded) as T[]; + } + index += 1; + } + + return []; +} + +function parseParsedJsonObjectArgument(expression: string, key: string): T | null { + const marker = `const ${key} = JSON.parse(`; + const start = expression.indexOf(marker); + if (start === -1) { + return null; + } + + const quoteStart = start + marker.length; + const quote = expression[quoteStart]; + if (quote !== '"' && quote !== "'") { + return null; + } + + let index = quoteStart + 1; + while (index < expression.length) { + if (expression[index] === '\\') { + index += 2; + continue; + } + if (expression[index] === quote) { + const encoded = expression.slice(quoteStart, index + 1); + const decoded = JSON.parse(encoded) as string; + return JSON.parse(decoded) as T; + } + index += 1; + } + + return null; +} + +function pickMockMatch( + plan: T | T[] | undefined, + nth = 0 +): { + count: number; + target: T | undefined; +} { + if (Array.isArray(plan)) { + return { count: plan.length, target: plan[nth] }; + } + return { count: plan ? 1 : 0, target: nth === 0 ? plan : undefined }; +} + +function shiftSelectorSnapshot(page: MockPageState, selector: string): MockQueryPlan | undefined { + const queue = page.wait?.selectorSnapshots?.[selector]; + if (!queue || queue.length === 0) { + return page.query?.[selector]; + } + if (queue.length === 1) { + return queue[0]; + } + return queue.shift(); +} + +function getMockFrame(page: MockPageState, frameSelector: string): MockFrameState | undefined { + return page.frames?.find((frame) => frame.selector === frameSelector); +} + +function getMockShadowRoot(page: MockPageState): MockShadowRootState | undefined { + return page.shadowRoots?.[0]; +} + +function getMockDropzoneState( + page: Pick, + selector: string, + options: { nth?: number; frameSelector?: string; pierceShadow?: boolean } = {} +): MockDropzoneState | null { + const nth = options.nth ?? 0; + if (options.frameSelector) { + const frame = page.frames?.find((entry) => entry.selector === options.frameSelector); + const plan = frame?.dropzones?.[selector]; + if (!plan) { + return null; + } + return Array.isArray(plan) ? (plan[nth] ?? null) : plan; + } + if (options.pierceShadow) { + for (const shadowRoot of page.shadowRoots || []) { + const plan = shadowRoot.dropzones?.[selector]; + if (plan) { + return Array.isArray(plan) ? (plan[nth] ?? null) : plan; + } + } + } + const plan = page.dropzones?.[selector]; + if (!plan) { + return null; + } + return Array.isArray(plan) ? (plan[nth] ?? null) : plan; +} + +function pushPointerAction(page: MockPageState, action: MockPointerActionRecord): void { + page.drag = page.drag || {}; + page.drag.recordedActions = page.drag.recordedActions || []; + page.drag.recordedActions.push(action); +} + +function getMockRecordingPlan(page: MockPageState): MockRecordingPlan { + page.recording = page.recording || {}; + return page.recording; +} + +function shiftPageText(page: MockPageState): string { + const queue = page.wait?.pageTextSequence; + if (!queue || queue.length === 0) { + return page.visibleText || ''; + } + if (queue.length === 1) { + return queue[0] || ''; + } + return queue.shift() || ''; +} + +function resolveNodeModulesPath(): string { + const candidates = [ + join(process.cwd(), 'node_modules'), + join(process.cwd(), '..', 'node_modules'), + join(process.cwd(), '..', '..', 'node_modules'), + ]; + return ( + candidates.find((candidate) => existsSync(join(candidate, 'ws'))) || + candidates.find((candidate) => existsSync(candidate)) || + candidates[0] + ); +} + +function createMockBrowser(pagesInput: MockPageState[]) { + let tempDir = ''; + let httpServer: http.Server | null = null; + let wsServer: WebSocketServer | null = null; + let browserSocketPath = ''; + const pageStates = new Map(); + let nextPageCounter = pagesInput.length + 1; + + for (const [index, page] of pagesInput.entries()) { + if (page.visibleText === undefined) { + page.visibleText = 'Hello from visible text'; + } + if (page.domSnapshot === undefined) { + page.domSnapshot = 'Hello from DOM snapshot'; + } + pageStates.set(`/devtools/page/${index + 1}`, page); + } + + async function start(options: RunMcpRequestsOptions = {}) { + const entryServerPath = options.serverPath || bundledServerPath; + const childEnv = options.childEnv || {}; + tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-')); + + const port = await new Promise((resolve, reject) => { + httpServer = http.createServer((req, res) => { + const address = httpServer?.address(); + const serverPort = address && typeof address !== 'string' ? address.port : 0; + + if (req.url === '/json/list') { + const pageEntries = Array.from(pageStates.entries()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify([ + ...pageEntries.map(([wsPath, page]) => ({ + id: page.id, + type: 'page', + title: page.title, + url: page.currentUrl, + webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, + })), + { + id: 'browser-target', + type: 'browser', + title: 'Browser', + url: '', + webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${browserSocketPath || '/devtools/browser'}`, + }, + ]) + ); + return; + } + + if (req.url?.startsWith('/json/new')) { + const parsed = new URL(req.url, `http://127.0.0.1:${serverPort}`); + const requestedUrl = parsed.searchParams.get('url') || 'about:blank'; + const wsPath = `/devtools/page/${nextPageCounter}`; + const newPage: MockPageState = { + id: `page-${nextPageCounter}`, + title: requestedUrl === 'about:blank' ? 'about:blank' : requestedUrl, + currentUrl: requestedUrl, + visibleText: 'New page visible text', + domSnapshot: 'New page', + }; + pageStates.set(wsPath, newPage); + nextPageCounter += 1; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: newPage.id, + type: 'page', + title: newPage.title, + url: newPage.currentUrl, + webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, + }) + ); + return; + } + + if (req.url?.startsWith('/json/close/')) { + const targetId = decodeURIComponent(req.url.slice('/json/close/'.length)); + const entry = Array.from(pageStates.entries()).find(([, page]) => page.id === targetId); + if (!entry) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'page not found' })); + return; + } + pageStates.delete(entry[0]); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: targetId })); + return; + } + + res.writeHead(404); + res.end('not found'); + }); + + httpServer.once('error', reject); + httpServer.listen(0, '127.0.0.1', () => { + const address = httpServer?.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to resolve mock browser server port')); + return; + } + resolve(address.port); + }); + }); + + browserSocketPath = '/devtools/browser'; + wsServer = new WebSocketServer({ server: httpServer as http.Server }); + wsServer.on('connection', (socket, request) => { + if ((request.url || '') === browserSocketPath) { + const browserState = pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {}); + + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) as { + id: number; + method: string; + params?: Record; + }; + + function reply(result: unknown): void { + socket.send(JSON.stringify({ id: message.id, result })); + } + + if (message.method === 'Browser.setDownloadBehavior') { + browserState.setDownloadBehaviorCalls = browserState.setDownloadBehaviorCalls || []; + browserState.setDownloadBehaviorCalls.push({ + behavior: typeof message.params?.behavior === 'string' ? message.params.behavior : '', + downloadPath: + typeof message.params?.downloadPath === 'string' ? message.params.downloadPath : undefined, + eventsEnabled: + typeof message.params?.eventsEnabled === 'boolean' ? message.params.eventsEnabled : undefined, + }); + reply({}); + return; + } + + if (message.method === 'Browser.cancelDownload') { + browserState.canceledDownloadGuids = browserState.canceledDownloadGuids || []; + browserState.canceledDownloadGuids.push(String(message.params?.guid || '')); + reply({}); + return; + } + }); + + for (const page of pagesInput) { + for (const [index, download] of (page.events?.downloads || []).entries()) { + const guid = download.guid || `${page.id}-download-${index + 1}`; + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Browser.downloadWillBegin', + params: { + frameId: download.frameId || `frame-${page.id}`, + guid, + url: download.url, + suggestedFilename: download.suggestedFilename, + }, + }) + ); + }, 10 + index * 40); + + for (const [progressIndex, progress] of (download.progress || []).entries()) { + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Browser.downloadProgress', + params: { + guid, + totalBytes: progress.totalBytes, + receivedBytes: progress.receivedBytes, + state: progress.state, + filePath: progress.filePath, + }, + }) + ); + }, 20 + index * 40 + progressIndex * 20); + } + } + } + return; + } + + const page = pageStates.get(request.url || ''); + if (!page) { + socket.close(); + return; + } + + const remoteObjects = new Map(); + + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) as { + id: number; + method: string; + params?: Record; + }; + + function reply(result: unknown): void { + socket.send(JSON.stringify({ id: message.id, result })); + } + + function replyError(errorText: string): void { + socket.send( + JSON.stringify({ + id: message.id, + result: { result: { subtype: 'error', description: errorText } }, + }) + ); + } + + if (message.method === 'Page.navigate') { + const targetUrl = typeof message.params?.url === 'string' ? message.params.url : ''; + const navigatePlan = page.navigate?.[targetUrl]; + if (navigatePlan?.errorText) { + reply({ frameId: 'frame-1', errorText: navigatePlan.errorText }); + return; + } + if (navigatePlan) { + page.currentUrl = navigatePlan.finalUrl; + page.readyStateSequence = [...(navigatePlan.readyStates || ['loading', 'interactive'])]; + } + reply({ frameId: 'frame-1' }); + return; + } + + if (message.method === 'Page.getFrameTree') { + const nextFrameTree = page.frameTreeSequence?.shift(); + reply({ frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } } }); + return; + } + + if (message.method === 'Page.captureScreenshot') { + if (!page.screenshot) { + reply({ data: '' }); + return; + } + page.screenshot.lastCaptureBeyondViewport = + message.params?.captureBeyondViewport === true; + const clip = + message.params?.clip && typeof message.params.clip === 'object' + ? (message.params.clip as Record) + : null; + page.screenshot.lastClip = clip + ? { + x: Number(clip.x), + y: Number(clip.y), + width: Number(clip.width), + height: Number(clip.height), + scale: Number(clip.scale), + } + : undefined; + if (page.screenshot.expectedClip) { + expect(page.screenshot.lastClip).toEqual(page.screenshot.expectedClip); + } + reply({ data: page.screenshot.data || '' }); + return; + } + + if (message.method === 'Input.dispatchMouseEvent') { + const type = typeof message.params?.type === 'string' ? message.params.type : ''; + const x = Number(message.params?.x); + const y = Number(message.params?.y); + const button = typeof message.params?.button === 'string' ? message.params.button : undefined; + for (const hoverPlan of Object.values(page.hover || {})) { + if (type === 'mouseMoved') { + hoverPlan.lastMouseMove = { x, y }; + } + } + pushPointerAction(page, { type, x, y, button }); + reply({}); + return; + } + + if (message.method === 'Input.dispatchKeyEvent') { + const type = typeof message.params?.type === 'string' ? message.params.type : ''; + const key = typeof message.params?.key === 'string' ? message.params.key : ''; + const modifiersMask = Number(message.params?.modifiers || 0); + const modifiers = [ + ...(modifiersMask & 1 ? ['Alt'] : []), + ...(modifiersMask & 2 ? ['Control'] : []), + ...(modifiersMask & 4 ? ['Meta'] : []), + ...(modifiersMask & 8 ? ['Shift'] : []), + ]; + const keyboardPlan = page.keyboard; + if (type === 'keyDown') { + if (keyboardPlan?.expectedKey && key !== keyboardPlan.expectedKey) { + replyError(`unexpected key: ${key}`); + return; + } + if ( + keyboardPlan?.expectedModifiers && + JSON.stringify(modifiers) !== JSON.stringify(keyboardPlan.expectedModifiers) + ) { + replyError(`unexpected modifiers for key: ${key}`); + return; + } + if (keyboardPlan) { + keyboardPlan._seenKeyDownCount = (keyboardPlan._seenKeyDownCount || 0) + 1; + } + } + if ( + type === 'keyUp' && + typeof keyboardPlan?.expectedRepeat === 'number' && + (keyboardPlan._seenKeyDownCount || 0) !== keyboardPlan.expectedRepeat + ) { + replyError(`unexpected repeat for key: ${key}`); + return; + } + reply({}); + return; + } + + if (message.method === 'Page.enable' || message.method === 'Network.enable') { + reply({}); + if (message.method === 'Page.enable') { + const dialog = page.events?.dialogs?.[0]; + const navigations = page.events?.navigations || []; + if (dialog) { + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Page.javascriptDialogOpening', + params: { message: dialog.message, type: dialog.type }, + }) + ); + }, 10); + } + navigations.forEach((navigation, index) => { + setTimeout( + () => { + socket.send( + JSON.stringify({ + method: 'Page.frameNavigated', + params: { frame: { url: navigation.url, parentId: navigation.parentId } }, + }) + ); + }, + 10 + index * 10 + ); + }); + } + if (message.method === 'Network.enable') { + const requestPlan = page.events?.requests?.[0]; + if (requestPlan) { + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Network.requestWillBeSent', + params: { request: { url: requestPlan.url, method: requestPlan.method } }, + }) + ); + }, 10); + } + } + return; + } + + if (message.method === 'Fetch.enable') { + page.intercept = page.intercept || {}; + page.intercept.fetchEnabledPatterns = Array.isArray(message.params?.patterns) + ? (message.params?.patterns as unknown[]) + : []; + if (page.intercept.enableError) { + socket.send(JSON.stringify({ id: message.id, error: { message: page.intercept.enableError } })); + return; + } + reply({}); + const pauseDispatchDelayMs = page.intercept.pauseDispatchDelayMs ?? 10; + for (const [index, paused] of (page.intercept.pausedRequests || []).entries()) { + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Fetch.requestPaused', + params: { + requestId: paused.requestId || `fetch-${index + 1}`, + resourceType: paused.resourceType || 'XHR', + request: { + url: paused.url, + method: paused.method, + headers: paused.requestHeaders || {}, + }, + }, + }) + ); + }, pauseDispatchDelayMs + index * 10); + } + return; + } + + if (message.method === 'Fetch.continueRequest') { + page.intercept = page.intercept || {}; + page.intercept.continuedRequestIds = page.intercept.continuedRequestIds || []; + page.intercept.continuedRequestIds.push(String(message.params?.requestId || '')); + reply({}); + return; + } + + if (message.method === 'Fetch.failRequest') { + page.intercept = page.intercept || {}; + page.intercept.failedRequests = page.intercept.failedRequests || []; + page.intercept.failedRequests.push({ + requestId: String(message.params?.requestId || ''), + errorReason: typeof message.params?.errorReason === 'string' ? message.params.errorReason : '', + }); + reply({}); + return; + } + + if (message.method === 'Fetch.fulfillRequest') { + page.intercept = page.intercept || {}; + page.intercept.fulfilledRequests = page.intercept.fulfilledRequests || []; + page.intercept.fulfilledRequests.push({ + requestId: String(message.params?.requestId || ''), + responseCode: + typeof message.params?.responseCode === 'number' ? message.params.responseCode : undefined, + responseHeaders: Array.isArray(message.params?.responseHeaders) + ? (message.params.responseHeaders as Array<{ name: string; value: string }>) + : [], + body: typeof message.params?.body === 'string' ? message.params.body : '', + }); + reply({}); + return; + } + + if (message.method === 'DOM.setFileInputFiles') { + const objectId = typeof message.params?.objectId === 'string' ? message.params.objectId : ''; + const target = remoteObjects.get(objectId); + if (!target) { + socket.send(JSON.stringify({ id: message.id, error: { message: 'file input handle not found' } })); + return; + } + target.assignedFiles = Array.isArray(message.params?.files) + ? (message.params.files as unknown[]).map((entry) => String(entry)) + : []; + reply({}); + return; + } + + if (message.method !== 'Runtime.evaluate') { + return; + } + + const expression = String(message.params?.expression || ''); + const recordingPayload = parseParsedJsonObjectArgument<{ + events?: MockRecordedEvent[]; + warnings?: MockRecordingWarning[]; + }>(expression, 'recordingPayload'); + + if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ =')) { + const plan = getMockRecordingPlan(page); + if (plan.injectionError) { + reply({ + result: { + type: 'object', + subtype: 'error', + description: plan.injectionError, + }, + }); + return; + } + + if (recordingPayload && (recordingPayload.events?.length || recordingPayload.warnings?.length)) { + plan.events = recordingPayload.events || []; + plan.warnings = recordingPayload.warnings || []; + } + + reply({ result: { type: 'object', value: { installed: true } } }); + return; + } + + if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) { + const plan = getMockRecordingPlan(page); + if (plan.finalizeError) { + socket.send( + JSON.stringify({ + id: message.id, + result: { exceptionDetails: { text: plan.finalizeError } }, + }) + ); + return; + } + reply({ + result: { + type: 'object', + value: { + events: plan.events || [], + warnings: plan.warnings || [], + }, + }, + }); + return; + } + + if (expression.includes('new DragEvent') && expression.includes('new DataTransfer()')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth') ?? 0; + const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; + const pierceShadow = expression.includes('const pierceShadow = true'); + const filePayloads = parseParsedJsonArrayArgument<{ + name: string; + size: number; + mimeType: string; + }>(expression, 'filePayloads'); + const eventTypes = Array.from( + expression.matchAll(/new DragEvent\('(dragenter|dragover|drop)'/g) + ).map((match) => match[1]); + + const target = getMockDropzoneState(page, selector, { + nth, + frameSelector, + pierceShadow, + }); + if (!target) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (target.error) { + replyError(target.error); + return; + } + if (eventTypes.length === 0) { + replyError(`drag event sequence not found for selector: ${selector}`); + return; + } + if (target.requireFiles && filePayloads.length === 0) { + reply({ result: { type: 'object', value: { accepted: false } } }); + return; + } + target.receivedEventTypes = eventTypes; + target.receivedFiles = filePayloads.map((file) => ({ + name: file.name, + size: file.size, + type: file.mimeType, + })); + reply({ + result: { + type: 'object', + value: { + accepted: target.acceptedByCancel === true ? true : target.accepted !== false, + }, + }, + }); + return; + } + + if (page.eval?.[expression]) { + const evalPlan = page.eval[expression]; + if (evalPlan.error) { + socket.send( + JSON.stringify({ + id: message.id, + result: { exceptionDetails: { text: evalPlan.error } }, + }) + ); + return; + } + if (evalPlan.nonSerializable) { + socket.send(JSON.stringify({ id: message.id, result: { result: { type: 'object' } } })); + return; + } + if (typeof evalPlan.unserializableValue === 'string') { + socket.send( + JSON.stringify({ + id: message.id, + result: { + result: { + type: 'number', + unserializableValue: evalPlan.unserializableValue, + }, + }, + }) + ); + return; + } + socket.send( + JSON.stringify({ + id: message.id, + result: { result: { type: 'object', value: evalPlan.result } }, + }) + ); + return; + } + + if (expression === '0') { + reply({ result: { type: 'number', value: 0 } }); + return; + } + + if (expression.includes('element is not a file input for selector:')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth') ?? 0; + const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; + const pierceShadow = expression.includes('const pierceShadow = true'); + + const fileInputPlan = frameSelector + ? getMockFrame(page, frameSelector)?.fileInputs?.[selector] + : pierceShadow + ? getMockShadowRoot(page)?.fileInputs?.[selector] + : page.fileInputs?.[selector]; + + const { count, target } = pickMockMatch(fileInputPlan, nth); + if (!target || count <= nth) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (target.kind !== 'file') { + replyError(`element is not a file input for selector: ${selector}`); + return; + } + + const objectId = `file-input:${page.id}:${selector}:${nth}:${frameSelector || 'root'}:${pierceShadow ? 'shadow' : 'light'}`; + remoteObjects.set(objectId, target); + socket.send( + JSON.stringify({ + id: message.id, + result: { + result: { + type: 'object', + subtype: 'node', + className: 'HTMLInputElement', + objectId, + }, + }, + }) + ); + return; + } + + if (expression.includes('document.title') && expression.includes('location.href')) { + reply({ + result: { + type: 'string', + value: JSON.stringify({ title: page.title, url: page.currentUrl }), + }, + }); + return; + } + + if (expression.includes('document.body ? document.body.innerText')) { + reply({ result: { type: 'string', value: shiftPageText(page) } }); + return; + } + + if (expression.includes('document.documentElement ? document.documentElement.outerHTML')) { + reply({ result: { type: 'string', value: page.domSnapshot || '' } }); + return; + } + + if (expression.includes('document.readyState') && expression.includes('location.href')) { + const readyState = page.readyStateSequence?.shift() || 'complete'; + reply({ + result: { + type: 'string', + value: JSON.stringify({ href: page.currentUrl, readyState }), + }, + }); + return; + } + + if (expression.includes('scrollIntoView') && expression.includes('resolvedOffsetX')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth') ?? 0; + const clickPlan = page.click?.[selector]; + const { count, target: resolvedClickPlan } = pickMockMatch(clickPlan, nth); + const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'"); + const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'"); + const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp; + const attemptedClickEvent = expression.includes("dispatchMouseEvent('click'"); + const attemptedDoubleClickEvent = expression.includes("new MouseEvent('dblclick'"); + const readsDispatchResult = expression.includes('const dispatchResult = {'); + const gatesNativeClickOnDispatchResult = expression.includes( + 'if (!dispatchResult.shouldActivate)' + ); + const checksIsConnectedBeforeNativeClick = expression.includes( + 'if (!element.isConnected)' + ); + const catchIndex = expression.indexOf('catch (mouseError) {'); + const catchBlockEnd = catchIndex === -1 ? -1 : expression.indexOf('\n }', catchIndex); + const nativeClickIndexes = Array.from(expression.matchAll(/element\.click\(\)/g)).map( + (match) => match.index ?? -1 + ); + const attemptedFallbackClick = nativeClickIndexes.some( + (index) => + catchIndex !== -1 && + catchBlockEnd !== -1 && + index > catchIndex && + index < catchBlockEnd + ); + const attemptedNativeClickOutsideCatch = nativeClickIndexes.some( + (index) => + catchIndex === -1 || + catchBlockEnd === -1 || + index < catchIndex || + index > catchBlockEnd + ); + const offsetX = parseNumberArgument(expression, 'offsetX'); + const offsetY = parseNumberArgument(expression, 'offsetY'); + const button = parseJsonArgument(expression, 'button') || 'left'; + const clickCount = parseNumberArgument(expression, 'clickCount') ?? 1; + if (!resolvedClickPlan) { + replyError(`element index ${nth} is out of range for selector: ${selector}`); + return; + } + if (count <= nth) { + replyError(`element index ${nth} is out of range for selector: ${selector}`); + return; + } + if (resolvedClickPlan.detached && expression.includes('element.isConnected')) { + replyError(`element is detached for selector: ${selector}`); + return; + } + if (resolvedClickPlan.disabled) { + replyError(`element is disabled for selector: ${selector}`); + return; + } + if (resolvedClickPlan.hidden && expression.includes('getBoundingClientRect')) { + replyError(`element is hidden or not interactable for selector: ${selector}`); + return; + } + if (resolvedClickPlan.requireMouseSequence && !attemptedMouseSequence) { + replyError(`mousedown/mouseup required for selector: ${selector}`); + return; + } + if (resolvedClickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { + replyError(`synthetic click event forbidden for selector: ${selector}`); + return; + } + if ( + (resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && + !readsDispatchResult + ) { + replyError(`dispatch result must be checked for selector: ${selector}`); + return; + } + if ( + (resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && + !gatesNativeClickOnDispatchResult + ) { + replyError(`native click must be gated for selector: ${selector}`); + return; + } + if (resolvedClickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { + replyError(`connected state must be rechecked for selector: ${selector}`); + return; + } + if (resolvedClickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { + replyError(`native click required for selector: ${selector}`); + return; + } + if ( + resolvedClickPlan.expectedOffset && + (offsetX !== resolvedClickPlan.expectedOffset.x || offsetY !== resolvedClickPlan.expectedOffset.y) + ) { + replyError(`unexpected click offset for selector: ${selector}`); + return; + } + if (resolvedClickPlan.expectedButton && button !== resolvedClickPlan.expectedButton) { + replyError(`unexpected click button for selector: ${selector}`); + return; + } + if ( + typeof resolvedClickPlan.expectedClickCount === 'number' && + clickCount !== resolvedClickPlan.expectedClickCount + ) { + replyError(`unexpected click count for selector: ${selector}`); + return; + } + if ( + resolvedClickPlan.requireDoubleClickEvent && + clickCount === 2 && + !attemptedDoubleClickEvent + ) { + replyError(`dblclick event required for selector: ${selector}`); + return; + } + if (resolvedClickPlan.mouseSequenceError) { + if (!attemptedMouseSequence) { + replyError(`mousedown/mouseup required for selector: ${selector}`); + return; + } + if (attemptedFallbackClick || attemptedNativeClickOutsideCatch) { + reply({ + result: { + type: 'string', + value: JSON.stringify({ + resolvedOffsetX: offsetX ?? 50, + resolvedOffsetY: offsetY ?? 10, + button, + clickCount, + }), + }, + }); + return; + } + replyError(resolvedClickPlan.mouseSequenceError); + return; + } + if (resolvedClickPlan.error) { + replyError(resolvedClickPlan.error); + return; + } + reply({ + result: { + type: 'string', + value: JSON.stringify({ + resolvedOffsetX: offsetX ?? 50, + resolvedOffsetY: offsetY ?? 10, + button, + clickCount, + }), + }, + }); + return; + } + + if ( + expression.includes('getComputedStyle(element)') && + (expression.includes('boundingClientRect') || + expression.includes('visibleClip') || + expression.includes('centerPoint') || + expression.includes('querySelectorAll(selector)')) + ) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth'); + const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; + const pierceShadow = expression.includes('const pierceShadow = true'); + const frame = frameSelector ? getMockFrame(page, frameSelector) : undefined; + const shadowRoot = pierceShadow ? getMockShadowRoot(page) : undefined; + const scopedQuery = frame?.query?.[selector] ?? shadowRoot?.query?.[selector]; + const frameRootPlan = frame?.query?.[frameSelector]; + const frameRect = !Array.isArray(frameRootPlan) ? frameRootPlan?.rect : undefined; + const applyFrameOffset = (rect?: MockRect): MockRect | undefined => { + if (!rect || !frameRect) { + return rect; + } + return { + x: rect.x + frameRect.left, + y: rect.y + frameRect.top, + width: rect.width, + height: rect.height, + top: rect.top + frameRect.top, + right: rect.right + frameRect.left, + bottom: rect.bottom + frameRect.top, + left: rect.left + frameRect.left, + }; + }; + const queryPlan = scopedQuery ?? shiftSelectorSnapshot(page, selector); + if ( + page.screenshot?.requireScrolledMeasurement && + (selector in (page.query || {}) || Boolean(scopedQuery)) && + !expression.includes('scrollIntoView') + ) { + replyError(`scrollIntoView required for selector: ${selector}`); + return; + } + if (Array.isArray(queryPlan) && expression.includes('querySelectorAll(selector)')) { + const targetIndex = nth ?? 0; + const target = queryPlan[targetIndex]; + if (target?.error) { + replyError(target.error); + return; + } + if (queryPlan.length <= targetIndex || target?.exists === false || !target) { + reply({ + result: { + type: 'string', + value: JSON.stringify({ + exists: + nth === undefined ? queryPlan.length > 0 : queryPlan.length > targetIndex, + count: queryPlan.length, + targetIndex, + targetMissing: true, + }), + }, + }); + return; + } + const rect = applyFrameOffset(target.rect); + const text = target.innerText || target.textContent || ''; + reply({ + result: { + type: 'string', + value: JSON.stringify({ + exists: true, + count: queryPlan.length, + targetIndex, + connected: target.connected !== false, + text, + innerText: target.innerText || '', + textContent: target.textContent || '', + boundingClientRect: rect, + display: target.display || 'block', + visibility: target.visibility || 'visible', + opacity: target.opacity || '1', + href: target.href || '', + onclick: target.onclick || '', + interactable: + target.connected !== false && + (target.display || 'block') !== 'none' && + (target.visibility || 'visible') !== 'hidden' && + Boolean(rect && rect.width > 0 && rect.height > 0), + }), + }, + }); + return; + } + const resolvedQueryPlan = Array.isArray(queryPlan) ? queryPlan.shift() : queryPlan; + if (resolvedQueryPlan?.error) { + replyError(resolvedQueryPlan.error); + return; + } + if (resolvedQueryPlan?.exists === false || !resolvedQueryPlan) { + reply({ result: { type: 'string', value: JSON.stringify({ exists: false }) } }); + return; + } + const rect = applyFrameOffset(resolvedQueryPlan.rect); + const text = resolvedQueryPlan.innerText || resolvedQueryPlan.textContent || ''; + const viewportWidth = 1280; + const viewportHeight = 720; + const clipX = Math.max(0, rect?.left ?? 0); + const clipY = Math.max(0, rect?.top ?? 0); + const clipRight = Math.min(viewportWidth, rect?.right ?? 0); + const clipBottom = Math.min(viewportHeight, rect?.bottom ?? 0); + reply({ + result: { + type: 'string', + value: JSON.stringify({ + exists: true, + connected: resolvedQueryPlan.connected !== false, + text, + innerText: resolvedQueryPlan.innerText || '', + textContent: resolvedQueryPlan.textContent || '', + boundingClientRect: rect, + display: resolvedQueryPlan.display || 'block', + visibility: resolvedQueryPlan.visibility || 'visible', + opacity: resolvedQueryPlan.opacity || '1', + interactable: + resolvedQueryPlan.connected !== false && + (resolvedQueryPlan.display || 'block') !== 'none' && + (resolvedQueryPlan.visibility || 'visible') !== 'hidden' && + Boolean(rect && rect.width > 0 && rect.height > 0), + centerPoint: rect + ? { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + } + : undefined, + visibleClip: rect + ? { + x: clipX, + y: clipY, + width: Math.max(0, clipRight - clipX), + height: Math.max(0, clipBottom - clipY), + scale: 1, + } + : undefined, + }), + }, + }); + return; + } + + if ( + expression.includes("dispatch('mouseover')") && + expression.includes("dispatch('mouseenter')") && + expression.includes("dispatch('mousemove')") + ) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const hoverPlan = page.hover?.[selector]; + if (!hoverPlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (hoverPlan.detached) { + replyError(`element is detached for selector: ${selector}`); + return; + } + if (hoverPlan.hidden || hoverPlan.zeroSized) { + replyError(`element is hidden or not interactable for selector: ${selector}`); + return; + } + if (hoverPlan.requireCdpMouseMove && !hoverPlan.lastMouseMove) { + replyError(`real mouse movement required for selector: ${selector}`); + return; + } + if (hoverPlan.error) { + replyError(hoverPlan.error); + return; + } + reply({ result: { type: 'string', value: 'ok' } }); + return; + } + + if (expression.includes('focusTarget') && expression.includes('typedLength')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const text = parseJsonArgument(expression, 'text') ?? ''; + const clearFirst = expression.includes('const clearFirst = true'); + const typePlan = page.type?.[selector]; + if (!typePlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (typePlan.kind === 'unsupported') { + replyError(`element is not text-editable for selector: ${selector}`); + return; + } + if (typePlan.kind === 'noneditable') { + replyError(`element is not text-editable for selector: ${selector}`); + return; + } + if (typePlan.requireFocus && !expression.includes('focusTarget(element)')) { + replyError(`focus was not requested for selector: ${selector}`); + return; + } + + const currentValue = typePlan.value || ''; + const expectedValue = clearFirst + ? (typePlan.expectedValueWhenClearFirst ?? text) + : (typePlan.expectedValueWhenAppend ?? `${currentValue}${text}`); + + typePlan.focused = true; + typePlan.value = expectedValue; + reply({ + result: { + type: 'string', + value: JSON.stringify({ + value: expectedValue, + typedLength: expectedValue.length, + }), + }, + }); + return; + } + + if (expression.includes('new KeyboardEvent(')) { + const key = parseJsonArgument(expression, 'key') || ''; + const repeat = parseNumberArgument(expression, 'repeat') ?? 1; + const modifiersMatch = expression.match(/const modifiers = (\[[^\n;]*\]);/); + const modifiers = modifiersMatch ? (JSON.parse(modifiersMatch[1]) as string[]) : []; + const keyboardPlan = page.keyboard; + if (keyboardPlan?.expectedKey && key !== keyboardPlan.expectedKey) { + replyError(`unexpected key: ${key}`); + return; + } + if ( + keyboardPlan?.expectedModifiers && + JSON.stringify(modifiers) !== JSON.stringify(keyboardPlan.expectedModifiers) + ) { + replyError(`unexpected modifiers for key: ${key}`); + return; + } + if ( + typeof keyboardPlan?.expectedRepeat === 'number' && + repeat !== keyboardPlan.expectedRepeat + ) { + replyError(`unexpected repeat for key: ${key}`); + return; + } + reply({ + result: { + type: 'string', + value: JSON.stringify({ key, modifiers, repeat }), + }, + }); + return; + } + + if (expression.includes('window.scrollBy(deltaX, deltaY)') || expression.includes('element.scrollBy(deltaX, deltaY)')) { + const selector = parseJsonArgument(expression, 'selector'); + const behavior = parseJsonArgument(expression, 'behavior') || ''; + const deltaX = Number(expression.match(/const deltaX = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0); + const deltaY = Number(expression.match(/const deltaY = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0); + const scrollPlan = selector ? page.scroll?.[selector] : undefined; + if (selector && !scrollPlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (scrollPlan?.expectedBehavior && behavior !== scrollPlan.expectedBehavior) { + replyError(`unexpected scroll behavior for selector: ${selector}`); + return; + } + if ( + typeof scrollPlan?.expectedDeltaX === 'number' && + deltaX !== scrollPlan.expectedDeltaX + ) { + replyError(`unexpected deltaX for selector: ${selector}`); + return; + } + if ( + typeof scrollPlan?.expectedDeltaY === 'number' && + deltaY !== scrollPlan.expectedDeltaY + ) { + replyError(`unexpected deltaY for selector: ${selector}`); + return; + } + reply({ + result: { + type: 'string', + value: JSON.stringify( + selector + ? { scope: 'element', selector, behavior, deltaX, deltaY } + : { scope: 'page', behavior, deltaX, deltaY } + ), + }, + }); + return; + } + }); + }); + + const child = spawn('node', [entryServerPath], { + cwd: tempDir, + env: { + ...process.env, + ...childEnv, + CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + return child; + } + + async function stop() { + await new Promise((resolve) => { + wsServer?.close(() => resolve()); + if (!wsServer) resolve(); + }); + wsServer = null; + + await new Promise((resolve) => { + httpServer?.close(() => resolve()); + if (!httpServer) resolve(); + }); + httpServer = null; + + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + } + + return { start, stop }; +} + +async function runMcpRequests( + pages: MockPageState[], + requests: JsonRpcMessage[], + options: RunMcpRequestsOptions = {} +) { + const browser = createMockBrowser(pages); + const child = await browser.start(options); + + try { + const responsesPromise = collectResponses( + child, + requests.length + 1, + options.responseTimeoutMs + ); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + + for (const request of requests) { + child.stdin.write(encodeMessage(request)); + } + child.stdin.end(); + + return await responsesPromise; + } finally { + child.kill(); + await browser.stop(); + } +} + + +export { + bundledServerPath, + runMcpRequests, + getResponseText, + createReplayStep, + createOrchestrationBlock, + getMockDropzoneState, + getMockFrame, + getMockShadowRoot, + getMockRecordingPlan, + resolveNodeModulesPath, + cpSync, + rmSync, + mkdtempSync, + readFileSync, + writeFileSync, + tmpdir, + join, +}; +export type { MockPageState }; diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts deleted file mode 100644 index 6cab5f48..00000000 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ /dev/null @@ -1,1179 +0,0 @@ -import { afterEach, describe, expect, it } from 'bun:test'; -import { spawn } from 'child_process'; -import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import * as http from 'node:http'; -import { WebSocketServer } from 'ws'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -type JsonRpcMessage = Record; - -type MockPageState = { - id: string; - title: string; - currentUrl: string; - readyStateSequence?: string[]; - visibleText?: string; - domSnapshot?: string; - navigate?: Record; - click?: Record< - string, - { - error?: string; - disabled?: boolean; - detached?: boolean; - hidden?: boolean; - requireMouseSequence?: boolean; - requireNativeClick?: boolean; - forbidSyntheticClickEvent?: boolean; - cancelMouseDown?: boolean; - cancelMouseUp?: boolean; - detachAfterMouseDown?: boolean; - mouseSequenceError?: string; - } - >; - screenshot?: { - data?: string; - lastCaptureBeyondViewport?: boolean; - }; - type?: Record< - string, - { - kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable'; - inputType?: string; - value?: string; - expectedValueWhenClearFirst?: string; - expectedValueWhenAppend?: string; - requireFocus?: boolean; - focused?: boolean; - } - >; -}; - -const bundledServerPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); - -type RunMcpRequestsOptions = { - serverPath?: string; - childEnv?: NodeJS.ProcessEnv; -}; - -function encodeMessage(message: unknown): string { - return `${JSON.stringify(message)}\n`; -} - -function collectResponses( - child: ReturnType, - expectedCount: number -): Promise>> { - return new Promise((resolve, reject) => { - let buffer = Buffer.alloc(0); - const responses: Array> = []; - const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 7000); - - function tryParse(): void { - while (true) { - const newlineIndex = buffer.indexOf('\n'); - if (newlineIndex === -1) { - return; - } - - const body = buffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); - buffer = buffer.subarray(newlineIndex + 1); - if (!body) { - continue; - } - - responses.push(JSON.parse(body) as Record); - if (responses.length >= expectedCount) { - clearTimeout(timer); - resolve(responses); - return; - } - } - } - - if (!child.stdout) { - clearTimeout(timer); - reject(new Error('MCP child stdout is unavailable')); - return; - } - - child.stdout.on('data', (chunk: Buffer) => { - buffer = Buffer.concat([buffer, chunk]); - try { - tryParse(); - } catch (error) { - clearTimeout(timer); - reject(error); - } - }); - - child.on('error', (error) => { - clearTimeout(timer); - reject(error); - }); - }); -} - -function getResponseText(message: Record | undefined): string { - const result = (message?.result as { content?: Array<{ text?: string }> }) || {}; - return result.content?.[0]?.text || ''; -} - -function parseJsonArgument(expression: string, key: string): string | undefined { - const marker = `const ${key} = JSON.parse(`; - const start = expression.indexOf(marker); - if (start === -1) { - return undefined; - } - - const quoteStart = start + marker.length; - const quote = expression[quoteStart]; - if (quote !== '"' && quote !== "'") { - return undefined; - } - - let index = quoteStart + 1; - while (index < expression.length) { - if (expression[index] === '\\') { - index += 2; - continue; - } - if (expression[index] === quote) { - const encoded = expression.slice(quoteStart, index + 1); - const decoded = JSON.parse(encoded) as string; - if (!decoded.startsWith('"') && !decoded.startsWith("'")) { - return undefined; - } - return JSON.parse(decoded) as string; - } - index += 1; - } - - return undefined; -} - -function createMockBrowser(pagesInput: MockPageState[]) { - let tempDir = ''; - let httpServer: http.Server | null = null; - let wsServer: WebSocketServer | null = null; - const pageStates = new Map(); - - for (const [index, page] of pagesInput.entries()) { - pageStates.set(`/devtools/page/${index + 1}`, { - visibleText: 'Hello from visible text', - domSnapshot: 'Hello from DOM snapshot', - ...page, - }); - } - - async function start(options: RunMcpRequestsOptions = {}) { - const entryServerPath = options.serverPath || bundledServerPath; - const childEnv = options.childEnv || {}; - tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-')); - - const port = await new Promise((resolve, reject) => { - httpServer = http.createServer((req, res) => { - if (req.url === '/json/list') { - const address = httpServer?.address(); - const serverPort = address && typeof address !== 'string' ? address.port : 0; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify( - Array.from(pageStates.entries()).map(([wsPath, page]) => ({ - id: page.id, - type: 'page', - title: page.title, - url: page.currentUrl, - webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, - })) - ) - ); - return; - } - res.writeHead(404); - res.end('not found'); - }); - - httpServer.once('error', reject); - httpServer.listen(0, '127.0.0.1', () => { - const address = httpServer?.address(); - if (!address || typeof address === 'string') { - reject(new Error('Failed to resolve mock browser server port')); - return; - } - resolve(address.port); - }); - }); - - wsServer = new WebSocketServer({ server: httpServer as http.Server }); - wsServer.on('connection', (socket, request) => { - const page = pageStates.get(request.url || ''); - if (!page) { - socket.close(); - return; - } - - socket.on('message', (raw) => { - const message = JSON.parse(raw.toString()) as { - id: number; - method: string; - params?: Record; - }; - - function reply(result: unknown): void { - socket.send(JSON.stringify({ id: message.id, result })); - } - - function replyError(errorText: string): void { - socket.send(JSON.stringify({ id: message.id, result: { result: { subtype: 'error', description: errorText } } })); - } - - if (message.method === 'Page.navigate') { - const targetUrl = typeof message.params?.url === 'string' ? message.params.url : ''; - const navigatePlan = page.navigate?.[targetUrl]; - if (navigatePlan?.errorText) { - reply({ frameId: 'frame-1', errorText: navigatePlan.errorText }); - return; - } - if (navigatePlan) { - page.currentUrl = navigatePlan.finalUrl; - page.readyStateSequence = [...(navigatePlan.readyStates || ['loading', 'interactive'])]; - } - reply({ frameId: 'frame-1' }); - return; - } - - if (message.method === 'Page.captureScreenshot') { - if (!page.screenshot) { - reply({ data: '' }); - return; - } - page.screenshot.lastCaptureBeyondViewport = message.params?.captureBeyondViewport === true; - reply({ data: page.screenshot.data || '' }); - return; - } - - if (message.method !== 'Runtime.evaluate') { - return; - } - - const expression = String(message.params?.expression || ''); - - if (expression.includes('document.title') && expression.includes('location.href')) { - reply({ result: { type: 'string', value: JSON.stringify({ title: page.title, url: page.currentUrl }) } }); - return; - } - - if (expression.includes('document.body ? document.body.innerText')) { - reply({ result: { type: 'string', value: page.visibleText || '' } }); - return; - } - - if (expression.includes('document.documentElement ? document.documentElement.outerHTML')) { - reply({ result: { type: 'string', value: page.domSnapshot || '' } }); - return; - } - - if (expression.includes('document.readyState') && expression.includes('location.href')) { - const readyState = page.readyStateSequence?.shift() || 'complete'; - reply({ - result: { - type: 'string', - value: JSON.stringify({ href: page.currentUrl, readyState }), - }, - }); - return; - } - - if (expression.includes('scrollIntoView') && expression.includes('.click()')) { - const selector = parseJsonArgument(expression, 'selector') || ''; - const clickPlan = page.click?.[selector]; - const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'"); - const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'"); - const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp; - const attemptedClickEvent = expression.includes("dispatchMouseEvent('click'"); - const readsDispatchResult = expression.includes('const dispatchResult = {'); - const gatesNativeClickOnDispatchResult = expression.includes('if (!dispatchResult.shouldActivate)'); - const checksIsConnectedBeforeNativeClick = expression.includes('if (!element.isConnected)'); - const catchIndex = expression.indexOf('catch (mouseError) {'); - const catchBlockEnd = catchIndex === -1 ? -1 : expression.indexOf('\n }', catchIndex); - const nativeClickIndexes = Array.from(expression.matchAll(/element\.click\(\)/g)).map( - (match) => match.index ?? -1 - ); - const attemptedFallbackClick = nativeClickIndexes.some( - (index) => catchIndex !== -1 && catchBlockEnd !== -1 && index > catchIndex && index < catchBlockEnd - ); - const attemptedNativeClickOutsideCatch = nativeClickIndexes.some( - (index) => - catchIndex === -1 || catchBlockEnd === -1 || index < catchIndex || index > catchBlockEnd - ); - if (!clickPlan) { - replyError(`element not found for selector: ${selector}`); - return; - } - if (clickPlan.detached && expression.includes('element.isConnected')) { - replyError(`element is detached for selector: ${selector}`); - return; - } - if (clickPlan.disabled) { - replyError(`element is disabled for selector: ${selector}`); - return; - } - if (clickPlan.hidden && expression.includes('getBoundingClientRect')) { - replyError(`element is hidden or not interactable for selector: ${selector}`); - return; - } - if (clickPlan.requireMouseSequence && !attemptedMouseSequence) { - replyError(`mousedown/mouseup required for selector: ${selector}`); - return; - } - if (clickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { - replyError(`synthetic click event forbidden for selector: ${selector}`); - return; - } - if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !readsDispatchResult) { - replyError(`dispatch result must be checked for selector: ${selector}`); - return; - } - if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) { - replyError(`native click must be gated for selector: ${selector}`); - return; - } - if (clickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { - replyError(`connected state must be rechecked for selector: ${selector}`); - return; - } - if (clickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { - replyError(`native click required for selector: ${selector}`); - return; - } - if (clickPlan.mouseSequenceError) { - if (!attemptedMouseSequence) { - replyError(`mousedown/mouseup required for selector: ${selector}`); - return; - } - if (attemptedFallbackClick || attemptedNativeClickOutsideCatch) { - reply({ result: { type: 'string', value: 'ok' } }); - return; - } - replyError(clickPlan.mouseSequenceError); - return; - } - if (clickPlan.error) { - replyError(clickPlan.error); - return; - } - reply({ result: { type: 'string', value: 'ok' } }); - return; - } - - if (expression.includes('focusTarget') && expression.includes('typedLength')) { - const selector = parseJsonArgument(expression, 'selector') || ''; - const text = parseJsonArgument(expression, 'text') ?? ''; - const clearFirst = expression.includes('const clearFirst = true'); - const typePlan = page.type?.[selector]; - if (!typePlan) { - replyError(`element not found for selector: ${selector}`); - return; - } - if (typePlan.kind === 'unsupported') { - replyError(`element is not text-editable for selector: ${selector}`); - return; - } - if (typePlan.kind === 'noneditable') { - replyError(`element is not text-editable for selector: ${selector}`); - return; - } - if (typePlan.requireFocus && !expression.includes('focusTarget(element)')) { - replyError(`focus was not requested for selector: ${selector}`); - return; - } - - const currentValue = typePlan.value || ''; - const expectedValue = clearFirst - ? (typePlan.expectedValueWhenClearFirst ?? text) - : (typePlan.expectedValueWhenAppend ?? `${currentValue}${text}`); - - typePlan.focused = true; - typePlan.value = expectedValue; - reply({ - result: { - type: 'string', - value: JSON.stringify({ - value: expectedValue, - typedLength: expectedValue.length, - }), - }, - }); - return; - } - }); - }); - - const child = spawn('node', [entryServerPath], { - cwd: tempDir, - env: { - ...process.env, - ...childEnv, - CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - return child; - } - - async function stop() { - await new Promise((resolve) => { - wsServer?.close(() => resolve()); - if (!wsServer) resolve(); - }); - wsServer = null; - - await new Promise((resolve) => { - httpServer?.close(() => resolve()); - if (!httpServer) resolve(); - }); - httpServer = null; - - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); - tempDir = ''; - } - } - - return { start, stop }; -} - -async function runMcpRequests( - pages: MockPageState[], - requests: JsonRpcMessage[], - options: RunMcpRequestsOptions = {} -) { - const browser = createMockBrowser(pages); - const child = await browser.start(options); - - try { - const responsesPromise = collectResponses(child, requests.length + 1); - child.stdin.write( - encodeMessage({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'bun-test', version: '1.0.0' }, - }, - }) - ); - - for (const request of requests) { - child.stdin.write(encodeMessage(request)); - } - - return await responsesPromise; - } finally { - child.kill(); - await browser.stop(); - } -} - -describe('ccs-browser MCP server', () => { - afterEach(() => { - // Cleanup is handled per test via runMcpRequests/createMockBrowser. - }); - - it('lists browser tools including navigate, click, type, and screenshot', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], - [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }] - ); - - const tools = (responses.find((message) => message.id === 2)?.result as { - tools: Array<{ - name: string; - description?: string; - inputSchema?: { properties?: Record }; - }>; - }).tools; - - expect(tools.map((tool) => tool.name)).toEqual([ - 'browser_get_session_info', - 'browser_get_url_and_title', - 'browser_get_visible_text', - 'browser_get_dom_snapshot', - 'browser_navigate', - 'browser_click', - 'browser_type', - 'browser_take_screenshot', - ]); - - const clickTool = tools.find((tool) => tool.name === 'browser_click'); - expect(clickTool?.description).toContain('mouse event chain'); - expect(clickTool?.description).not.toContain('synthetic element.click()'); - - for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { - expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ - type: 'integer', - minimum: 0, - }); - } - }); - - it('works from an installed copy when global WebSocket is unavailable and NODE_PATH supplies package dependencies', async () => { - const installDir = mkdtempSync(join(tmpdir(), 'ccs-browser-installed-copy-')); - const installedServerPath = join(installDir, 'ccs-browser-server.cjs'); - const bootstrapServerPath = join(installDir, 'bootstrap.cjs'); - - try { - cpSync(bundledServerPath, installedServerPath); - writeFileSync( - bootstrapServerPath, - 'delete globalThis.WebSocket;\nrequire("./ccs-browser-server.cjs");\n', - 'utf8' - ); - - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Installed Copy', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_get_url_and_title', arguments: {} }, - }, - ], - { - serverPath: bootstrapServerPath, - childEnv: { - NODE_PATH: join(process.cwd(), 'node_modules'), - }, - } - ); - - const response = responses.find((message) => message.id === 2); - expect((response?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(response)).toContain('title: Installed Copy'); - expect(getResponseText(response)).toContain('url: https://example.com/'); - } finally { - rmSync(installDir, { recursive: true, force: true }); - } - }); - - it('navigates successfully after readiness polling', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - navigate: { - 'https://example.com/next': { - finalUrl: 'https://example.com/next', - readyStates: ['loading', 'interactive'], - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/next' }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 2)); - expect(text).toContain('pageIndex: 0'); - expect(text).toContain('url: https://example.com/next'); - expect(text).toContain('status: navigated'); - }); - - it( - 'returns a handled error when navigation readiness times out', - async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - navigate: { - 'https://example.com/slow': { - finalUrl: 'https://example.com/', - readyStates: new Array(60).fill('loading'), - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/slow' }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 2); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: navigation did not complete'); - }, - 8000 - ); - - it('returns handled errors for missing URL, malformed URL, invalid page index, and out-of-range page index', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_navigate', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'browser_navigate', arguments: { url: 'file:///tmp/example' } }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { name: 'browser_navigate', arguments: { pageIndex: 1.5, url: 'https://example.com/next' } }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { name: 'browser_navigate', arguments: { pageIndex: 9, url: 'https://example.com/next' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('Browser MCP failed: url is required'); - expect(getResponseText(responses.find((message) => message.id === 3))).toContain( - 'Browser MCP failed: url must be an absolute http or https URL' - ); - expect(getResponseText(responses.find((message) => message.id === 4))).toContain( - 'Browser MCP failed: pageIndex must be a non-negative integer' - ); - expect(getResponseText(responses.find((message) => message.id === 5))).toContain('page index 9 is out of range'); - }); - - it('surfaces Page.navigate CDP failures as handled errors', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/start', - navigate: { - 'https://example.com/fail': { - finalUrl: 'https://example.com/start', - errorText: 'net::ERR_ABORTED', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/fail' }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 2); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain( - 'Browser MCP failed: navigation failed for URL: https://example.com/fail: net::ERR_ABORTED' - ); - }); - - it('treats redirects as successful navigation', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/start', - navigate: { - 'https://example.com/redirect': { - finalUrl: 'https://example.com/final', - readyStates: ['loading', 'interactive'], - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/redirect' }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 2)); - expect(text).toContain('status: navigated'); - expect(text).toContain('url: https://example.com/final'); - }); - - it('clicks matching elements and reports selector failures', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#submit': {}, - '#disabled': { disabled: true }, - '#throws': { error: 'click exploded' }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#submit' } }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#missing' } }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#disabled' } }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#throws' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #submit'); - - const selectorMiss = responses.find((message) => message.id === 3); - expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(selectorMiss)).toContain('element not found for selector: #missing'); - - const disabledError = responses.find((message) => message.id === 4); - expect((disabledError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(disabledError)).toContain('element is disabled for selector: #disabled'); - - const pageSideError = responses.find((message) => message.id === 5); - expect((pageSideError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(pageSideError)).toContain('click exploded'); - }); - - it('reports detached and hidden click targets as handled errors', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#hidden': { hidden: true }, - '#detached': { detached: true }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#hidden' } }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#detached' } }, - }, - ] - ); - - const hiddenError = responses.find((message) => message.id === 2); - expect((hiddenError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(hiddenError)).toContain('element is hidden or not interactable for selector: #hidden'); - - const detachedError = responses.find((message) => message.id === 3); - expect((detachedError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(detachedError)).toContain('element is detached for selector: #detached'); - }); - - it('uses a mouse sequence when the target requires it', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#menu-trigger': { requireMouseSequence: true }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#menu-trigger' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #menu-trigger'); - }); - - it('preserves mouse sequence preparation without dispatching a synthetic click event', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#click-event': { - requireMouseSequence: true, - forbidSyntheticClickEvent: true, - requireNativeClick: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#click-event' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #click-event'); - }); - - it('preserves native click activation after the mouse sequence', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#native-click': { - requireMouseSequence: true, - requireNativeClick: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#native-click' } }, - }, - ] - ); - - const clickResponse = responses.find((message) => message.id === 2); - expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(clickResponse)).toContain('status: clicked'); - expect(getResponseText(clickResponse)).toContain('selector: #native-click'); - }); - - it('does not force activation when mousedown cancels the interaction', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#cancel-mousedown': { - requireMouseSequence: true, - cancelMouseDown: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#cancel-mousedown' } }, - }, - ] - ); - - const clickResponse = responses.find((message) => message.id === 2); - expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(clickResponse)).toContain('status: clicked'); - expect(getResponseText(clickResponse)).toContain('selector: #cancel-mousedown'); - }); - - it('rechecks connectivity before native activation after the mouse sequence', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#detached-during-click': { - requireMouseSequence: true, - requireNativeClick: true, - detachAfterMouseDown: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#detached-during-click' } }, - }, - ] - ); - - const clickResponse = responses.find((message) => message.id === 2); - expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(clickResponse)).toContain('status: clicked'); - expect(getResponseText(clickResponse)).toContain('selector: #detached-during-click'); - }); - - it('falls back to click when mouse sequence dispatch fails', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#fallback': { mouseSequenceError: 'mouse dispatch exploded' }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#fallback' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #fallback'); - }); - - it('captures screenshots and reports empty payload failures', async () => { - const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - screenshot: screenshotPlan, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_take_screenshot', - arguments: { fullPage: true }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 2)); - expect(text).toContain('pageIndex: 0'); - expect(text).toContain('format: png'); - expect(text).toContain('fullPage: true'); - expect(text).toContain('data: c2NyZWVuc2hvdA=='); - expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true); - - const errorResponses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - screenshot: { data: '' }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_take_screenshot', - arguments: {}, - }, - }, - ] - ); - - const errorResponse = errorResponses.find((message) => message.id === 2); - expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(errorResponse)).toContain('Browser MCP failed: screenshot capture failed'); - }); - - it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - type: { - 'input[name="email"]': { - kind: 'input', - inputType: 'email', - value: 'old@example.com', - expectedValueWhenAppend: 'old@example.comhi@example.com', - requireFocus: true, - }, - '#notes': { - kind: 'textarea', - value: 'old note', - expectedValueWhenClearFirst: '', - requireFocus: true, - }, - '#editor': { - kind: 'contenteditable', - value: 'old content', - expectedValueWhenAppend: 'old contentrich text', - requireFocus: true, - }, - '#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' }, - '#plain': { kind: 'noneditable', value: 'plain' }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: 'input[name="email"]', text: 'hi@example.com' }, - }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#notes', text: '', clearFirst: true }, - }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#editor', text: 'rich text' }, - }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#color', text: 'ignored' }, - }, - }, - { - jsonrpc: '2.0', - id: 6, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#plain', text: 'ignored' }, - }, - }, - ] - ); - - const typed = getResponseText(responses.find((message) => message.id === 2)); - expect(typed).toContain('status: typed'); - expect(typed).toContain('selector: input[name="email"]'); - expect(typed).toContain('typedLength: 29'); - - const clearFirst = getResponseText(responses.find((message) => message.id === 3)); - expect(clearFirst).toContain('status: typed'); - expect(clearFirst).toContain('selector: #notes'); - expect(clearFirst).toContain('typedLength: 0'); - - const contenteditable = getResponseText(responses.find((message) => message.id === 4)); - expect(contenteditable).toContain('status: typed'); - expect(contenteditable).toContain('selector: #editor'); - expect(contenteditable).toContain('typedLength: 20'); - - const unsupported = responses.find((message) => message.id === 5); - expect((unsupported?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(unsupported)).toContain('element is not text-editable for selector: #color'); - - const nonEditable = responses.find((message) => message.id === 6); - expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(nonEditable)).toContain('element is not text-editable for selector: #plain'); - }); -}); diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 470ced04..2770439f 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -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', () => { diff --git a/tests/unit/proxy/messages-route.test.ts b/tests/unit/proxy/messages-route.test.ts index 3ac40437..f118a794 100644 --- a/tests/unit/proxy/messages-route.test.ts +++ b/tests/unit/proxy/messages-route.test.ts @@ -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); diff --git a/tests/unit/proxy/proxy-daemon-state.test.ts b/tests/unit/proxy/proxy-daemon-state.test.ts new file mode 100644 index 00000000..c653b030 --- /dev/null +++ b/tests/unit/proxy/proxy-daemon-state.test.ts @@ -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']); + }); +}); diff --git a/tests/unit/proxy/proxy-port-resolver.test.ts b/tests/unit/proxy/proxy-port-resolver.test.ts new file mode 100644 index 00000000..6ca2ae1c --- /dev/null +++ b/tests/unit/proxy/proxy-port-resolver.test.ts @@ -0,0 +1,108 @@ +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 { + resolveOpenAICompatProxyAdaptivePort, + resolveOpenAICompatProxyPortPreference, + 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('returns a configured shared proxy port as a shared preference', () => { + mutateUnifiedConfig((config) => { + config.proxy = { + ...(config.proxy ?? {}), + port: 45_000, + profile_ports: {}, + }; + }); + + expect(resolveOpenAICompatProxyPortPreference('ccg')).toEqual({ + port: 45_000, + source: 'shared', + }); + }); + + it('treats legacy shared 3456 as an adaptive default on read', () => { + mutateUnifiedConfig((config) => { + config.proxy = { + ...(config.proxy ?? {}), + port: 3456, + profile_ports: {}, + }; + }); + + expect(resolveOpenAICompatProxyPortPreference('ccg')).toEqual({ + port: resolveOpenAICompatProxyAdaptivePort('ccg'), + source: 'adaptive', + }); + }); + + it('preserves an explicit shared 43456 port when the user configures it', () => { + mutateUnifiedConfig((config) => { + config.proxy = { + ...(config.proxy ?? {}), + port: 43_456, + profile_ports: {}, + }; + }); + + expect(resolveOpenAICompatProxyPortPreference('ccg')).toEqual({ + port: 43_456, + source: 'shared', + }); + }); + + it('falls back to an adaptive shared default when no profile mapping exists', () => { + const portPreference = resolveOpenAICompatProxyPortPreference('ccg'); + + expect(portPreference).toEqual({ + port: resolveOpenAICompatProxyAdaptivePort('ccg'), + source: 'adaptive', + }); + expect(resolveOpenAICompatProxyPreferredPort('ccg')).not.toBe(3456); + }); + + it('derives a stable adaptive default that does not keep all profiles on 3456', () => { + const first = resolveOpenAICompatProxyPreferredPort('ccg'); + const second = resolveOpenAICompatProxyPreferredPort('ccg'); + const other = resolveOpenAICompatProxyPreferredPort('ccgm'); + + expect(first).toBe(second); + expect(first).not.toBe(3456); + expect(other).not.toBe(3456); + expect(other).not.toBe(first); + }); +}); diff --git a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts index e9a05c18..4bb3192f 100644 --- a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -30,6 +30,17 @@ describe('ProxyRequestTransformer regressions', () => { expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); }); + it('explicitly normalizes anthropic xhigh adaptive effort for OpenAI-compatible upstreams', () => { + const result = new ProxyRequestTransformer().transform({ + messages: [{ role: 'user', content: 'hello' }], + thinking: { type: 'adaptive' }, + output_config: { effort: 'xhigh' }, + }); + + 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({ @@ -120,7 +131,47 @@ describe('ProxyRequestTransformer regressions', () => { }, ], }) - ).toThrow('tool_result blocks must come before other user content'); + ).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({ diff --git a/tests/unit/scripts/github/ci-workflow.test.ts b/tests/unit/scripts/github/ci-workflow.test.ts new file mode 100644 index 00000000..a5319626 --- /dev/null +++ b/tests/unit/scripts/github/ci-workflow.test.ts @@ -0,0 +1,36 @@ +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('pr ci workflow', () => { + test('keeps full coverage on pull requests', () => { + const workflowPath = resolvePath('../../../../.github/workflows/ci.yml'); + + expect(fs.existsSync(workflowPath)).toBe(true); + + const workflow = fs.readFileSync(workflowPath, 'utf8'); + + expect(workflow).toContain('name: CI'); + expect(workflow).toContain('pull_request:'); + expect(workflow).toContain('branches: [main, dev]'); + expect(workflow).toContain('group: ci-${{ github.ref }}'); + expect(workflow).toContain('cancel-in-progress: true'); + expect(workflow).toContain('fail-fast: false'); + expect(workflow).toContain('runs-on: [self-hosted, linux, x64]'); + expect(workflow).toContain("cmd: 'bun run typecheck'"); + expect(workflow).toContain("cmd: 'bun run lint'"); + expect(workflow).toContain("cmd: 'bun run format:check'"); + expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}"); + expect(workflow).not.toContain('restore-keys:'); + expect(workflow).toContain('name: dist'); + expect(workflow).toContain('path: dist/'); + expect(workflow).toContain('needs: [build]'); + expect(workflow).toContain('run: bun run test:all'); + expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'"); + expect(workflow).toContain('run: bun run test:e2e'); + }); +}); diff --git a/tests/unit/scripts/github/dev-release-workflow.test.ts b/tests/unit/scripts/github/dev-release-workflow.test.ts new file mode 100644 index 00000000..5f103afb --- /dev/null +++ b/tests/unit/scripts/github/dev-release-workflow.test.ts @@ -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 }}'); + }); +}); diff --git a/tests/unit/scripts/github/push-ci-workflow.test.ts b/tests/unit/scripts/github/push-ci-workflow.test.ts new file mode 100644 index 00000000..c3313427 --- /dev/null +++ b/tests/unit/scripts/github/push-ci-workflow.test.ts @@ -0,0 +1,41 @@ +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('push ci workflow', () => { + test('keeps dev push quality checks separate from release automation', () => { + const workflowPath = resolvePath('../../../../.github/workflows/push-ci.yml'); + + expect(fs.existsSync(workflowPath)).toBe(true); + + const workflow = fs.readFileSync(workflowPath, 'utf8'); + + expect(workflow).toContain('name: Push CI'); + expect(workflow).toContain('push:'); + expect(workflow).toContain('branches: [dev]'); + expect(workflow).toContain('group: push-ci-${{ github.ref }}'); + expect(workflow).toContain('cancel-in-progress: true'); + expect(workflow).toContain('runs-on: [self-hosted, linux, x64]'); + expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}"); + expect(workflow).not.toContain('restore-keys:'); + expect(workflow).toContain("name: ${{ matrix.check.name }}"); + expect(workflow).toContain("cmd: 'bun run typecheck'"); + expect(workflow).toContain("cmd: 'bun run lint'"); + expect(workflow).toContain("cmd: 'bun run format:check'"); + expect(workflow).toContain('run: bun run build:all'); + expect(workflow).toContain("FAST_TEST_BUDGET_SECONDS: '90'"); + expect(workflow).toContain('name: Test fast bucket with perf budget'); + expect(workflow).toContain('bun run test:fast'); + expect(workflow).toContain('test_status=$?'); + expect(workflow).toContain('exit "$test_status"'); + expect(workflow).toContain('::warning::test:fast took ${elapsed_seconds}s'); + expect(workflow).toContain('scripts/run-test-bucket.js'); + expect(workflow).toContain('run: bun run test:slow'); + expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'"); + expect(workflow).toContain('run: bun run test:e2e'); + }); +}); diff --git a/tests/unit/scripts/run-test-bucket.test.js b/tests/unit/scripts/run-test-bucket.test.js new file mode 100644 index 00000000..0e1618cd --- /dev/null +++ b/tests/unit/scripts/run-test-bucket.test.js @@ -0,0 +1,46 @@ +const { describe, expect, test } = require('bun:test'); +const path = require('node:path'); +const bucket = require('../../../scripts/run-test-bucket.js'); + +describe('run-test-bucket', () => { + const browserMcpSplitSuites = [ + 'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts', + 'tests/unit/hooks/browser-mcp-downloads-and-files.test.ts', + 'tests/unit/hooks/browser-mcp-navigation-and-query.test.ts', + 'tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts', + 'tests/unit/hooks/browser-mcp-recording-and-replay.test.ts', + 'tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts', + ]; + + test('all declared slow tests still exist on disk', () => { + for (const relativePath of bucket.slowTests) { + const absolutePath = path.resolve(__dirname, '../../../', relativePath); + expect(Bun.file(absolutePath).exists()).resolves.toBe(true); + } + }); + + test('keeps the split Browser MCP suites in the slow bucket', () => { + const slowSet = bucket.getSlowSet(); + + expect(slowSet.has('tests/unit/hooks/ccs-browser-mcp-server.test.ts')).toBe(false); + for (const relativePath of browserMcpSplitSuites) { + expect(slowSet.has(relativePath)).toBe(true); + } + }); + + test('forces npm tests into the slow bucket', () => { + expect(bucket.shouldForceSlow('tests/npm/cli.test.js')).toBe(true); + }); + + test('keeps dist-independent javascript tests in the fast bucket', () => { + expect(bucket.shouldForceSlow('tests/unit/flag-parsing-simple.test.js')).toBe(false); + }); + + test('keeps non-allowlisted javascript tests in the slow bucket', () => { + expect(bucket.shouldForceSlow('tests/unit/commands/persist-command.test.js')).toBe(true); + }); + + test('still forces dist-dependent tests into the slow bucket', () => { + expect(bucket.shouldForceSlow('tests/unit/config-dir-override.test.js')).toBe(true); + }); +}); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index bfdb1212..9d79ebce 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -124,6 +124,9 @@ if (envOut) { CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN, CODEX_THREAD_ID: process.env.CODEX_THREAD_ID, ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR, + CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR, + CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL, }) + '\\n' ); } @@ -163,7 +166,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'], { @@ -173,27 +176,87 @@ process.exit(0); CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, CCS_THINKING: '8192', + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-codex-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-codex-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-codex-env', }); 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', - ], + expect(calls).toEqual([['fix failing tests']]); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: undefined, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, ]); }); + 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', () => { if (process.platform === 'win32') return; @@ -205,11 +268,13 @@ process.exit(0); config.browser = { claude: { enabled: false, + policy: 'auto', user_data_dir: '', devtools_port: 9222, }, codex: { enabled: false, + policy: 'auto', }, }; }); @@ -235,7 +300,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'], { @@ -250,20 +423,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(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', - ], - ]); + expect(calls).toEqual([['fix failing tests']]); }); for (const versionFlag of ['--version', '-v']) { @@ -286,6 +446,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; @@ -334,6 +512,9 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, }, ]); }); @@ -360,14 +541,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', @@ -382,6 +555,9 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, }); }); @@ -460,6 +636,9 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, }, ]); }); @@ -562,14 +741,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', diff --git a/tests/unit/targets/codex-settings-bridge-launch.test.ts b/tests/unit/targets/codex-settings-bridge-launch.test.ts index edec0748..6bf1c75c 100644 --- a/tests/unit/targets/codex-settings-bridge-launch.test.ts +++ b/tests/unit/targets/codex-settings-bridge-launch.test.ts @@ -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'); }); diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index 53cb189c..28d92d3a 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -119,6 +119,7 @@ describe('default profile browser launch', () => { printf "%s\n" "$@" > "${claudeArgsLogPath}" { printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "legacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" @@ -137,6 +138,13 @@ 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: '', }; }); @@ -198,7 +206,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'); @@ -249,14 +257,38 @@ 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).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('scrubs inherited CCS_BROWSER_* env from browser-off default Claude launches', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['default', 'smoke'], { + ...baseEnv, + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-browser-legacy', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9555', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9555', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-default-env', + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).not.toContain('/tmp/stale-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/stale-browser-legacy'); + expect(launchedEnv).not.toContain('9555'); + expect(launchedEnv).not.toContain('stale-default-env'); }); it('skips managed browser attach when the default CCS browser profile directory is missing', () => { @@ -270,11 +302,13 @@ server.listen(0, '127.0.0.1', () => { config.browser = { claude: { enabled: true, + policy: 'auto', user_data_dir: '', devtools_port: 43123, }, codex: { enabled: true, + policy: 'auto', }, }; }); @@ -284,8 +318,9 @@ server.listen(0, '127.0.0.1', () => { }); expect(result.status).toBe(0); - expect(result.stderr).toContain('CCS created the managed browser profile'); - expect(result.stderr).toContain('Start Chrome with remote debugging'); + 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); @@ -320,11 +355,13 @@ server.listen(0, '127.0.0.1', () => { config.browser = { claude: { enabled: true, + policy: 'auto', user_data_dir: '', devtools_port: unreachablePort, }, codex: { enabled: true, + policy: 'auto', }, }; }); @@ -395,11 +432,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), }, codex: { enabled: true, + policy: 'auto', }, }; }); @@ -424,4 +463,86 @@ server.listen(0, '127.0.0.1', () => { } } }); + + 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; + } else { + delete process.env.CCS_HOME; + } + } + }); }); diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index 8f0efaa1..21128d55 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -143,6 +143,23 @@ describe('droid-config-manager', () => { expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960); }); + it('writes adaptive anthropic thinking for claude-opus-4-7 overrides', async () => { + await upsertCcsModel('claude', { + model: 'claude-opus-4-7', + displayName: 'CCS claude', + baseUrl: 'https://api.anthropic.com', + apiKey: 'anthropic-key', + provider: 'anthropic', + reasoningOverride: 'max', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels[0].extraArgs?.thinking?.type).toBe('adaptive'); + expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBeUndefined(); + expect(settings.customModels[0].extraArgs?.output_config?.effort).toBe('max'); + }); + it('should clear prior reasoning config when override disables thinking', async () => { await upsertCcsModel('glm', { model: 'glm-4.7', diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index 32780a4c..e89e8855 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -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; @@ -40,6 +42,40 @@ function reserveClosedPort(): number { return port; } +async function waitForDevtoolsVersionEndpoint(port: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() <= deadline) { + try { + await new Promise((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 = ''; @@ -95,10 +131,18 @@ describe('settings profile browser launch', () => { printf "%s\n" "$@" > "${claudeArgsLogPath}" { printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "legacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" 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 "stripAnthropic=%s\n" "$CCS_STRIP_INHERITED_ANTHROPIC_ENV" + printf "anthropicBaseUrl=%s\n" "$ANTHROPIC_BASE_URL" + printf "anthropicAuthToken=%s\n" "$ANTHROPIC_AUTH_TOKEN" + printf "anthropicApiKey=%s\n" "$ANTHROPIC_API_KEY" + printf "anthropicModel=%s\n" "$ANTHROPIC_MODEL" + printf "anthropicSonnet=%s\n" "$ANTHROPIC_DEFAULT_SONNET_MODEL" + printf "maxOutputTokens=%s\n" "$CLAUDE_CODE_MAX_OUTPUT_TOKENS" } > "${claudeEnvLogPath}" exit 0 `, @@ -113,6 +157,13 @@ 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: '', }; }); @@ -128,7 +179,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 }); @@ -138,12 +189,75 @@ 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('passes selective Anthropic env stripping to settings-profile Claude launches while preserving model defaults', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'profile-token', + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', + }, + }, + null, + 2 + ) + '\n' + ); + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + mutateUnifiedConfig((config) => { + config.global_env = { + enabled: true, + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:9999/api/provider/global', + ANTHROPIC_AUTH_TOKEN: 'global-routing-token', + ANTHROPIC_API_KEY: 'global-api-key', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '54321', + }, + }; + }); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'parent-routing-token', + ANTHROPIC_API_KEY: 'parent-api-key', + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + }); + + expect(result.status).toBe(0); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain('stripAnthropic=1'); + expect(launchedEnv).toContain('anthropicBaseUrl='); + expect(launchedEnv).toContain('anthropicAuthToken='); + expect(launchedEnv).toContain('anthropicApiKey='); + expect(launchedEnv).toContain('anthropicModel=gpt-5.4'); + expect(launchedEnv).toContain('anthropicSonnet=gpt-5.4'); + expect(launchedEnv).toContain('maxOutputTokens=12345'); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + + 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'); @@ -182,6 +296,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( @@ -200,14 +315,72 @@ 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).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('scrubs inherited CCS_BROWSER_* env from browser-off settings-profile launches', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-settings-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-settings-browser-legacy', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9666', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9666', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-settings-env', + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).not.toContain('/tmp/stale-settings-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/stale-settings-browser-legacy'); + expect(launchedEnv).not.toContain('9666'); + expect(launchedEnv).not.toContain('stale-settings-env'); + }); + + it('scrubs CCS_BROWSER_* values embedded in settings-profile env when browser is off', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_MODEL: 'glm-5', + CCS_BROWSER_USER_DATA_DIR: '/tmp/settings-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/settings-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env', + }, + }, + null, + 2 + ) + '\n' + ); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).not.toContain('/tmp/settings-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/settings-browser-legacy'); + expect(launchedEnv).not.toContain('settings-env'); }); it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => { @@ -221,11 +394,13 @@ server.listen(0, '127.0.0.1', () => { config.browser = { claude: { enabled: true, + policy: 'auto', user_data_dir: '', devtools_port: 43123, }, codex: { enabled: true, + policy: 'auto', }, }; }); @@ -235,8 +410,9 @@ server.listen(0, '127.0.0.1', () => { }); expect(result.status).toBe(0); - expect(result.stderr).toContain('CCS created the managed browser profile'); - expect(result.stderr).toContain('Start Chrome with remote debugging'); + 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); @@ -272,11 +448,13 @@ server.listen(0, '127.0.0.1', () => { config.browser = { claude: { enabled: true, + policy: 'auto', user_data_dir: '', devtools_port: unreachablePort, }, codex: { enabled: true, + policy: 'auto', }, }; }); @@ -341,6 +519,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( @@ -353,11 +532,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), }, codex: { enabled: true, + policy: 'auto', }, }; }); @@ -382,4 +563,93 @@ server.listen(0, '127.0.0.1', () => { } } }); + + 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; + } else { + delete process.env.CCS_HOME; + } + } + }); }); diff --git a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts index 1430fd72..ee2d7187 100644 --- a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts +++ b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts @@ -1,10 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from 'bun:test'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read'; +setDefaultTimeout(30000); interface RunResult { status: number | null; @@ -98,6 +99,9 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY" printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL" printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH" + printf "browserUserDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "browserLegacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" + printf "browserWsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" } > "${claudeEnvLogPath}" exit 0 `, @@ -113,6 +117,13 @@ exit 0 CCS_CLAUDE_PATH: fakeClaudePath, CCS_DEBUG: '1', }; + 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(() => { @@ -167,7 +178,12 @@ exit 0 expect(result.status).toBe(0); expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool'); const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); + expect(launchedEnv).toContain('skip=1'); + expect(launchedEnv).not.toContain('runtimeApiKey=current-token'); + expect(launchedEnv).not.toContain('runtimeBaseUrl=https://api.z.ai'); + expect(launchedEnv).not.toContain('runtimePath=/api/provider/agy'); }); it('suppresses stale CCS image hooks during a healthy MCP-first launch', () => { @@ -245,9 +261,26 @@ exit 0 expect(result.status).toBe(0); expect(fs.existsSync(claudeEnvLogPath)).toBe(true); const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); - expect(launchedEnv).toContain('runtimeApiKey=current-token'); expect(launchedEnv).not.toContain('stale-token'); - expect(launchedEnv).toContain('runtimePath=/api/provider/agy'); + expect(launchedEnv).not.toContain('runtimeApiKey=stale-token'); + }); + + it('scrubs stale CCS_BROWSER_* env while preserving settings-profile image analysis runtime', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-image-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-image-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-image-env', + }); + + expect(result.status).toBe(0); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).not.toContain('stale-token'); + expect(launchedEnv).not.toContain('/tmp/stale-image-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/stale-image-browser-legacy'); + expect(launchedEnv).not.toContain('stale-image-env'); }); it('pins direct settings image analysis to the current local CLIProxy auth token', () => { @@ -273,8 +306,7 @@ exit 0 expect(result.status).toBe(0); expect(fs.existsSync(claudeEnvLogPath)).toBe(true); const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); - expect(launchedEnv).toContain('runtimeApiKey=current-token'); expect(launchedEnv).not.toContain('stale-token'); - expect(launchedEnv).toContain('runtimePath=/api/provider/'); + expect(launchedEnv).not.toContain('runtimeApiKey=stale-token'); }); }); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 68299433..d63c79d8 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -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'); }); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 13c5661c..1fc660c5 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -117,6 +117,24 @@ 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, + eval_mode: 'readonly', + }, + codex: { + enabled: false, + policy: 'manual', + eval_mode: 'readonly', + }, + }); + }); }); describe('isUnifiedConfig', () => { @@ -337,3 +355,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 }); + } + }); +}); diff --git a/tests/unit/utils/browser/browser-policy.test.ts b/tests/unit/utils/browser/browser-policy.test.ts new file mode 100644 index 00000000..03e35e19 --- /dev/null +++ b/tests/unit/utils/browser/browser-policy.test.ts @@ -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, + }); + }); +}); diff --git a/tests/unit/utils/browser/browser-setup.test.ts b/tests/unit/utils/browser/browser-setup.test.ts new file mode 100644 index 00000000..fd180579 --- /dev/null +++ b/tests/unit/utils/browser/browser-setup.test.ts @@ -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>, + }; + + 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>, + }; + + 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'); + }); +}); diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index e35e52d7..9add8441 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -2,12 +2,18 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; 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 * as chromeReuse from '../../../../src/utils/browser/chrome-reuse'; import { - getBrowserStatus, -} from '../../../../src/utils/browser/browser-status'; -import { resolveOptionalBrowserAttachRuntime } from '../../../../src/utils/browser/browser-settings'; + 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', () => { @@ -58,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, @@ -72,19 +78,68 @@ 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, 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', - supportsConfigOverrides: true, }); } finally { + runtimeSpy.mockRestore(); codexSpy.mockRestore(); } }); @@ -94,11 +149,13 @@ describe('browser status', () => { config.browser = { claude: { enabled: true, + policy: 'auto', user_data_dir: '', devtools_port: 9222, }, codex: { enabled: true, + policy: 'auto', }, }; }); @@ -119,11 +176,9 @@ describe('browser status', () => { const status = await getBrowserStatus(); expect(status.claude.state).toBe('browser_not_running'); - expect(status.claude.title).toBe( - 'Claude Browser Attach is waiting for a managed Chrome session.' - ); - expect(status.claude.detail).toContain('CCS created the managed browser profile'); - expect(status.claude.nextStep).toContain('--remote-debugging-port=9222'); + 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(); @@ -136,11 +191,13 @@ describe('browser status', () => { config.browser = { claude: { enabled: true, + policy: 'auto', user_data_dir: '/config-browser', devtools_port: 9333, }, codex: { enabled: true, + policy: 'auto', }, }; }); @@ -178,16 +235,91 @@ describe('browser status', () => { } }); - 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, + policy: 'auto', + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: true, + 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('keeps env override paths from implicitly enabling Claude browser attach when config is disabled', () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: false, + policy: 'manual', + user_data_dir: '/config-browser', + devtools_port: 9333, + }, + codex: { + enabled: false, + policy: 'manual', + }, + }; + }); + process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser'; + process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444'; + + const effective = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); + + expect(effective).toMatchObject({ + enabled: false, + source: 'CCS_BROWSER_USER_DATA_DIR', + overrideActive: true, + userDataDir: '/env-browser', + devtoolsPort: 9444, + }); + }); + + 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', }, }; }); @@ -214,27 +346,21 @@ describe('browser status', () => { } }); - it('returns a 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 runtime = await resolveOptionalBrowserAttachRuntime({ - enabled: true, - source: 'config', - overrideActive: false, - userDataDir: managedDir, - devtoolsPort: 43123, - hasExplicitDevtoolsPort: true, - }); - - expect(runtime.runtimeEnv).toBeUndefined(); - expect(runtime.warning).toContain( - 'could not reach the attach-mode DevTools endpoint for the managed browser profile' - ); - expect(runtime.warning).toContain('continue without browser tools'); - }); - it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + policy: 'manual', + user_data_dir: '/config-browser', + devtools_port: 9333, + }, + codex: { + enabled: false, + policy: 'manual', + }, + }; + }); process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser'; const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ @@ -270,11 +396,13 @@ describe('browser status', () => { config.browser = { claude: { enabled: true, + policy: 'auto', user_data_dir: '/tmp/config-browser', devtools_port: 9222, }, codex: { enabled: true, + policy: 'auto', }, }; }); diff --git a/tests/unit/utils/browser/chrome-reuse.test.ts b/tests/unit/utils/browser/chrome-reuse.test.ts index f9b90c5c..2b0915ab 100644 --- a/tests/unit/utils/browser/chrome-reuse.test.ts +++ b/tests/unit/utils/browser/chrome-reuse.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -12,6 +12,13 @@ describe('chrome reuse resolver', () => { const originalHome = process.env.HOME; const originalLocalAppData = process.env.LOCALAPPDATA; const originalUserProfile = process.env.USERPROFILE; + const originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + const originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + const originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + const originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST; + const originalBrowserDevtoolsHttpUrl = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + const originalBrowserDevtoolsWsUrl = process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + const originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE; let tempDirs: string[] = []; let servers: Array<{ stop: () => void }> = []; @@ -71,7 +78,7 @@ describe('chrome reuse resolver', () => { return server; } - async function reserveClosedPort(): Promise { + function reserveClosedPort(): number { const server = Bun.serve({ port: 0, fetch() { @@ -79,13 +86,27 @@ describe('chrome reuse resolver', () => { }, }); const port = server.port; - server.stop(true); + if (port === undefined) { + server.stop(); + throw new Error('Failed to reserve a DevTools test port'); + } + server.stop(); return port; } + beforeEach(() => { + 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_HTTP_URL; + delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + delete process.env.CCS_BROWSER_EVAL_MODE; + }); + afterEach(() => { for (const server of servers) { - server.stop(true); + server.stop(); } servers = []; @@ -111,6 +132,48 @@ describe('chrome reuse resolver', () => { } else { process.env.USERPROFILE = originalUserProfile; } + + if (originalBrowserUserDataDir === undefined) { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } else { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } + + if (originalBrowserProfileDir === undefined) { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } else { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } + + if (originalBrowserDevtoolsPort === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } else { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } + + if (originalBrowserDevtoolsHost === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_HOST; + } else { + process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost; + } + + if (originalBrowserDevtoolsHttpUrl === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + } else { + process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL = originalBrowserDevtoolsHttpUrl; + } + + if (originalBrowserDevtoolsWsUrl === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + } else { + process.env.CCS_BROWSER_DEVTOOLS_WS_URL = originalBrowserDevtoolsWsUrl; + } + + if (originalBrowserEvalMode === undefined) { + delete process.env.CCS_BROWSER_EVAL_MODE; + } else { + process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode; + } }); it('uses explicit profile-dir before the default path and resolves the websocket target', async () => { @@ -175,7 +238,7 @@ describe('chrome reuse resolver', () => { it('throws a clear error when DevToolsActivePort metadata is missing', async () => { const profileDir = createTempDir('ccs-chrome-missing-metadata-'); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}` ); }); @@ -184,17 +247,17 @@ describe('chrome reuse resolver', () => { const profileDir = createTempDir('ccs-chrome-invalid-metadata-'); writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target'); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}` ); }); it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => { const profileDir = createTempDir('ccs-chrome-unreachable-'); - const port = await reserveClosedPort(); + const port = reserveClosedPort(); writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}` ); }); @@ -204,7 +267,7 @@ describe('chrome reuse resolver', () => { const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' }); writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version` ); }); @@ -214,7 +277,7 @@ describe('chrome reuse resolver', () => { const server = await startFailingDevToolsServer(500); writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` ); }); @@ -224,7 +287,7 @@ describe('chrome reuse resolver', () => { const server = await startMalformedJsonDevToolsServer(); writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` ); }); @@ -262,7 +325,7 @@ describe('chrome reuse resolver', () => { 'missing-profile' ); - await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow( `Chrome profile directory is invalid: ${missingProfileDir}` ); }); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 5fb99ded..bb64e71d 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -21,8 +21,16 @@ type SpawnCall = { options: Record | undefined; }; -const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches'; +type SpawnSyncCall = { + command: string; + args: string[]; + options: Record | undefined; +}; + +const STEERING_PROMPT_SNIPPET = + 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches'; const spawnCalls: SpawnCall[] = []; +const spawnSyncCalls: SpawnSyncCall[] = []; const originalPlatform = process.platform; let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; @@ -31,6 +39,7 @@ let originalCcsHome: string | undefined; let originalCcsClaudePath: string | undefined; let originalDisableAutoUpdater: string | undefined; let originalClaudeConfigDir: string | undefined; +let originalTmux: string | undefined; const realSpawn = childProcess.spawn.bind(childProcess); const realSpawnSync = childProcess.spawnSync.bind(childProcess); const realExecSync = childProcess.execSync.bind(childProcess); @@ -103,6 +112,18 @@ function registerChildProcessMock(): void { | Record | undefined; + if (command === 'tmux') { + spawnSyncCalls.push({ command, args, options }); + return { + pid: process.pid, + output: ['', '', ''], + stdout: '', + stderr: '', + status: 0, + signal: null, + }; + } + return realSpawnSync(command, args, options as Parameters[2]); }, execSync: (...execArgs: unknown[]) => @@ -140,15 +161,18 @@ ${yamlBody} } let execClaude: typeof import('../../../src/utils/shell-executor').execClaude; +let stripAnthropicRoutingEnv: typeof import('../../../src/utils/shell-executor').stripAnthropicRoutingEnv; let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv; let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor; let SharedManager: typeof import('../../../src/management/shared-manager').default; +let stopOpenAICompatProxy: typeof import('../../../src/proxy/proxy-daemon').stopOpenAICompatProxy; beforeAll(async () => { registerChildProcessMock(); const shellExecutor = await import('../../../src/utils/shell-executor'); execClaude = shellExecutor.execClaude; + stripAnthropicRoutingEnv = shellExecutor.stripAnthropicRoutingEnv; stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv; const sharedManagerModule = await import('../../../src/management/shared-manager'); @@ -156,6 +180,9 @@ beforeAll(async () => { const headless = await import('../../../src/delegation/headless-executor'); HeadlessExecutor = headless.HeadlessExecutor; + + const proxyDaemon = await import('../../../src/proxy/proxy-daemon'); + stopOpenAICompatProxy = proxyDaemon.stopOpenAICompatProxy; }); afterAll(() => { @@ -165,6 +192,7 @@ afterAll(() => { describe('CLAUDECODE environment stripping', () => { beforeEach(() => { spawnCalls.length = 0; + spawnSyncCalls.length = 0; process.env.CCS_QUIET = '1'; // Save original env values for restoration in afterEach @@ -172,17 +200,26 @@ describe('CLAUDECODE environment stripping', () => { originalCcsClaudePath = process.env.CCS_CLAUDE_PATH; originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER; originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalTmux = process.env.TMUX; // Clear CCS-managed env vars that leak from host sessions delete process.env.DISABLE_AUTOUPDATER; delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.TMUX; baselineSigintListeners = process.listeners('SIGINT'); baselineSigtermListeners = process.listeners('SIGTERM'); baselineSighupListeners = process.listeners('SIGHUP'); }); - afterEach(() => { + afterEach(async () => { + const tempCcsHome = process.env.CCS_HOME?.startsWith(os.tmpdir()) + ? process.env.CCS_HOME + : undefined; + if (tempCcsHome) { + await stopOpenAICompatProxy(); + } + Object.defineProperty(process, 'platform', { value: originalPlatform }); delete process.env.CLAUDECODE; delete process.env.claudecode; @@ -197,8 +234,20 @@ describe('CLAUDECODE environment stripping', () => { } else { delete process.env.DISABLE_AUTOUPDATER; } - if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + if (originalClaudeConfigDir !== undefined) + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; else delete process.env.CLAUDE_CONFIG_DIR; + if (originalTmux !== undefined) process.env.TMUX = originalTmux; + else delete process.env.TMUX; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_MODEL; + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL; + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL; + delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + delete process.env.ANTHROPIC_SMALL_FAST_MODEL; + delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS; for (const listener of process.listeners('SIGINT')) { if (!baselineSigintListeners.includes(listener)) { @@ -215,6 +264,10 @@ describe('CLAUDECODE environment stripping', () => { process.removeListener('SIGHUP', listener as (...args: unknown[]) => void); } } + + if (tempCcsHome) { + fs.rmSync(tempCcsHome, { recursive: true, force: true }); + } }); it('stripClaudeCodeEnv removes CLAUDECODE case-insensitively', () => { @@ -230,6 +283,25 @@ describe('CLAUDECODE environment stripping', () => { expect(result.PATH).toBe('/usr/bin'); }); + it('stripAnthropicRoutingEnv removes routing/auth env case-insensitively while preserving model vars', () => { + const input: NodeJS.ProcessEnv = { + anthropic_base_url: 'http://127.0.0.1:8317/api/provider/codex', + Anthropic_Auth_Token: 'parent-routing-token', + ANTHROPIC_API_KEY: 'parent-api-key', + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + PATH: '/usr/bin', + }; + + const result = stripAnthropicRoutingEnv(input); + expect(result.anthropic_base_url).toBeUndefined(); + expect(result.Anthropic_Auth_Token).toBeUndefined(); + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); + expect(result.PATH).toBe('/usr/bin'); + }); + it('execClaude strips CLAUDECODE from merged env (including overrides)', () => { process.env.CLAUDECODE = 'from-parent'; process.env.claudecode = 'from-parent-lower'; @@ -336,6 +408,97 @@ describe('CLAUDECODE environment stripping', () => { expect(normalizeSpy).toHaveBeenCalledWith(instancePath); }); + it('execClaude strips inherited ANTHROPIC routing env but keeps model intent for settings-profile Claude launches', () => { + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex'; + process.env.ANTHROPIC_AUTH_TOKEN = 'ccs-internal-managed'; + process.env.ANTHROPIC_API_KEY = 'stale-api-key'; + process.env.ANTHROPIC_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'gpt-5.4-mini'; + process.env.ANTHROPIC_SMALL_FAST_MODEL = 'gpt-5-codex-mini'; + + execClaude('claude', ['--help'], { + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'), + CCS_WEBSEARCH_SKIP: '1', + }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.CCS_PROFILE_TYPE).toBe('settings'); + expect(env.CLAUDE_CONFIG_DIR).toContain('ccs-settings-profile-instance'); + expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); + expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5-codex-mini'); + }); + + it('execClaude strips routing env reintroduced by explicit settings-profile overrides', () => { + execClaude('claude', ['--help'], { + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'reintroduced-routing-token', + ANTHROPIC_API_KEY: 'reintroduced-api-key', + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); + }); + + it('execClaude sanitizes tmux teammate env for bridge-backed settings launches while keeping the launched child on the runtime proxy', () => { + process.env.TMUX = 'session-1'; + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex'; + process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token'; + process.env.ANTHROPIC_API_KEY = 'parent-api-key'; + process.env.ANTHROPIC_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4'; + + execClaude('claude', ['--help'], { + CCS_PROFILE_TYPE: 'settings', + CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'), + ANTHROPIC_BASE_URL: 'http://127.0.0.1:3456', + ANTHROPIC_AUTH_TOKEN: 'fresh-runtime-token', + ANTHROPIC_MODEL: 'gpt-5.4', + }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const childEnv = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(childEnv.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:3456'); + expect(childEnv.ANTHROPIC_AUTH_TOKEN).toBe('fresh-runtime-token'); + + const unsetBaseUrlCall = spawnSyncCalls.find( + (call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_BASE_URL' + ); + const unsetAuthTokenCall = spawnSyncCalls.find( + (call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_AUTH_TOKEN' + ); + const modelCall = spawnSyncCalls.find( + (call) => + call.command === 'tmux' && + call.args[0] === 'setenv' && + call.args[1] === 'ANTHROPIC_MODEL' && + call.args[2] === 'gpt-5.4' + ); + + expect(unsetBaseUrlCall).toBeDefined(); + expect(unsetAuthTokenCall).toBeDefined(); + expect(modelCall).toBeDefined(); + }); + it('headless executor spawn path strips CLAUDECODE before spawn', async () => { writeConfigWithAutoUpdatePreference(false); process.env.CLAUDECODE = 'nested'; @@ -432,6 +595,92 @@ describe('CLAUDECODE environment stripping', () => { }); }); + it('headless executor strips inherited routing env for settings-profile delegation while preserving model intent', async () => { + writeConfigWithAutoUpdatePreference(false); + const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); + fs.writeFileSync( + path.join(ccsDir, 'glm.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + const projectDir = path.join(ccsDir, 'project-headless-settings'); + fs.mkdirSync(projectDir, { recursive: true }); + process.env.CCS_CLAUDE_PATH = 'claude'; + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex'; + process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token'; + process.env.ANTHROPIC_API_KEY = 'parent-api-key'; + process.env.ANTHROPIC_MODEL = 'gpt-5.4'; + + const result = await HeadlessExecutor.execute('glm', 'latest AI chip news', { + cwd: projectDir, + permissionMode: 'default', + timeout: 1000, + }); + + expect(result.success).toBe(true); + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); + expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345'); + }); + + it('headless executor rebuilds OpenAI-compatible bridge env from settings instead of inheriting stale parent routing', async () => { + writeConfigWithAutoUpdatePreference(false); + const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); + fs.writeFileSync( + path.join(ccsDir, 'bridge.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'settings-bridge-token', + ANTHROPIC_MODEL: 'gpt-5.4', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + const projectDir = path.join(ccsDir, 'project-headless-bridge'); + fs.mkdirSync(projectDir, { recursive: true }); + process.env.CCS_CLAUDE_PATH = 'claude'; + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex'; + process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token'; + process.env.ANTHROPIC_API_KEY = 'parent-api-key'; + + const result = await HeadlessExecutor.execute('bridge', 'latest AI chip news', { + cwd: projectDir, + permissionMode: 'default', + timeout: 1000, + }); + + expect(result.success).toBe(true); + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(env.ANTHROPIC_BASE_URL).not.toBe('http://127.0.0.1:8317/api/provider/codex'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).not.toBe('parent-routing-token'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345'); + }); + it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => { writeConfigWithAutoUpdatePreference(false); const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); @@ -468,9 +717,7 @@ describe('CLAUDECODE environment stripping', () => { args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], env: {}, }); - expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe( - false - ); + expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(false); expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false); }); diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts index 2ce1c342..46482358 100644 --- a/tests/unit/web-server/browser-routes.test.ts +++ b/tests/unit/web-server/browser-routes.test.ts @@ -13,6 +13,13 @@ describe('browser routes', () => { let tempHome = ''; let originalCcsHome: string | undefined; let originalDashboardAuthEnabled: string | undefined; + let originalBrowserUserDataDir: string | undefined; + let originalBrowserProfileDir: string | undefined; + let originalBrowserDevtoolsHost: string | undefined; + let originalBrowserDevtoolsPort: string | undefined; + let originalBrowserDevtoolsHttpUrl: string | undefined; + let originalBrowserDevtoolsWsUrl: string | undefined; + let originalBrowserEvalMode: string | undefined; let forcedRemoteAddress = '127.0.0.1'; beforeAll(async () => { @@ -54,8 +61,22 @@ describe('browser routes', () => { tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-')); originalCcsHome = process.env.CCS_HOME; originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + 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; process.env.CCS_HOME = tempHome; 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_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; forcedRemoteAddress = '127.0.0.1'; }); @@ -72,6 +93,42 @@ describe('browser routes', () => { delete process.env.CCS_DASHBOARD_AUTH_ENABLED; } + if (originalBrowserUserDataDir !== undefined) { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } else { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } + if (originalBrowserProfileDir !== undefined) { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } else { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } + if (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 { + delete process.env.CCS_BROWSER_EVAL_MODE; + } + rmSync(tempHome, { recursive: true, force: true }); }); @@ -93,21 +150,65 @@ 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, + enabled: false, + policy: 'manual', + evalMode: 'readonly', }, }); expect(payload.status.claude).toMatchObject({ state: 'disabled', + policy: 'manual', + evalMode: 'readonly', managedMcpServerName: 'ccs-browser', }); expect(payload.status.codex).toMatchObject({ - enabled: true, + enabled: false, + state: 'disabled', + policy: 'manual', + evalMode: 'readonly', serverName: 'ccs_browser', }); + expect(payload.status.codex.detail).toContain('off by default'); + }); + + it('returns evalMode through the standalone browser status route', async () => { + const updateResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + enabled: true, + policy: 'manual', + evalMode: 'readwrite', + }, + codex: { + enabled: true, + policy: 'auto', + evalMode: 'disabled', + }, + }), + }); + + expect(updateResponse.status).toBe(200); + + const response = await fetch(`${baseUrl}/api/browser/status`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + claude: { + policy: 'manual', + evalMode: 'readwrite', + }, + codex: { + policy: 'auto', + evalMode: 'disabled', + }, + }); }); it('updates the saved browser config through the dashboard route', async () => { @@ -117,11 +218,15 @@ describe('browser routes', () => { body: JSON.stringify({ claude: { enabled: true, + policy: 'manual', userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, + evalMode: 'readwrite', }, codex: { - enabled: false, + enabled: true, + policy: 'auto', + evalMode: 'disabled', }, }), }); @@ -131,11 +236,25 @@ describe('browser routes', () => { expect(payload.browser.config).toMatchObject({ claude: { enabled: true, + policy: 'manual', userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, + evalMode: 'readwrite', }, codex: { - enabled: false, + enabled: true, + policy: 'auto', + evalMode: 'disabled', + }, + }); + expect(payload.browser.status).toMatchObject({ + claude: { + policy: 'manual', + evalMode: 'readwrite', + }, + codex: { + policy: 'auto', + evalMode: 'disabled', }, }); @@ -143,15 +262,60 @@ 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, + enabled: true, + policy: 'auto', + eval_mode: 'disabled', }, }); }); + it('updates evalMode without changing the saved policy', async () => { + const firstResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + codex: { + enabled: true, + policy: 'auto', + evalMode: 'readonly', + }, + }), + }); + + expect(firstResponse.status).toBe(200); + + const secondResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + codex: { + evalMode: 'readwrite', + }, + }), + }); + + expect(secondResponse.status).toBe(200); + const payload = await secondResponse.json(); + expect(payload.browser.config.codex).toMatchObject({ + enabled: true, + policy: 'auto', + evalMode: 'readwrite', + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.browser?.codex).toMatchObject({ + enabled: true, + policy: 'auto', + eval_mode: 'readwrite', + }); + }); + it('treats a blank user-data directory as a reset to the recommended path', async () => { const firstResponse = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', @@ -159,8 +323,10 @@ describe('browser routes', () => { body: JSON.stringify({ claude: { enabled: true, + policy: 'manual', userDataDir: '/tmp/ccs-browser-custom', devtoolsPort: 9333, + evalMode: 'readonly', }, }), }); @@ -181,19 +347,24 @@ 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(payload.browser.status.claude.evalMode).toBe('readonly'); 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', }, }); }); @@ -214,4 +385,87 @@ describe('browser routes', () => { error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.', }); }); + + 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: 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(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Unknown browser config field(s): codxe.', + }); + }); + + 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: { + policy: 'always', + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for codex.policy. Must be auto or manual.', + }); + }); + + it('rejects invalid browser evalMode values at the route boundary', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + evalMode: 'always', + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + }); + + 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.', + }); + }); }); diff --git a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts index 8c08b01e..5805ed54 100644 --- a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts +++ b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts @@ -47,7 +47,7 @@ function createDeps( } describe('installDashboardCliproxyVersion', () => { - it('restarts the proxy after install when it was already running', async () => { + it('restarts the plus proxy after install when it was already running', async () => { const { deps, calls } = createDeps({ sessionRunning: true }); const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps); @@ -56,14 +56,14 @@ describe('installDashboardCliproxyVersion', () => { success: true, restarted: true, port: 8317, - message: 'Successfully installed CLIProxy v6.7.1 and restarted it on port 8317', + message: 'Successfully installed CLIProxy Plus v6.7.1 and restarted it on port 8317', }); expect(calls.isCliproxyRunning).toBe(0); expect(calls.installCliproxyVersion).toBe(1); expect(calls.ensureCliproxyService).toBe(1); }); - it('keeps the proxy stopped after install when it was not running beforehand', async () => { + it('keeps the plus proxy stopped after install when it was not running beforehand', async () => { const { deps, calls } = createDeps({ sessionRunning: false, remoteRunning: false }); const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps); @@ -71,7 +71,7 @@ describe('installDashboardCliproxyVersion', () => { expect(result).toEqual({ success: true, restarted: false, - message: 'Successfully installed CLIProxy v6.7.1', + message: 'Successfully installed CLIProxy Plus v6.7.1', }); expect(calls.isCliproxyRunning).toBe(1); expect(calls.installCliproxyVersion).toBe(1); @@ -118,8 +118,8 @@ describe('installDashboardCliproxyVersion', () => { expect(result).toEqual({ success: false, restarted: false, - error: 'Installed CLIProxy v6.7.1, but restart failed', - message: 'Installed CLIProxy v6.7.1, but failed to restart it', + error: 'Installed CLIProxy Plus v6.7.1, but restart failed', + message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it', }); }); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts index 296851dc..e5cfcef8 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts @@ -96,7 +96,7 @@ afterAll(async () => { }); describe('cliproxy-stats-routes install contract', () => { - it('routes saved plus configs through original backend for update checks', async () => { + it('keeps saved plus configs on the plus backend for update checks', async () => { const response = await fetch(`${baseUrl}/api/cliproxy/update-check`); expect(response.status).toBe(200); @@ -107,8 +107,8 @@ describe('cliproxy-stats-routes install contract', () => { latestVersion: string; }; - expect(body.backend).toBe('original'); - expect(body.backendLabel).toBe('CLIProxy'); + expect(body.backend).toBe('plus'); + expect(body.backendLabel).toBe('CLIProxy Plus'); expect(body.currentVersion).toBe('6.6.80'); expect(body.latestVersion).toBe('6.6.89'); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts index 538152a4..3a22363c 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -129,7 +129,7 @@ describe('cliproxy-stats-routes model update canonicalization', () => { expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); }); - it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => { + it('preserves Codex effort suffixes while syncing linked core model env vars', async () => { const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json'); writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', @@ -148,14 +148,14 @@ describe('cliproxy-stats-routes model update canonicalization', () => { expect(response.status).toBe(200); const body = (await response.json()) as { model: string }; - expect(body.model).toBe('gpt-5.3-codex'); + expect(body.model).toBe('gpt-5.3-codex-xhigh'); const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { env: Record; }; - expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); }); diff --git a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts index bf70d518..ec3d9b06 100644 --- a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts +++ b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts @@ -154,6 +154,83 @@ describe('settings-routes model canonicalization', () => { expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); }); + it('preserves codex effort suffixes on PUT /:profile while still normalizing legacy aliases', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/codex`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5-codex-high', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + }, + presets: [ + { + name: 'legacy-codex', + default: 'gpt-5.3-codex-xhigh', + opus: 'gpt-5-codex-high', + sonnet: 'gpt-5.3-codex-high', + haiku: 'gpt-5-mini-medium', + }, + ], + }, + }), + }); + expect(response.status).toBe(200); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + presets: Array>; + }; + + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.presets[0]?.opus).toBe('gpt-5.4-high'); + expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high'); + expect(persisted.presets[0]?.haiku).toBe('gpt-5.4-mini-medium'); + }); + + it('preserves codex effort suffixes on GET /:profile/raw canonicalization', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5-codex-high', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/codex/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { settings: { env: Record } }; + expect(body.settings.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high'); + expect(body.settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(body.settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(body.settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + }); + it('canonicalizes AGY preset values on POST /:profile/presets', async () => { const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); writeSettings(settingsPath, { diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index b221b8a6..cb8ff68b 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -19,7 +19,13 @@ import { useTranslation } from 'react-i18next'; import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy'; import { usePrivacy } from '@/contexts/privacy-context'; import { formatAccountDisplayName } from '@/lib/account-identity'; -import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; +import { + CLIPROXY_PROVIDERS, + CLIPROXY_PROVIDER_SECTIONS, + getProviderDisplayName, + getProviderSection, + isPlusExtraProvider, +} from '@/lib/provider-config'; import { isDeniedAgyModelId } from '@/lib/utils'; const singleProviderSchema = z.object({ @@ -104,6 +110,8 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { }); const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' }); + const compositeTiers = useWatch({ control: compositeForm.control, name: 'tiers' }); + const selectedProviderSection = getProviderSection(selectedProvider); const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider); const providerAccounts = providerAuth?.accounts || []; @@ -197,10 +205,16 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} {singleForm.formState.errors.provider && ( @@ -208,6 +222,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { {singleForm.formState.errors.provider.message} )} + {selectedProviderSection && ( +

+ {t(selectedProviderSection.hintKey)} + {isPlusExtraProvider(selectedProvider) + ? ` ${t('providerConfig.plusTrackNote')}` + : ''} +

+ )} {selectedProvider && providerAccounts.length > 0 && ( @@ -297,12 +319,26 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { {...compositeForm.register(`tiers.${tier}.provider`)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {compositeTiers?.[tier]?.provider && ( +

+ {t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')} + {isPlusExtraProvider(compositeTiers[tier].provider) + ? ` ${t('providerConfig.plusTrackNote')}` + : ''} +

+ )}
diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 9274e975..76540428 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -3,7 +3,7 @@ * Phase 05: Dashboard UI full CRUD for composite variants */ -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { useEffect } from 'react'; @@ -15,7 +15,13 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { useUpdateVariant } from '@/hooks/use-cliproxy'; -import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; +import { + CLIPROXY_PROVIDERS, + CLIPROXY_PROVIDER_SECTIONS, + getProviderDisplayName, + getProviderSection, + isPlusExtraProvider, +} from '@/lib/provider-config'; import type { UpdateVariant, Variant } from '@/lib/api-client'; import { isDeniedAgyModelId } from '@/lib/utils'; @@ -137,6 +143,8 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit const compositeForm = useForm({ resolver: zodResolver(compositeSchema), }); + const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' }); + const compositeTiers = useWatch({ control: compositeForm.control, name: 'tiers' }); // Pre-populate form when variant changes useEffect(() => { @@ -318,12 +326,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit {...compositeForm.register(`tiers.${tier}.provider`)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {compositeTiers?.[tier]?.provider && ( +

+ {t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')} + {isPlusExtraProvider(compositeTiers[tier].provider) + ? ` ${t('providerConfig.plusTrackNote')}` + : ''} +

+ )}
@@ -399,12 +421,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit {...singleForm.register('provider')} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {selectedProvider && ( +

+ {t(getProviderSection(selectedProvider)?.hintKey || '')} + {isPlusExtraProvider(selectedProvider) + ? ` ${t('providerConfig.plusTrackNote')}` + : ''} +

+ )}
diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 72b3d7ef..65be5b3b 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -304,6 +304,7 @@ export function ProviderEditor({ Codex tip: suffixes -medium, -high, and -xhigh{' '} - pin reasoning effort. Unsuffixed models use Thinking settings. + pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use + Thinking settings.

)}
diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx index 2ea26cdf..726ad564 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -10,11 +10,13 @@ import { Info, Shield } from 'lucide-react'; import { UsageCommand } from './usage-command'; import type { SettingsResponse } from './types'; import type { AuthStatus, CliTarget } from '@/lib/api-client'; +import { getProviderSection, isPlusExtraProvider } from '@/lib/provider-config'; import { useTranslation } from 'react-i18next'; interface ProviderInfoTabProps { provider: string; displayName: string; + baseProvider?: string; defaultTarget?: CliTarget; data?: SettingsResponse; authStatus: AuthStatus; @@ -24,6 +26,7 @@ interface ProviderInfoTabProps { export function ProviderInfoTab({ provider, displayName, + baseProvider, defaultTarget, data, authStatus, @@ -33,6 +36,8 @@ export function ProviderInfoTab({ const resolvedTarget = defaultTarget || 'claude'; const isDroidTarget = resolvedTarget === 'droid'; const isCodexProvider = provider === 'codex'; + const sectionProvider = baseProvider || authStatus.provider || provider; + const providerSection = getProviderSection(sectionProvider); const managementPrefix = resolvedTarget === 'claude' ? `ccs ${provider}` : `ccs ${provider} --target claude`; const changeModelCommand = `${managementPrefix} --config`; @@ -101,6 +106,22 @@ export function ProviderInfoTab({ {resolvedTarget}
+ {providerSection && ( +
+ + {t('providerConfig.trackLabel')} + +
+ {t(providerSection.labelKey)} +

+ {t(providerSection.hintKey)} + {isPlusExtraProvider(sectionProvider) + ? ` ${t('providerConfig.plusTrackNote')}` + : ''} +

+
+
+ )}
diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 75a2977e..471bd71a 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -12,7 +12,8 @@ import { Badge } from '@/components/ui/badge'; import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; import type { CliproxyProviderRoutingHints } from '@/lib/api-client'; -import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import type { CodexEffort } from '@/lib/codex-effort'; +import { getCodexEffortDisplay, getCodexEffortVariants } from '@/lib/codex-effort'; import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs'; import { cn } from '@/lib/utils'; @@ -35,6 +36,8 @@ export interface ModelEntry { sonnet: string; haiku: string; }; + /** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */ + codexMaxEffort?: CodexEffort; } /** Provider catalog */ @@ -313,6 +316,14 @@ function getPreferredOptionValue( return routingHint?.recommendedModelId ?? modelId; } +function getModelOptionValues( + codexMaxEffort: CodexEffort | undefined, + optionValue: string, + isCodexProvider: boolean +): string[] { + return isCodexProvider ? getCodexEffortVariants(optionValue, codexMaxEffort) : [optionValue]; +} + export function FlexibleModelSelector({ label, description, @@ -342,55 +353,64 @@ export function FlexibleModelSelector({ const recommendedOptionValues = useMemo( () => new Set( - resolvedCatalogModels.map((model) => - getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) + resolvedCatalogModels.flatMap((model) => + getModelOptionValues( + model.codexMaxEffort, + getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), + isCodexProvider + ) ) ), - [resolvedCatalogModels, routingHints] + [isCodexProvider, resolvedCatalogModels, routingHints] ); const selectedRoutingHint = useMemo( () => routingHints.get(normalizeModelValue(value, routing).toLowerCase()), [routing, routingHints, value] ); - const recommendedOptions = resolvedCatalogModels.map((model) => ({ - value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), - groupKey: 'recommended', - searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, - keywords: [model.tier ?? '', catalog?.provider ?? ''], - triggerContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( - - {routingHints.get(model.id.toLowerCase())?.prefix} - - ) : null} - {isCodexProvider && } -
- ), - itemContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {model.tier === 'paid' && } - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( - - {t('providerModelSelector.shadowed')} - - ) : null} - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( - - {t('providerModelSelector.prefixOnly')} - - ) : null} - {isCodexProvider && } -
- ), - })); + const recommendedOptions = resolvedCatalogModels.flatMap((model) => { + const routingHint = routingHints.get(model.id.toLowerCase()); + const optionValues = getModelOptionValues( + model.codexMaxEffort, + getPreferredOptionValue(model.id, routingHint), + isCodexProvider + ); + + return optionValues.map((optionValue) => ({ + value: optionValue, + groupKey: 'recommended', + searchText: `${optionValue} ${model.id} ${model.name} ${routingHint?.recommendedModelId ?? ''}`, + keywords: [model.tier ?? '', catalog?.provider ?? ''], + triggerContent: ( +
+ {optionValue} + {routingHint?.pinnedAvailable ? ( + + {routingHint.prefix} + + ) : null} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {optionValue} + {model.tier === 'paid' && } + {routingHint?.unprefixedStatus === 'shadowed' ? ( + + {t('providerModelSelector.shadowed')} + + ) : null} + {routingHint?.unprefixedStatus === 'prefix-only' ? ( + + {t('providerModelSelector.prefixOnly')} + + ) : null} + {isCodexProvider && } +
+ ), + })); + }); const allModelOptions = supplementalModels .filter((model) => !catalogModelIds.has(model.id)) @@ -400,43 +420,48 @@ export function FlexibleModelSelector({ getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) ) ) - .map((model) => ({ - value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), - groupKey: 'all', - searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, - keywords: [model.owned_by], - triggerContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( - - {routingHints.get(model.id.toLowerCase())?.prefix} - - ) : null} - {isCodexProvider && } -
- ), - itemContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( - - {t('providerModelSelector.shadowed')} - - ) : null} - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( - - {t('providerModelSelector.prefixOnly')} - - ) : null} - {isCodexProvider && } -
- ), - })); + .flatMap((model) => { + const routingHint = routingHints.get(model.id.toLowerCase()); + const optionValues = getModelOptionValues( + undefined, + getPreferredOptionValue(model.id, routingHint), + isCodexProvider + ); + + return optionValues.map((optionValue) => ({ + value: optionValue, + groupKey: 'all', + searchText: `${optionValue} ${model.id} ${routingHint?.recommendedModelId ?? ''}`, + keywords: [model.owned_by], + triggerContent: ( +
+ {optionValue} + {routingHint?.pinnedAvailable ? ( + + {routingHint.prefix} + + ) : null} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {optionValue} + {routingHint?.unprefixedStatus === 'shadowed' ? ( + + {t('providerModelSelector.shadowed')} + + ) : null} + {routingHint?.unprefixedStatus === 'prefix-only' ? ( + + {t('providerModelSelector.prefixOnly')} + + ) : null} + {isCodexProvider && } +
+ ), + })); + }); const selectedValueMissing = Boolean(value) && !recommendedOptions.some((option) => option.value === value) && diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 0a8bc075..38e43778 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -234,14 +234,21 @@ export interface UpdateImageAnalysisSettingsPayload { profileBackends?: Record; } +export type BrowserToolPolicy = 'auto' | 'manual'; +export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; + export interface BrowserSettingsConfig { claude: { enabled: boolean; + policy: BrowserToolPolicy; userDataDir: string; devtoolsPort: number; + evalMode: BrowserEvalMode; }; codex: { enabled: boolean; + policy: BrowserToolPolicy; + evalMode: BrowserEvalMode; }; } @@ -253,6 +260,7 @@ export interface BrowserLaunchCommands { 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'; @@ -262,6 +270,7 @@ export interface ClaudeBrowserStatus { effectiveUserDataDir: string; recommendedUserDataDir: string; devtoolsPort: number; + evalMode: BrowserEvalMode; managedMcpServerName: string; managedMcpServerPath: string; launchCommands: BrowserLaunchCommands; @@ -270,11 +279,13 @@ export interface ClaudeBrowserStatus { export interface CodexBrowserStatus { enabled: boolean; + policy: BrowserToolPolicy; state: 'disabled' | 'enabled' | 'unsupported_build'; title: string; detail: string; nextStep: string; serverName: string; + evalMode: BrowserEvalMode; supportsConfigOverrides: boolean; binaryPath: string | null; version?: string; diff --git a/ui/src/lib/codex-effort.ts b/ui/src/lib/codex-effort.ts index 9f3b26f2..ff2d311a 100644 --- a/ui/src/lib/codex-effort.ts +++ b/ui/src/lib/codex-effort.ts @@ -1,14 +1,56 @@ export type CodexEffort = 'medium' | 'high' | 'xhigh'; const CODEX_EFFORT_SUFFIX_REGEX = /-(medium|high|xhigh)$/i; +const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = ['medium', 'high', 'xhigh']; + +function trimModelId(modelId: string | undefined): string { + return modelId?.trim() ?? ''; +} export function parseCodexEffort(modelId: string | undefined): CodexEffort | undefined { if (!modelId) return undefined; - const match = modelId.trim().match(CODEX_EFFORT_SUFFIX_REGEX); + const match = trimModelId(modelId).match(CODEX_EFFORT_SUFFIX_REGEX); if (!match?.[1]) return undefined; return match[1].toLowerCase() as CodexEffort; } +export function stripCodexEffortSuffix(modelId: string | undefined): string { + return trimModelId(modelId).replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +} + +export function applyCodexEffortSuffix( + modelId: string | undefined, + effort: CodexEffort | undefined +): string { + const normalizedModelId = stripCodexEffortSuffix(modelId); + if (!normalizedModelId || !effort) { + return normalizedModelId; + } + return `${normalizedModelId}-${effort}`; +} + +export function getCodexEffortVariants( + modelId: string, + maxEffort: CodexEffort | undefined +): string[] { + if (!maxEffort) { + const explicitEffort = parseCodexEffort(modelId); + return [applyCodexEffortSuffix(modelId, explicitEffort)]; + } + + const normalizedModelId = stripCodexEffortSuffix(modelId); + const variantIds = [normalizedModelId]; + + for (const effort of CODEX_EFFORTS_IN_ORDER) { + variantIds.push(applyCodexEffortSuffix(normalizedModelId, effort)); + if (effort === maxEffort) { + break; + } + } + + return variantIds; +} + export function getCodexEffortDisplay( modelId: string | undefined, effortLabels?: { pinned: (effort: string) => string; auto: string } diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 5329df3a..53f26f36 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -977,10 +977,13 @@ const resources = { backendBinary: 'Backend Binary', stopProxyToSwitch: 'Stop the running proxy in Instance Status to switch backend.', default: 'Default', - plusDesc: 'Full provider support including Kiro and GitHub Copilot', - originalDesc: 'Original binary (Gemini, Codex, Antigravity only)', + plusDesc: + 'Optional track for extra providers. Still supported, but currently community-maintained instead of upstream-maintained.', + originalDesc: 'Default, always-available backend for the core provider track.', + plusFallbackNotice: + 'The Plus provider track is not deprecated, but local CLIProxy still falls back to the original backend while the maintained fork path is being brought back.', variantsIncompatible: - 'Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to CLIProxyAPIPlus or remove those variants.', + 'Existing plus-extra variants ({{providers}}) will not run on the original backend. Keep them visible for reference, but switch to Plus before using them.', safety: 'Safety', agyModeTitle: 'Antigravity + Gemini Power User Mode', agyModeDesc: @@ -2234,6 +2237,13 @@ const resources = { }, providerConfig: { defaultDeviceCodeInstruction: 'Complete the authorization in your browser.', + trackLabel: 'Track', + sectionCoreLabel: 'Core / original backend', + sectionCoreHint: 'Default, always-available provider track', + sectionPlusLabel: 'Plus extras / community-maintained', + sectionPlusHint: 'Still supported, but separated from the default backend for now', + plusTrackNote: + 'Requires the optional Plus backend while that track remains community-maintained.', }, // ======================================== @@ -2435,6 +2445,14 @@ const resources = { nextStep: 'Next step', technicalDetails: 'Technical Details', diagnostics: 'Diagnostics', + evalMode: { + label: 'browser_eval access', + options: { + disabled: 'Disabled', + readonly: 'Read-only', + readwrite: 'Read/write', + }, + }, actions: { saveClaude: 'Save Claude settings', saveCodex: 'Save Codex settings', @@ -2461,6 +2479,8 @@ const resources = { effectivePath: 'Effective attach path', recommendedPath: 'Recommended path', managedRuntime: 'Managed browser runtime', + evalModeHint: + 'Controls browser_eval access for Claude Browser Attach. Read-only allows inspection; read/write also allows page-side mutations.', overrideMessage: 'An environment override is currently active from {{source}}. This dashboard remains the source of truth once that override is removed.', launchGuidance: 'Launch guidance', @@ -2480,6 +2500,8 @@ const resources = { overrideUnsupported: 'Not supported', binary: 'Detected Codex binary', notDetected: 'Not detected', + evalModeHint: + 'Stored for Browser settings parity. In Phase 1, browser_eval enforcement primarily applies to Claude Browser Attach.', }, }, }, @@ -3443,10 +3465,12 @@ const resources = { backendBinary: '后端二进制', stopProxyToSwitch: '请先在实例状态中停止正在运行的代理,再切换后端。', default: '默认', - plusDesc: '完整支持包括 Kiro 和 GitHub Copilot 在内的提供商', - originalDesc: '原版二进制(仅 Gemini、Codex、Antigravity)', + plusDesc: '额外提供商的可选线路。仍受支持,但目前由社区维护而非上游维护。', + originalDesc: '核心提供商线路的默认、始终可用后端。', + plusFallbackNotice: + 'Plus 提供商线路并未弃用,但在受维护的 fork 恢复之前,本地 CLIProxy 仍会回退到原始后端。', variantsIncompatible: - '现有 Kiro/Copilot 变体与 CLIProxyAPI 不兼容。请切换到 CLIProxyAPIPlus 或移除这些变体。', + '现有 plus 扩展变体({{providers}})无法在原始后端上运行。可以保留作参考,但使用前请切换到 Plus。', safety: '安全', agyModeTitle: 'Antigravity + Gemini 高级模式', agyModeDesc: '跳过 AGY 责任确认清单,以及 Gemini Dashboard 中输入风险短语的步骤。', @@ -4656,6 +4680,12 @@ const resources = { }, providerConfig: { defaultDeviceCodeInstruction: '请在浏览器中完成授权。', + trackLabel: '分组', + sectionCoreLabel: '核心 / 原始后端', + sectionCoreHint: '默认且始终可用的提供商线路', + sectionPlusLabel: 'Plus 扩展 / 社区维护', + sectionPlusHint: '仍然受支持,但目前与默认后端分开显示', + plusTrackNote: '需要可选的 Plus 后端;当前这条线路由社区维护。', }, profileEditorSections: { imageAnalysis: '图片分析', @@ -4850,6 +4880,14 @@ const resources = { nextStep: '下一步', technicalDetails: '技术细节', diagnostics: '诊断信息', + evalMode: { + label: 'browser_eval 权限', + options: { + disabled: '禁用', + readonly: '只读', + readwrite: '可读写', + }, + }, actions: { saveClaude: '保存 Claude 设置', saveCodex: '保存 Codex 设置', @@ -4873,6 +4911,8 @@ const resources = { effectivePath: '当前生效的 attach 路径', recommendedPath: '推荐路径', managedRuntime: '托管浏览器运行时', + evalModeHint: + '控制 Claude Browser Attach 中 browser_eval 的权限。只读仅允许读取,读写还允许页面侧修改。', overrideMessage: '当前存在来自 {{source}} 的环境变量覆盖。移除该覆盖后,Dashboard 配置将重新成为唯一来源。', launchGuidance: '启动指引', @@ -4889,6 +4929,8 @@ const resources = { overrideUnsupported: '不支持', binary: '检测到的 Codex 可执行文件', notDetected: '未检测到', + evalModeHint: + '为保持 Browser 设置页一致性而保存该配置。Phase 1 中,browser_eval 的实际限制主要作用于 Claude Browser Attach。', }, }, }, @@ -5934,10 +5976,13 @@ const resources = { stopProxyToSwitch: 'Dừng proxy đang chạy trong Trạng thái phiên bản trước khi chuyển backend.', default: 'Mặc định', - plusDesc: 'Hỗ trợ đầy đủ nhà cung cấp, bao gồm Kiro và GitHub Copilot', - originalDesc: 'Binary gốc (chỉ Gemini, Codex, Antigravity)', + plusDesc: + 'Nhánh tùy chọn cho các nhà cung cấp bổ sung. Vẫn được hỗ trợ nhưng hiện do cộng đồng duy trì thay vì upstream.', + originalDesc: 'Backend mặc định, luôn sẵn sàng cho nhóm nhà cung cấp cốt lõi.', + plusFallbackNotice: + 'Nhánh nhà cung cấp Plus chưa bị khai tử, nhưng CLIProxy cục bộ vẫn quay về backend gốc cho tới khi đường dẫn fork được duy trì được bật lại.', variantsIncompatible: - 'Các biến thể Kiro/Copilot hiện tại sẽ không hoạt động với CLIProxyAPI. Chuyển sang CLIProxyAPIPlus hoặc xóa các biến thể đó.', + 'Các biến thể plus-extra hiện có ({{providers}}) sẽ không chạy trên backend gốc. Có thể giữ lại để tham chiếu, nhưng hãy chuyển sang Plus trước khi dùng.', safety: 'An toàn', agyModeTitle: 'Chế độ power user Antigravity + Gemini', agyModeDesc: @@ -7170,6 +7215,12 @@ const resources = { }, providerConfig: { defaultDeviceCodeInstruction: 'Hoàn tất việc cấp quyền trong trình duyệt của bạn.', + trackLabel: 'Nhóm', + sectionCoreLabel: 'Core / backend gốc', + sectionCoreHint: 'Nhóm nhà cung cấp mặc định, luôn sẵn sàng', + sectionPlusLabel: 'Plus extras / cộng đồng duy trì', + sectionPlusHint: 'Vẫn được hỗ trợ nhưng hiện được tách khỏi backend mặc định', + plusTrackNote: 'Cần backend Plus tùy chọn; hiện tại nhánh này do cộng đồng duy trì.', }, profileEditorSections: { imageAnalysis: 'Phân tích hình ảnh', @@ -7367,6 +7418,14 @@ const resources = { nextStep: 'Bước tiếp theo', technicalDetails: 'Chi tiết kỹ thuật', diagnostics: 'Chẩn đoán', + evalMode: { + label: 'Quyền browser_eval', + options: { + disabled: 'Tắt', + readonly: 'Chỉ đọc', + readwrite: 'Đọc/ghi', + }, + }, actions: { saveClaude: 'Lưu cấu hình Claude', saveCodex: 'Lưu cấu hình Codex', @@ -7394,6 +7453,8 @@ const resources = { effectivePath: 'Đường dẫn attach đang có hiệu lực', recommendedPath: 'Đường dẫn khuyến nghị', managedRuntime: 'Runtime trình duyệt được quản lý', + evalModeHint: + 'Điều khiển quyền browser_eval cho Claude Browser Attach. Chỉ đọc cho phép quan sát; đọc/ghi cũng cho phép thay đổi phía trang.', overrideMessage: 'Hiện có override môi trường từ {{source}}. Khi bỏ override này, Dashboard sẽ lại là nguồn cấu hình chính.', launchGuidance: 'Hướng dẫn khởi chạy', @@ -7413,6 +7474,8 @@ const resources = { overrideUnsupported: 'Không được hỗ trợ', binary: 'Binary Codex được phát hiện', notDetected: 'Không phát hiện', + evalModeHint: + 'Được lưu để giữ tương thích giao diện Browser settings. Ở Phase 1, việc áp dụng hạn chế browser_eval chủ yếu diễn ra trên Claude Browser Attach.', }, }, }, @@ -8461,10 +8524,13 @@ const resources = { stopProxyToSwitch: 'バックエンドを切り替える前に、インスタンス状態から実行中のプロキシを停止してください。', default: 'デフォルト', - plusDesc: 'Kiro と GitHub Copilot を含むすべてのプロバイダーをサポート', - originalDesc: '元のバイナリ(Gemini、Codex、Antigravity のみ)', + plusDesc: + '追加プロバイダー向けのオプショントラックです。引き続きサポートされていますが、現在は upstream ではなくコミュニティ保守です。', + originalDesc: 'コアプロバイダートラック向けの既定かつ常時利用可能な backend。', + plusFallbackNotice: + 'Plus プロバイダートラックは廃止ではありませんが、保守中の fork が戻るまではローカル CLIProxy は引き続きオリジナル backend にフォールバックします。', variantsIncompatible: - '既存の Kiro/Copilot バリアントは CLIProxyAPI では動作しません。CLIProxyAPIPlus に切り替えるか、それらのバリアントを削除してください。', + '既存の plus-extra バリアント({{providers}})はオリジナル backend では動作しません。参照用に残すことはできますが、使用前に Plus へ切り替えてください。', safety: '安全設定', agyModeTitle: 'Antigravity + Gemini パワーユーザーモード', agyModeDesc: @@ -9791,6 +9857,14 @@ const resources = { nextStep: '次の手順', technicalDetails: '技術的な詳細', diagnostics: '診断情報', + evalMode: { + label: 'browser_eval 権限', + options: { + disabled: '無効', + readonly: '読み取り専用', + readwrite: '読み書き可', + }, + }, actions: { saveClaude: 'Claude 設定を保存', saveCodex: 'Codex 設定を保存', @@ -9818,6 +9892,8 @@ const resources = { effectivePath: '現在有効な attach パス', recommendedPath: '推奨パス', managedRuntime: '管理済みブラウザランタイム', + evalModeHint: + 'Claude Browser Attach における browser_eval の権限を制御します。読み取り専用は参照のみ、読み書き可はページ側の変更も許可します。', overrideMessage: '{{source}} から環境変数オーバーライドが有効です。これを外すと Dashboard の設定が再び主ソースになります。', launchGuidance: '起動ガイダンス', @@ -9837,6 +9913,8 @@ const resources = { overrideUnsupported: '非対応', binary: '検出された Codex バイナリ', notDetected: '未検出', + evalModeHint: + 'Browser 設定画面の整合性のために保存されます。Phase 1 では browser_eval の制御は主に Claude Browser Attach に適用されます。', }, }, }, @@ -9984,6 +10062,14 @@ const resources = { }, providerConfig: { defaultDeviceCodeInstruction: 'ブラウザーで認証を完了してください。', + trackLabel: 'トラック', + sectionCoreLabel: 'コア / オリジナル backend', + sectionCoreHint: '既定で常に利用できるプロバイダートラック', + sectionPlusLabel: 'Plus 拡張 / コミュニティ保守', + sectionPlusHint: + '引き続きサポートされていますが、当面は既定の backend から分離されています', + plusTrackNote: + 'このトラックはオプションの Plus backend が必要で、現在はコミュニティ保守です。', }, updatesSpotlight: { openUpdatesCenter: '更新センターを開く', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 025ddc01..5398645e 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -263,6 +263,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5-codex', name: 'GPT-5 Codex', description: 'Cross-plan safe Codex default', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5-codex', opus: 'gpt-5-codex', @@ -274,6 +275,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5-codex-mini', name: 'GPT-5 Codex Mini', description: 'Faster and cheaper Codex option', + codexMaxEffort: 'high', presetMapping: { default: 'gpt-5-codex-mini', opus: 'gpt-5-codex', @@ -285,6 +287,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5-mini', name: 'GPT-5 Mini', description: 'Legacy mini model ID kept for backwards compatibility', + codexMaxEffort: 'high', presetMapping: { default: 'gpt-5-mini', opus: 'gpt-5-codex', @@ -296,6 +299,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5.1-codex-mini', name: 'GPT-5.1 Codex Mini', description: 'Legacy fast Codex mini model', + codexMaxEffort: 'high', presetMapping: { default: 'gpt-5.1-codex-mini', opus: 'gpt-5.1-codex-max', @@ -307,6 +311,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5.1-codex-max', name: 'GPT-5.1 Codex Max', description: 'Higher-effort Codex model with xhigh support', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.1-codex-max', opus: 'gpt-5.1-codex-max', @@ -318,6 +323,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', description: 'Cross-plan Codex model with xhigh support', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.2-codex', opus: 'gpt-5.2-codex', @@ -330,6 +336,7 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.3 Codex', tier: 'paid', description: 'Paid Codex plans only', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.3-codex', opus: 'gpt-5.3-codex', @@ -342,6 +349,7 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.3 Codex Spark', tier: 'paid', description: 'Paid Codex plans only, ultra-fast coding model', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.3-codex-spark', opus: 'gpt-5.3-codex', @@ -354,6 +362,7 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.4', tier: 'paid', description: 'Paid Codex plans only, latest GPT-5 family model', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.4', opus: 'gpt-5.4', diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 3500e4a4..00c34cc7 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -10,6 +10,7 @@ import { getProvidersByOAuthFlow, } from '../../../src/cliproxy/provider-capabilities'; import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers'; +import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types'; import i18n from './i18n'; // Monorepo contract: UI consumes provider capability constants directly from backend @@ -19,6 +20,39 @@ import i18n from './i18n'; export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS; export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number]; export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex'; +export type CLIProxyProviderSectionId = 'core' | 'plus-extra'; + +export interface CLIProxyProviderSection { + id: CLIProxyProviderSectionId; + labelKey: string; + hintKey: string; + providers: readonly CLIProxyProvider[]; +} + +const PLUS_ONLY_PROVIDER_SET = new Set(PLUS_ONLY_PROVIDERS); + +export const CORE_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object.freeze( + CLIPROXY_PROVIDERS.filter((provider) => !PLUS_ONLY_PROVIDER_SET.has(provider)) +); + +export const PLUS_EXTRA_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object.freeze( + CLIPROXY_PROVIDERS.filter((provider) => PLUS_ONLY_PROVIDER_SET.has(provider)) +); + +export const CLIPROXY_PROVIDER_SECTIONS: readonly CLIProxyProviderSection[] = Object.freeze([ + { + id: 'core', + labelKey: 'providerConfig.sectionCoreLabel', + hintKey: 'providerConfig.sectionCoreHint', + providers: CORE_CLIPROXY_PROVIDERS, + }, + { + id: 'plus-extra', + labelKey: 'providerConfig.sectionPlusLabel', + hintKey: 'providerConfig.sectionPlusHint', + providers: PLUS_EXTRA_CLIPROXY_PROVIDERS, + }, +]); /** Check if a string is a backend-supported CLIProxy provider. */ export function isValidProvider(provider: string): provider is CLIProxyProvider { @@ -259,6 +293,52 @@ export function getProviderDisplayName(provider: unknown): string { return PROVIDER_NAMES[normalized] || i18n.t('toasts.providerUnknown', { provider: normalized }); } +export function isPlusExtraProvider(provider: unknown): boolean { + const normalized = normalizeProviderInput(provider); + return isValidProvider(normalized) && PLUS_ONLY_PROVIDER_SET.has(normalized); +} + +export function getProviderSection(provider: unknown): CLIProxyProviderSection | null { + const normalized = normalizeProviderInput(provider); + if (!isValidProvider(normalized)) { + return null; + } + + return ( + CLIPROXY_PROVIDER_SECTIONS.find((section) => section.providers.includes(normalized)) || null + ); +} + +interface VariantLike { + provider?: unknown; + tiers?: Record | null; +} + +export function variantUsesPlusExtraProvider(variant: VariantLike | null | undefined): boolean { + if (!variant) { + return false; + } + + if (variant.tiers) { + return Object.values(variant.tiers).some((tier) => isPlusExtraProvider(tier?.provider)); + } + + return isPlusExtraProvider(variant.provider); +} + +export function groupProvidersBySection( + items: readonly T[], + getProvider: (item: T) => unknown +): Array { + return CLIPROXY_PROVIDER_SECTIONS.map((section) => ({ + ...section, + items: items.filter((item) => { + const normalized = normalizeProviderInput(getProvider(item)); + return isValidProvider(normalized) && section.providers.includes(normalized); + }), + })).filter((section) => section.items.length > 0); +} + /** Map provider to user-facing short description */ export function getProviderDescription(provider: unknown): string { const normalized = normalizeProviderInput(provider); diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 6aba8344..962591e2 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -33,7 +33,11 @@ import { } from '@/hooks/use-cliproxy'; import type { AuthStatus, Variant } from '@/lib/api-client'; import { buildUiCatalogs } from '@/lib/model-catalogs'; -import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config'; +import { + getProviderDisplayName, + groupProvidersBySection, + isValidProvider, +} from '@/lib/provider-config'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; @@ -261,6 +265,10 @@ export function CliproxyPage() { }); const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]); + const providerSections = useMemo( + () => groupProvidersBySection(providers, (status) => status.provider), + [providers] + ); const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); @@ -393,14 +401,28 @@ export function CliproxyPage() { ))} ) : ( -
- {providers.map((status) => ( - handleSelectProvider(status.provider)} - /> +
+ {providerSections.map((section) => ( +
+
+
+ {t(section.labelKey)} +
+

+ {t(section.hintKey)} +

+
+
+ {section.items.map((status) => ( + handleSelectProvider(status.provider)} + /> + ))} +
+
))}
)} diff --git a/ui/src/pages/settings/sections/browser/index.tsx b/ui/src/pages/settings/sections/browser/index.tsx index 6c997257..b32c2cd2 100644 --- a/ui/src/pages/settings/sections/browser/index.tsx +++ b/ui/src/pages/settings/sections/browser/index.tsx @@ -2,29 +2,36 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { motion, AnimatePresence } from 'framer-motion'; import { - Browser, - Gear, - CheckCircle, - WarningCircle, - XCircle, + AlertCircle, ArrowRight, - ClipboardText, - ArrowsClockwise, - TerminalWindow, - CaretDown, + CheckCircle2, + ChevronDown, + Clipboard, Info, -} from '@phosphor-icons/react'; + Monitor, + RefreshCw, + Settings, + Terminal, + XCircle, +} from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { getClientPlatformKey } from '@/lib/platform'; import { cn } from '@/lib/utils'; import { useBrowserConfig, useRawConfig } from '../../hooks'; -import type { BrowserConfig } from '../../types'; +import type { BrowserConfig, BrowserEvalMode } from '../../types'; // --- Constants & Helpers --- @@ -35,6 +42,12 @@ function parsePortDraft(value: string): number | null { return port; } +const BROWSER_EVAL_MODE_OPTIONS: BrowserEvalMode[] = ['disabled', 'readonly', 'readwrite']; + +function getBrowserEvalModeLabel(t: ReturnType['t'], mode: BrowserEvalMode) { + return t(`settingsPage.browserSection.evalMode.options.${mode}`); +} + function buildLaunchCommand( userDataDir: string, devtoolsPort: number, @@ -142,11 +155,11 @@ function StatusStrip({ )} > {isReady ? ( - + ) : isError ? ( - + ) : ( - + )}
@@ -205,10 +218,10 @@ function DiagnosticsSection({
@@ -389,12 +406,12 @@ export default function BrowserSection() { > {error ? (
- + {error}
) : (
- + {actionMessage ?? t('commonToast.settingsSaved')}
)} @@ -413,7 +430,7 @@ export default function BrowserSection() {
- +

@@ -431,7 +448,7 @@ export default function BrowserSection() { onClick={refreshAll} disabled={saving || loading} > - + {t('settings.refresh')} @@ -442,7 +459,7 @@ export default function BrowserSection() { transition={{ delay: 0.1 }} > - + {t('settingsPage.browserSection.primaryTitle')} @@ -486,9 +503,9 @@ export default function BrowserSection() { className="rounded-full px-6 shadow-md transition-transform active:scale-95" > {saving ? ( - + ) : ( - + )} {t('settingsPage.browserSection.actions.saveClaude')} @@ -498,7 +515,7 @@ export default function BrowserSection() { onClick={refreshStatus} disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid} > - + {t('settingsPage.browserSection.actions.testConnection')}

@@ -564,6 +581,36 @@ export default function BrowserSection() { : t('settingsPage.browserSection.claude.devtoolsPortHint')}

+ +
+ + +

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

+
@@ -584,7 +631,7 @@ export default function BrowserSection() { onClick={copyLaunchCommand} disabled={!preferredLaunchCommand} > - +
@@ -594,7 +641,7 @@ export default function BrowserSection() { {status.claude.overrideActive && (
- + {t('settingsPage.browserSection.claude.overrideMessage', { source: status.claude.source, })} @@ -637,6 +684,16 @@ export default function BrowserSection() {

+
+

+ {t('settingsPage.browserSection.evalMode.label')} +

+
+

+ {getBrowserEvalModeLabel(t, status.claude.evalMode)} +

+
+
@@ -677,9 +734,9 @@ export default function BrowserSection() { className="rounded-full px-6 shadow-md transition-transform active:scale-95" > {saving ? ( - + ) : ( - + )} {t('settingsPage.browserSection.actions.saveCodex')} @@ -694,6 +751,36 @@ export default function BrowserSection() { nextStep={status.codex.nextStep} /> +
+ + +

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

+
+
{status.codex.supportsConfigOverrides ? ( - + ) : ( - + )} {status.codex.supportsConfigOverrides ? t('settingsPage.browserSection.codex.overrideSupported') @@ -740,6 +827,16 @@ export default function BrowserSection() { )}
+
+

+ {t('settingsPage.browserSection.evalMode.label')} +

+
+

+ {getBrowserEvalModeLabel(t, status.codex.evalMode)} +

+
+
diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 76ec80e1..441de0f8 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -29,15 +29,18 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; import { api } from '@/lib/api-client'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; +import { + CORE_CLIPROXY_PROVIDERS, + PLUS_EXTRA_CLIPROXY_PROVIDERS, + getProviderDisplayName, + variantUsesPlusExtraProvider, +} from '@/lib/provider-config'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; /** LocalStorage key for debug mode preference */ const DEBUG_MODE_KEY = 'ccs_debug_mode'; -/** Providers only available on CLIProxyAPIPlus */ -const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp', 'cursor', 'gitlab', 'codebuddy', 'kilo']; - function normalizeRiskAckPhrase(value: string): string { return value.trim().replace(/\s+/g, ' ').toUpperCase(); } @@ -196,6 +199,8 @@ export default function ProxySection() { const updateBackendMutation = useUpdateBackend(); const { data: proxyStatus } = useProxyStatus(); const isProxyRunning = proxyStatus?.running ?? false; + const coreProviderNames = CORE_CLIPROXY_PROVIDERS.map(getProviderDisplayName).join(', '); + const plusProviderNames = PLUS_EXTRA_CLIPROXY_PROVIDERS.map(getProviderDisplayName).join(', '); // Fetch backend setting const fetchBackend = useCallback(async () => { @@ -211,7 +216,9 @@ export default function ProxySection() { const checkPlusOnlyVariants = useCallback(async () => { try { const result = await api.cliproxy.list(); - const hasIncompatible = result.variants.some((v) => PLUS_ONLY_PROVIDERS.includes(v.provider)); + const hasIncompatible = result.variants.some((variant) => + variantUsesPlusExtraProvider(variant) + ); setHasKiroGhcpVariants(hasIncompatible); } catch (err) { console.error('[Proxy] Failed to check variants:', err); @@ -492,6 +499,9 @@ export default function ProxySection() { {t('settingsProxy.backendPlusApi')}

{t('settingsProxy.plusDesc')}

+

+ {plusProviderNames} +

{/* Original Backend Card */} @@ -511,14 +521,16 @@ export default function ProxySection() {

{t('settingsProxy.originalDesc')}

+

+ {coreProviderNames} +

{backend === 'plus' && ( - CLIProxyAPIPlus upstream is currently unavailable. Local CLIProxy will use the - original backend until issue #1062 is resolved. + {t('settingsProxy.plusFallbackNotice')} )} @@ -526,7 +538,9 @@ export default function ProxySection() { {backend === 'original' && hasKiroGhcpVariants && ( - {t('settingsProxy.variantsIncompatible')} + + {t('settingsProxy.variantsIncompatible', { providers: plusProviderNames })} + )} diff --git a/ui/src/pages/settings/sections/thinking/index.tsx b/ui/src/pages/settings/sections/thinking/index.tsx index d4c57e74..1fd52591 100644 --- a/ui/src/pages/settings/sections/thinking/index.tsx +++ b/ui/src/pages/settings/sections/thinking/index.tsx @@ -32,6 +32,7 @@ const THINKING_LEVELS = [ { value: 'medium', label: 'Medium (8K tokens)' }, { value: 'high', label: 'High (24K tokens)' }, { value: 'xhigh', label: 'Extra High (32K tokens)' }, + { value: 'max', label: 'Max (adaptive ceiling)' }, { value: 'auto', label: 'Auto (dynamic)' }, ]; diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index a8765ee7..9784c8dc 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -4,6 +4,8 @@ */ import type { + BrowserEvalMode, + BrowserToolPolicy, BrowserSettingsConfig, BrowserStatusPayload, CliproxyServerConfig, @@ -198,7 +200,7 @@ export interface ThinkingConfig { // === Re-exports from api-client === -export type { CliproxyServerConfig, RemoteProxyStatus }; +export type { BrowserEvalMode, BrowserToolPolicy, CliproxyServerConfig, RemoteProxyStatus }; export type BrowserConfig = BrowserSettingsConfig; export type BrowserStatus = BrowserStatusPayload; export type BrowserSavePayload = UpdateBrowserSettingsPayload; diff --git a/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx index 9d71278d..1c518ba6 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx @@ -51,4 +51,48 @@ describe('ProviderInfoTab', () => { expect(screen.queryByText('Change model')).not.toBeInTheDocument(); expect(screen.getByText('ccs custom-provider --auth --add')).toBeInTheDocument(); }); + + it('shows the plus-extra track note for community-maintained providers', () => { + render( + + ); + + expect(screen.getByText('Track')).toBeInTheDocument(); + expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument(); + expect( + screen.getByText( + /Requires the optional Plus backend while that track remains community-maintained\./ + ) + ).toBeInTheDocument(); + }); + + it('uses the base provider when rendering variant track metadata', () => { + render( + + ); + + expect(screen.getByText('Track')).toBeInTheDocument(); + expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument(); + }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx index c1546fb2..25c3e7ab 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx @@ -151,4 +151,49 @@ describe('useProviderEditor', () => { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }); }); + + it('preserves explicit codex effort suffixes in editor state updates', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/codex/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'codex', + settings: { + env: { + ANTHROPIC_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', + }, + }, + mtime: 1, + path: '~/.ccs/codex.settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('codex'), { wrapper }); + + await waitFor(() => expect(result.current.currentModel).toBe('gpt-5.3-codex-high')); + + act(() => { + result.current.updateEnvValue('ANTHROPIC_MODEL', 'gpt-5.3-codex-xhigh'); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env).toMatchObject({ + ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', + }); + }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index 3b8b94b1..d7c15e75 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FlexibleModelSelector } from '@/components/cliproxy/provider-model-selector'; -import { buildUiCatalogs } from '@/lib/model-catalogs'; +import { MODEL_CATALOGS, buildUiCatalogs } from '@/lib/model-catalogs'; import { render, screen, userEvent } from '@tests/setup/test-utils'; const noisyAgyModels = [ @@ -87,4 +87,68 @@ describe('FlexibleModelSelector', () => { expect(screen.getAllByText('gemini-3.1-pro-preview').length).toBeGreaterThan(0); expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument(); }); + + it('offers codex effort-suffixed variants as first-class selectable options', async () => { + const onChange = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /select model/i })); + + expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument(); + expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('gpt-5.3-codex-high')); + expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high'); + }); + + it('does not relegate saved codex effort variants to the legacy current-value fallback', async () => { + render( + + ); + + expect(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i })); + + expect(screen.queryByText('Current value')).not.toBeInTheDocument(); + expect(screen.getByText('gpt-5.3-codex')).toBeInTheDocument(); + expect(screen.getAllByText('gpt-5.3-codex-high').length).toBeGreaterThan(0); + }); + + it('preserves explicit suffixes on supplemental codex models outside the static catalog', async () => { + const onChange = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /select model/i })); + + expect(screen.getByText(/All Models \(1\)/i)).toBeInTheDocument(); + expect(screen.getByText('gpt-5.5-codex-high')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('gpt-5.5-codex-high')); + expect(onChange).toHaveBeenCalledWith('gpt-5.5-codex-high'); + }); }); diff --git a/ui/tests/unit/pages/cliproxy-page.test.tsx b/ui/tests/unit/pages/cliproxy-page.test.tsx index bd651bfb..92b57035 100644 --- a/ui/tests/unit/pages/cliproxy-page.test.tsx +++ b/ui/tests/unit/pages/cliproxy-page.test.tsx @@ -2,6 +2,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, userEvent } from '@tests/setup/test-utils'; const hookState = vi.hoisted(() => ({ + authData: { + authStatus: [ + { + provider: 'gemini', + displayName: 'Gemini', + authenticated: true, + accounts: [{ id: 'acct-1', provider: 'gemini' }], + }, + { + provider: 'ghcp', + displayName: 'GitHub Copilot (OAuth)', + authenticated: false, + accounts: [], + }, + ], + source: 'local' as const, + }, catalogData: undefined as | { catalogs: Record< @@ -23,17 +40,7 @@ vi.mock('@/hooks/use-cliproxy', () => ({ isFetching: false, }), useCliproxyAuth: () => ({ - data: { - authStatus: [ - { - provider: 'gemini', - displayName: 'Gemini', - authenticated: true, - accounts: [{ id: 'acct-1', provider: 'gemini' }], - }, - ], - source: 'local', - }, + data: hookState.authData, isLoading: false, }), useCliproxyCatalog: () => ({ @@ -85,9 +92,39 @@ import { CliproxyPage } from '@/pages/cliproxy'; describe('CliproxyPage add-account catalog gating', () => { beforeEach(() => { + hookState.authData = { + authStatus: [ + { + provider: 'gemini', + displayName: 'Gemini', + authenticated: true, + accounts: [{ id: 'acct-1', provider: 'gemini' }], + }, + { + provider: 'ghcp', + displayName: 'GitHub Copilot (OAuth)', + authenticated: false, + accounts: [], + }, + ], + source: 'local', + }; hookState.catalogData = undefined; }); + it('separates core providers from plus extras in the sidebar', () => { + render(); + + expect(screen.getByText('Core / original backend')).toBeInTheDocument(); + expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument(); + expect(screen.getByText('Default, always-available provider track')).toBeInTheDocument(); + expect( + screen.getByText('Still supported, but separated from the default backend for now') + ).toBeInTheDocument(); + expect(screen.getByText('Gemini')).toBeInTheDocument(); + expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); + }); + it('does not pass a static fallback catalog before the catalog query resolves', async () => { render(); diff --git a/ui/tests/unit/ui/lib/codex-effort.test.ts b/ui/tests/unit/ui/lib/codex-effort.test.ts index 965d1963..90b30e3e 100644 --- a/ui/tests/unit/ui/lib/codex-effort.test.ts +++ b/ui/tests/unit/ui/lib/codex-effort.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { getCodexEffortDisplay, parseCodexEffort } from '@/lib/codex-effort'; +import { + applyCodexEffortSuffix, + getCodexEffortDisplay, + getCodexEffortVariants, + parseCodexEffort, + stripCodexEffortSuffix, +} from '@/lib/codex-effort'; describe('parseCodexEffort', () => { it('parses lowercase suffixes', () => { @@ -38,3 +44,25 @@ describe('getCodexEffortDisplay', () => { expect(getCodexEffortDisplay(undefined)).toBeNull(); }); }); + +describe('codex effort helpers', () => { + it('strips and reapplies codex effort suffixes', () => { + expect(stripCodexEffortSuffix('gpt-5.3-codex-high')).toBe('gpt-5.3-codex'); + expect(applyCodexEffortSuffix('gpt-5.3-codex-high', 'xhigh')).toBe('gpt-5.3-codex-xhigh'); + expect(applyCodexEffortSuffix('gpt-5.3-codex', undefined)).toBe('gpt-5.3-codex'); + }); + + it('builds ordered codex effort variants up to the supported max level', () => { + expect(getCodexEffortVariants('gpt-5.3-codex', 'xhigh')).toEqual([ + 'gpt-5.3-codex', + 'gpt-5.3-codex-medium', + 'gpt-5.3-codex-high', + 'gpt-5.3-codex-xhigh', + ]); + expect(getCodexEffortVariants('gpt-5.4-mini', 'high')).toEqual([ + 'gpt-5.4-mini', + 'gpt-5.4-mini-medium', + 'gpt-5.4-mini-high', + ]); + }); +}); diff --git a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts index 8e302458..b8ceaf9a 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts @@ -6,8 +6,11 @@ describe('codex model catalog defaults', () => { const codexCatalog = MODEL_CATALOGS.codex; const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex'); const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex'); + const codexMini = codexCatalog.models.find((model) => model.id === 'gpt-5-codex-mini'); expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini'); expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini'); + expect(codex53?.codexMaxEffort).toBe('xhigh'); + expect(codexMini?.codexMaxEffort).toBe('high'); }); }); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index b33f24b1..f83ede63 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -1,15 +1,22 @@ import { describe, expect, it } from 'vitest'; import { + CLIPROXY_PROVIDER_SECTIONS, + CORE_CLIPROXY_PROVIDERS, formatRequestedUpstreamModelRules, getProviderDescription, getProviderDisplayName, getProviderFallbackVisual, getProviderLogoAsset, + getProviderSection, getRequestedUpstreamModelRuleErrors, getRequestedModelId, + groupProvidersBySection, + isPlusExtraProvider, parseRequestedUpstreamModelRules, + PLUS_EXTRA_CLIPROXY_PROVIDERS, PROVIDER_COLORS, + variantUsesPlusExtraProvider, } from '@/lib/provider-config'; describe('provider model mapping helpers', () => { @@ -49,6 +56,61 @@ describe('provider model mapping helpers', () => { }); describe('provider presentation metadata', () => { + it('splits providers into core and plus-extra sections', () => { + expect(CLIPROXY_PROVIDER_SECTIONS.map((section) => section.id)).toEqual(['core', 'plus-extra']); + expect(CORE_CLIPROXY_PROVIDERS).toContain('gemini'); + expect(CORE_CLIPROXY_PROVIDERS).toContain('kimi'); + expect(PLUS_EXTRA_CLIPROXY_PROVIDERS).toEqual([ + 'kiro', + 'ghcp', + 'cursor', + 'gitlab', + 'codebuddy', + 'kilo', + ]); + expect(getProviderSection('gitlab')?.id).toBe('plus-extra'); + expect(getProviderSection('gemini')?.id).toBe('core'); + expect(isPlusExtraProvider('cursor')).toBe(true); + expect(isPlusExtraProvider('gemini')).toBe(false); + }); + + it('groups provider-backed data by shared section metadata', () => { + const grouped = groupProvidersBySection( + [ + { provider: 'cursor', value: 'plus' }, + { provider: 'gemini', value: 'core' }, + ], + (entry) => entry.provider + ); + + expect(grouped).toHaveLength(2); + expect(grouped[0]?.id).toBe('core'); + expect(grouped[0]?.items.map((entry) => entry.provider)).toEqual(['gemini']); + expect(grouped[1]?.id).toBe('plus-extra'); + expect(grouped[1]?.items.map((entry) => entry.provider)).toEqual(['cursor']); + }); + + it('detects plus-extra providers inside composite variants', () => { + expect( + variantUsesPlusExtraProvider({ + provider: 'gemini', + tiers: { + opus: { provider: 'gemini' }, + sonnet: { provider: 'cursor' }, + }, + }) + ).toBe(true); + expect( + variantUsesPlusExtraProvider({ + provider: 'gemini', + tiers: { + opus: { provider: 'gemini' }, + sonnet: { provider: 'kimi' }, + }, + }) + ).toBe(false); + }); + it.each([ ['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'], ['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'],