mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 00:16:38 +00:00
Merge pull request #1080 from kaitranntt/kai/release/promote-dev-to-main-260424
feat: promote dev to stable release
This commit is contained in:
172 files changed
+23902
-2685
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
@@ -0,0 +1,138 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Issue 1058 UI Evidence</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #111111;
|
||||
--fg: #f5f1ea;
|
||||
--muted: #b8aea0;
|
||||
--card: #1a1714;
|
||||
--border: #3a332b;
|
||||
--accent: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 32px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font:
|
||||
15px/1.5 "SF Mono",
|
||||
"IBM Plex Sans",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header,
|
||||
.shot {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shot {
|
||||
margin-top: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shot-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="header">
|
||||
<p class="eyebrow">Issue 1058</p>
|
||||
<h1>Codex selector now exposes pinned effort variants</h1>
|
||||
<p class="muted">
|
||||
Capture environment: local dashboard at <code>http://127.0.0.1:3001/cliproxy</code>,
|
||||
light theme, Codex provider editor, Model Config tab.
|
||||
</p>
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<p class="eyebrow">What changed</p>
|
||||
<p>The selector now offers real codex model variants like <code>-high</code> and <code>-xhigh</code>.</p>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p class="eyebrow">Why it matters</p>
|
||||
<p>The dashboard can now emit the exact suffixed model IDs that runtime code already understands.</p>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p class="eyebrow">Review cue</p>
|
||||
<p>Look at the opened model picker: suffixed entries appear as first-class options with pinned-effort badges.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shot">
|
||||
<div class="shot-header">
|
||||
<p class="eyebrow">Targeted evidence</p>
|
||||
<h2>Codex provider editor model picker</h2>
|
||||
<p class="muted">
|
||||
The open selector shows canonical rows plus generated pinned variants such as
|
||||
<code>gpt-5.3-codex-high</code>, <code>gpt-5.3-codex-xhigh</code>, and
|
||||
<code>gpt-5.4-xhigh</code>.
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
src="./codex-selector-variants.png"
|
||||
alt="Codex provider editor with the model selector opened and suffixed effort variants visible."
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
+27
-32
@@ -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
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
+3
-1
@@ -35,6 +35,8 @@ package-lock.json
|
||||
.claude/active-plan
|
||||
.claude/agent-memory/
|
||||
.claude/plans-registry.json*
|
||||
CLAUDE.local.md
|
||||
AGENTS.override.md
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
@@ -43,4 +45,4 @@ logs/
|
||||
.dev-release-info.json
|
||||
|
||||
# Test coverage
|
||||
ui/coverage/
|
||||
ui/coverage/
|
||||
+8
-2
@@ -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"
|
||||
|
||||
@@ -41,6 +41,15 @@ gh pr checks <n>
|
||||
### Absolute rule
|
||||
AI MUST NOT declare a task done, close a session, or move to the next task while CI is red or still running. Leaving a PR red and moving on is the primary failure mode this protocol prevents.
|
||||
|
||||
### 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 <n>` — all checks green
|
||||
- [ ] If touching debt-sensitive code: `bun run maintainability:check:strict`
|
||||
- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json`
|
||||
- [ ] If UI changed: `cd ui && bun run format && bun run validate`
|
||||
- [ ] 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.)
|
||||
|
||||
+49
-5
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+77
-14
@@ -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=<port>`
|
||||
2. Make sure it is using the same `user_data_dir` configured in CCS
|
||||
3. Rerun `ccs browser doctor`
|
||||
1. Run `ccs browser setup`
|
||||
2. If needed, make sure Chrome was started with `--remote-debugging-port=<port>`
|
||||
3. Make sure it is using the same `user_data_dir` configured in CCS
|
||||
4. Rerun `ccs browser doctor`
|
||||
|
||||
For the CCS-managed default path, this is the normal first-run state after CCS bootstraps the
|
||||
directory for you.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+11
-21
@@ -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.<lane>.policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config.
|
||||
- **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default.
|
||||
- **2026-04-18**: **#1038** Legacy OpenAI-compatible provider writes no longer self-destruct on the next `ccs cliproxy restart`. CCS now preserves AI-provider-managed top-level sections such as `openai-compatibility` during CLIProxy config regeneration, and the legacy `openai-compat` manager now rewrites only its own YAML section instead of dumping the whole file and stripping the generated version header. Regression coverage now proves the legacy helper keeps the generated header intact and that OpenAI-compatible connectors survive regeneration.
|
||||
- **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor.
|
||||
- **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<bool
|
||||
### Overview
|
||||
|
||||
Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account.
|
||||
When CCS detects exhaustion and a healthy fallback exists, it also temporarily pauses the exhausted account out of CLIProxy rotation and automatically resumes that pause after the configured cooldown expires.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
@@ -293,6 +298,11 @@ Hybrid quota management enables automatic detection of exhausted accounts and fa
|
||||
+---> 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 <account> --> Set isPaused=true in account-manager
|
||||
|
||||
+5345
-42
File diff suppressed because it is too large.
Load diff
+5
-3
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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 <fast|slow|all>');
|
||||
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,
|
||||
};
|
||||
+89
-21
@@ -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<typeof resolveTargetType>
|
||||
target: ReturnType<typeof resolveTargetType>,
|
||||
browserLaunchOverride: BrowserLaunchOverride | undefined
|
||||
): string[] {
|
||||
if (target !== 'codex' || !getBrowserConfig().codex.enabled) {
|
||||
if (target !== 'codex') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const codexBrowserExposure = resolveBrowserExposure(
|
||||
getBrowserConfig().codex,
|
||||
browserLaunchOverride
|
||||
);
|
||||
if (!codexBrowserExposure.exposeForLaunch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return buildCodexBrowserMcpOverrides();
|
||||
}
|
||||
|
||||
@@ -449,6 +463,16 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
args = normalizeLegacyCursorArgs(args);
|
||||
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
try {
|
||||
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args);
|
||||
browserLaunchOverride = browserLaunchFlags.override;
|
||||
args = browserLaunchFlags.argsWithoutFlags;
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
cliLogger.info('command.start', 'CLI invocation started', {
|
||||
command: args[0] || 'default',
|
||||
@@ -697,7 +721,38 @@ async function main(): Promise<void> {
|
||||
|
||||
// For non-claude targets, verify target binary exists once and pass it through.
|
||||
const targetBinaryInfo = targetAdapter?.detectBinary() ?? null;
|
||||
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(resolvedTarget);
|
||||
const browserConfig = getBrowserConfig();
|
||||
const claudeAttachConfig =
|
||||
resolvedTarget === 'claude'
|
||||
? getEffectiveClaudeBrowserAttachConfig(browserConfig)
|
||||
: undefined;
|
||||
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(
|
||||
resolvedTarget,
|
||||
browserLaunchOverride
|
||||
);
|
||||
const claudeBrowserExposure =
|
||||
resolvedTarget === 'claude'
|
||||
? resolveBrowserExposure(
|
||||
{
|
||||
enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled,
|
||||
policy: browserConfig.claude.policy,
|
||||
},
|
||||
browserLaunchOverride
|
||||
)
|
||||
: undefined;
|
||||
const codexBrowserExposure =
|
||||
resolvedTarget === 'codex'
|
||||
? resolveBrowserExposure(browserConfig.codex, browserLaunchOverride)
|
||||
: undefined;
|
||||
const blockedBrowserOverrideWarning =
|
||||
resolvedTarget === 'claude' && claudeBrowserExposure
|
||||
? getBlockedBrowserOverrideWarning('Claude Browser Attach', claudeBrowserExposure)
|
||||
: resolvedTarget === 'codex' && codexBrowserExposure
|
||||
? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure)
|
||||
: undefined;
|
||||
if (blockedBrowserOverrideWarning) {
|
||||
console.error(warn(blockedBrowserOverrideWarning));
|
||||
}
|
||||
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
|
||||
const displayName = targetAdapter?.displayName || resolvedTarget;
|
||||
console.error(fail(`${displayName} CLI not found.`));
|
||||
@@ -1057,13 +1112,11 @@ async function main(): Promise<void> {
|
||||
// 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<void> {
|
||||
...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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<BinaryManagerConfig> = {}, 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<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<CliproxyUpdateCheckResult> {
|
||||
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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
|
||||
@@ -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<void> {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, string>;
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string,
|
||||
const imageAnalysisEnv = resolvedImageAnalysisEnv ?? getImageAnalysisHookEnv(provider);
|
||||
|
||||
// Merge all environment variables (filter undefined values)
|
||||
const baseEnv = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, v]) => v !== undefined)
|
||||
const baseEnv = stripBrowserEnv(
|
||||
Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined)) as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
) as Record<string, string>;
|
||||
|
||||
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<string, string>
|
||||
) as Record<string, string>;
|
||||
|
||||
const mergedEnv = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
displayName: 'Claude (Anthropic)',
|
||||
defaultModel: 'claude-sonnet-4-6',
|
||||
models: [
|
||||
{
|
||||
id: 'claude-opus-4-7',
|
||||
name: 'Claude Opus 4.7',
|
||||
description: 'Latest flagship model',
|
||||
nativeImageInput: true,
|
||||
// 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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<Prefli
|
||||
return { proceed: true, accountId: defaultAccount?.id || '' };
|
||||
}
|
||||
|
||||
const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import(
|
||||
'./account-safety'
|
||||
);
|
||||
restoreExpiredQuotaPauses();
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const quotaConfig = config.quota_management;
|
||||
|
||||
@@ -442,13 +450,32 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
|
||||
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
|
||||
|
||||
if (avgQuota < threshold) {
|
||||
// Apply cooldown to exhausted account
|
||||
applyCooldown(provider, defaultAccount.id, quotaConfig.auto?.cooldown_minutes ?? 5);
|
||||
return await findAndSwitch(
|
||||
|
||||
const alternative = await findHealthyAccount(provider, [defaultAccount.id]);
|
||||
if (!alternative) {
|
||||
return {
|
||||
proceed: true,
|
||||
accountId: defaultAccount.id,
|
||||
reason: `Quota exhausted (${avgQuota.toFixed(1)}%), no alternatives available`,
|
||||
quotaPercent,
|
||||
};
|
||||
}
|
||||
|
||||
pauseAccountForQuotaCooldown(
|
||||
provider,
|
||||
defaultAccount.id,
|
||||
`Quota exhausted (${avgQuota.toFixed(1)}%)`
|
||||
quotaConfig.auto?.cooldown_minutes ?? 5
|
||||
);
|
||||
setDefaultAccount(provider, alternative.id);
|
||||
touchAccount(provider, alternative.id);
|
||||
return {
|
||||
proceed: true,
|
||||
accountId: alternative.id,
|
||||
switchedFrom: defaultAccount.id,
|
||||
reason: `Quota exhausted (${avgQuota.toFixed(1)}%)`,
|
||||
quotaPercent: alternative.lastQuota,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -471,6 +498,9 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
|
||||
isDefault: boolean;
|
||||
}>;
|
||||
}> {
|
||||
const { restoreExpiredQuotaPauses } = await import('./account-safety');
|
||||
restoreExpiredQuotaPauses();
|
||||
|
||||
const accounts = getProviderAccounts(provider);
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
|
||||
|
||||
@@ -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<LatestVersionResult> {
|
||||
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<InstallResult> {
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, number> = {
|
||||
minimal: 512,
|
||||
@@ -39,6 +44,7 @@ export const THINKING_LEVEL_BUDGETS: Record<string, number> = {
|
||||
medium: 8192,
|
||||
high: 24576,
|
||||
xhigh: 32768,
|
||||
max: 65536,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,12 +56,21 @@ export const THINKING_LEVEL_RANK: Record<string, number> = {
|
||||
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<string, string> = {
|
||||
min: 'minimal',
|
||||
lo: 'low',
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+310
-15
@@ -1,10 +1,14 @@
|
||||
import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser';
|
||||
import * as browserUtils from '../utils/browser';
|
||||
import { getBrowserConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import type { BrowserToolPolicy } from '../config/unified-config-types';
|
||||
import { getCcsPathDisplay } from '../utils/config-manager';
|
||||
import { getNodePlatformKey } from '../utils/browser/platform';
|
||||
import { color, dim, header, initUI, subheader } from '../utils/ui';
|
||||
|
||||
type HelpWriter = (line: string) => void;
|
||||
type BrowserLane = 'claude' | 'codex' | 'all';
|
||||
|
||||
function summarizeBrowserHealth(status: BrowserStatusPayload): {
|
||||
function summarizeBrowserHealth(status: browserUtils.BrowserStatusPayload): {
|
||||
label: 'ready' | 'partial' | 'action required';
|
||||
exitCode: 0 | 1;
|
||||
} {
|
||||
@@ -20,13 +24,40 @@ function summarizeBrowserHealth(status: BrowserStatusPayload): {
|
||||
return { label: 'ready', exitCode: 0 };
|
||||
}
|
||||
|
||||
function isBrowserPolicy(value: string): value is BrowserToolPolicy {
|
||||
return value === 'auto' || value === 'manual';
|
||||
}
|
||||
|
||||
function parseBrowserLane(value: string | undefined): BrowserLane | undefined {
|
||||
if (value === 'claude' || value === 'codex' || value === 'all') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function writeCommandTable(writeLine: HelpWriter): void {
|
||||
writeLine(subheader('Commands'));
|
||||
writeLine(
|
||||
` ${color('ccs browser status', 'command')} Show Claude attach and Codex browser readiness`
|
||||
` ${color('ccs browser setup', 'command')} Configure Claude Browser Attach and print the manual launch command`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser doctor', 'command')} Explain what is missing and how to fix it`
|
||||
` ${color('ccs browser status', 'command')} Show Claude attach and Codex browser readiness`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser doctor', 'command')} Explain what is missing and how to fix it`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser policy', 'command')} Show the saved browser exposure policy and safe defaults`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser policy --all manual', 'command')} Keep browser tooling hidden unless a launch uses --browser`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser enable <claude|codex|all>', 'command')} Turn a browser lane on without forcing auto-exposure`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser disable <claude|codex|all>', 'command')} Turn a browser lane off`
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
@@ -36,19 +67,41 @@ function writeIntro(writeLine: HelpWriter): void {
|
||||
writeLine(
|
||||
' Codex Browser Tools inject managed Playwright MCP overrides into Codex-target launches.'
|
||||
);
|
||||
writeLine(
|
||||
' New installs, plus upgrades without saved browser settings, keep both lanes off by default; enable a lane and use `--browser` when you want browser access.'
|
||||
);
|
||||
writeLine('');
|
||||
writeLine(subheader('Launch Overrides'));
|
||||
writeLine(
|
||||
` ${color('--browser', 'command')} Force browser tooling on for the current launch when the lane is enabled`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('--no-browser', 'command')} Suppress browser tooling for the current launch even when policy is auto`
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
function writeLaunchPolicy(policy: BrowserToolPolicy, writeLine: HelpWriter): void {
|
||||
writeLine(` Policy: ${browserUtils.describeBrowserPolicy(policy)}`);
|
||||
writeLine(` Default launch behavior: ${browserUtils.describeDefaultBrowserExposure(policy)}`);
|
||||
}
|
||||
|
||||
function writeClaudeStatus(
|
||||
status: BrowserStatusPayload['claude'],
|
||||
status: browserUtils.BrowserStatusPayload['claude'],
|
||||
writeLine: HelpWriter,
|
||||
includeLaunchGuidance: boolean
|
||||
): void {
|
||||
const userDataDirDisplay =
|
||||
status.effectiveUserDataDir === status.recommendedUserDataDir
|
||||
? getCcsPathDisplay('browser', 'chrome-user-data')
|
||||
: status.effectiveUserDataDir;
|
||||
|
||||
writeLine(subheader('Claude Browser Attach'));
|
||||
writeLine(` State: ${status.state}`);
|
||||
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
|
||||
writeLaunchPolicy(status.policy, writeLine);
|
||||
writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`);
|
||||
writeLine(` User data dir: ${status.effectiveUserDataDir}`);
|
||||
writeLine(` User data dir: ${userDataDirDisplay}`);
|
||||
writeLine(` DevTools port: ${status.devtoolsPort}`);
|
||||
writeLine(` 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<void> {
|
||||
await initUI();
|
||||
writeLine(header('CCS Browser Help'));
|
||||
writeLine('');
|
||||
writeIntro(writeLine);
|
||||
writeLine(subheader('Usage'));
|
||||
writeLine(` ${color('ccs browser <status|doctor>', 'command')}`);
|
||||
writeLine(` ${color('ccs browser <setup|status|doctor|policy|enable|disable>', 'command')}`);
|
||||
writeLine(` ${color('ccs help browser', 'command')}`);
|
||||
writeLine('');
|
||||
writeCommandTable(writeLine);
|
||||
writeLine(subheader('What Each Lane Does'));
|
||||
writeLine(' Claude Browser Attach expects a Chrome user-data dir and remote debugging port.');
|
||||
writeLine(' Codex Browser Tools depend on a Codex build that supports --config overrides.');
|
||||
writeLine(
|
||||
' New installs and upgrades without saved browser settings keep both lanes off by default, so enabling a lane does not auto-expose browser tooling unless policy is set to auto.'
|
||||
);
|
||||
writeLine('');
|
||||
writeLine(subheader('Examples'));
|
||||
writeLine(` ${color('ccs browser status', 'command')} ${dim('# Quick readiness snapshot')}`);
|
||||
writeLine(
|
||||
` ${color('ccs browser doctor', 'command')} ${dim('# Detailed troubleshooting output')}`
|
||||
` ${color('ccs browser setup', 'command')} ${dim('# configure browser attach and print the manual launch command')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs config', 'command')} ${dim('# Open Settings > Browser in the dashboard')}`
|
||||
` ${color('ccs browser policy --all manual', 'command')} ${dim('# keep browser tooling hidden until a launch opts in')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs glm --browser "inspect app"', 'command')} ${dim('# one-run browser opt-in')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs glm --no-browser "summarize app"', 'command')} ${dim('# one-run browser opt-out')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs config', 'command')} ${dim('# open Settings > Browser in the dashboard')}`
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
@@ -108,24 +335,92 @@ export async function handleBrowserCommand(
|
||||
args: string[],
|
||||
writeLine: HelpWriter = console.log
|
||||
): Promise<void> {
|
||||
const subcommand = args[0];
|
||||
if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
|
||||
if (isHelpRequest(args)) {
|
||||
await showBrowserHelp(writeLine);
|
||||
return;
|
||||
}
|
||||
|
||||
const subcommand = args[0];
|
||||
if (subcommand === 'setup') {
|
||||
if (args.includes('--no-launch')) {
|
||||
await initUI();
|
||||
writeLine(color('`ccs browser setup` no longer supports `--no-launch`.', 'error'));
|
||||
writeLine(` ${dim('Setup is config-only and already prints the manual launch command.')}`);
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await initUI();
|
||||
const result = await browserUtils.runBrowserSetup();
|
||||
|
||||
const label = 'ccs browser setup';
|
||||
writeLine(header(label));
|
||||
writeLine('');
|
||||
writeIntro(writeLine);
|
||||
writeSetupSummary(result, writeLine, label);
|
||||
writeClaudeStatus(result.status.claude, writeLine, !result.ready);
|
||||
writeCodexStatus(result.status.codex, writeLine);
|
||||
process.exitCode = result.ready ? 0 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'policy') {
|
||||
const parsed = parsePolicyArgs(args.slice(1));
|
||||
await initUI();
|
||||
|
||||
if (parsed.error) {
|
||||
writeLine(color(parsed.error, 'error'));
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.claude || parsed.codex) {
|
||||
updateBrowserPolicies(parsed);
|
||||
}
|
||||
|
||||
writePolicySummary(writeLine);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'enable' || subcommand === 'disable') {
|
||||
const lane = parseBrowserLane(args[1]);
|
||||
await initUI();
|
||||
|
||||
if (!lane || args.length > 2) {
|
||||
writeLine(color(`Usage: ccs browser ${subcommand} <claude|codex|all>`, 'error'));
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
updateBrowserEnabled(subcommand, lane);
|
||||
writeToggleSummary(subcommand, lane, writeLine);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'doctor' && (args.includes('--fix') || args.includes('-f'))) {
|
||||
await initUI();
|
||||
writeLine(color('`ccs browser doctor` is read-only.', 'error'));
|
||||
writeLine(` ${dim('Run `ccs browser setup` for the browser remediation flow.')}`);
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand !== 'status' && subcommand !== 'doctor') {
|
||||
await initUI();
|
||||
writeLine(color(`Unknown browser subcommand: ${subcommand}`, 'error'));
|
||||
writeLine('');
|
||||
writeLine(` ${dim('Supported subcommands: status, doctor')}`);
|
||||
writeLine(` ${dim('Supported subcommands: setup, status, doctor, policy, enable, disable')}`);
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await initUI();
|
||||
const status = await getBrowserStatus();
|
||||
const status = await browserUtils.getBrowserStatus();
|
||||
|
||||
writeLine(header(`ccs browser ${subcommand}`));
|
||||
writeLine('');
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
[
|
||||
'--backend <type>',
|
||||
'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 <cli>', '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<void> {
|
||||
|
||||
console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
|
||||
console.log(dim(' Routing: use gcli/<model> or agy/<model> 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')}`);
|
||||
|
||||
@@ -123,7 +123,7 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'browser',
|
||||
summary: 'Inspect Claude Browser Attach and Codex Browser Tools readiness',
|
||||
summary: 'Set up, inspect, and control Claude Browser Attach and Codex Browser Tools',
|
||||
group: 'runtime',
|
||||
visibility: 'public',
|
||||
},
|
||||
@@ -305,6 +305,8 @@ export const PROVIDER_FLAGS = [
|
||||
'--effort',
|
||||
'--1m',
|
||||
'--no-1m',
|
||||
'--browser',
|
||||
'--no-browser',
|
||||
'--logout',
|
||||
'--headless',
|
||||
'--port-forward',
|
||||
@@ -320,7 +322,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
|
||||
config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'],
|
||||
cursor: ['--help', '-h'],
|
||||
doctor: ['--fix', '-f', '--help', '-h'],
|
||||
browser: ['status', 'doctor', '--help', '-h'],
|
||||
browser: ['setup', 'status', 'doctor', 'policy', 'enable', 'disable', '--help', '-h'],
|
||||
docker: ['--help', '-h', '--host'],
|
||||
env: ['--format', '--shell', '--ide', '--help', '-h'],
|
||||
migrate: MIGRATE_FLAGS,
|
||||
|
||||
@@ -84,6 +84,15 @@ function completeSubcommands(
|
||||
return uniqueStrings([...values, ...flags]).map((value) => suggestion(value));
|
||||
}
|
||||
|
||||
function completeBrowserPolicyArgs(tokensBeforeCurrent: string[]): CompletionSuggestion[] {
|
||||
const lastToken = tokensBeforeCurrent[tokensBeforeCurrent.length - 1];
|
||||
if (lastToken === '--all' || lastToken === '--claude' || lastToken === '--codex') {
|
||||
return completeSubcommands(['auto', 'manual'], ['--help', '-h']);
|
||||
}
|
||||
|
||||
return completeSubcommands(['--all', '--claude', '--codex'], ['--help', '-h']);
|
||||
}
|
||||
|
||||
function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSuggestion[] {
|
||||
const [command, subcommand] = tokensBeforeCurrent;
|
||||
const lastToken = tokensBeforeCurrent[tokensBeforeCurrent.length - 1];
|
||||
@@ -188,6 +197,23 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
|
||||
if (subcommand === 'logs')
|
||||
return completeSubcommands([], ['--follow', '--service', '--host', '--help', '-h']);
|
||||
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker);
|
||||
case 'browser':
|
||||
if (!subcommand || subcommand.startsWith('-')) {
|
||||
return completeSubcommands(
|
||||
['setup', 'status', 'doctor', 'policy', 'enable', 'disable'],
|
||||
['--help', '-h']
|
||||
);
|
||||
}
|
||||
if (subcommand === 'enable' || subcommand === 'disable') {
|
||||
return completeSubcommands(['claude', 'codex', 'all'], ['--help', '-h']);
|
||||
}
|
||||
if (subcommand === 'policy') {
|
||||
return completeBrowserPolicyArgs(tokensBeforeCurrent);
|
||||
}
|
||||
if (subcommand === 'setup' || subcommand === 'doctor') {
|
||||
return completeSubcommands([], ['--help', '-h']);
|
||||
}
|
||||
return completeSubcommands([], ['--help', '-h']);
|
||||
case 'cursor':
|
||||
return completeSubcommands(CURSOR_COMPLETION_SUBCOMMANDS);
|
||||
case 'proxy':
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
+119
-24
@@ -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 <profile> Start the local proxy for an OpenAI-compatible settings profile'
|
||||
);
|
||||
console.log(' stop Stop the running proxy');
|
||||
console.log(' status Show daemon status and active profile');
|
||||
console.log(' activate Print shell exports for the running proxy');
|
||||
console.log(' stop [profile] Stop the running proxy (or all proxies when omitted)');
|
||||
console.log(' status [profile] Show daemon status');
|
||||
console.log(' activate [profile] Print shell exports for the running proxy');
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(' --port <n> Override the local proxy port (default: 3456)');
|
||||
console.log(' --port <n> Pin an exact local proxy port for this launch');
|
||||
console.log(' --host <addr> Bind the proxy server to a specific host (default: 127.0.0.1)');
|
||||
console.log(' --shell <name> 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<number> {
|
||||
const profileName = args.find((arg) => !arg.startsWith('-'));
|
||||
if (hasHelpFlag(args)) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = findPositionalArg(args, ['--port', '--host'], ['--insecure']);
|
||||
if (!profileName) {
|
||||
console.error(
|
||||
fail('Usage: ccs proxy start <profile> [--port <n>] [--host <addr>] [--insecure]')
|
||||
@@ -72,7 +104,19 @@ async function handleStart(args: string[]): Promise<number> {
|
||||
|
||||
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<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleStatus(): Promise<number> {
|
||||
async function handleStatus(args: string[] = []): Promise<number> {
|
||||
if (hasHelpFlag(args)) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = findPositionalArg(args);
|
||||
const printStatus = (status: Awaited<ReturnType<typeof getOpenAICompatProxyStatus>>) => {
|
||||
console.log(
|
||||
status.running
|
||||
? ok(`Proxy running on port ${status.port ?? 'unknown'}`)
|
||||
: info(`Proxy is not running${status.port ? ` (last known port ${status.port})` : ''}`)
|
||||
);
|
||||
if (status.host && status.port) {
|
||||
console.log(` Host: ${status.host}`);
|
||||
console.log(` Local URL: http://${status.host}:${status.port}`);
|
||||
}
|
||||
if (status.profileName) {
|
||||
console.log(` Profile: ${status.profileName}`);
|
||||
}
|
||||
if (status.baseUrl) {
|
||||
console.log(` Base URL: ${status.baseUrl}`);
|
||||
}
|
||||
if (status.model) {
|
||||
console.log(` Model: ${status.model}`);
|
||||
}
|
||||
if (status.pid) {
|
||||
console.log(` PID: ${status.pid}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (profileName) {
|
||||
const status = await getOpenAICompatProxyStatus(profileName);
|
||||
if (!status.running && !status.profileName) {
|
||||
console.log(info('Proxy is not running'));
|
||||
return 0;
|
||||
}
|
||||
printStatus(status);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
if (!status.running) {
|
||||
if (!status.running && !status.profileName) {
|
||||
console.log(info('Proxy is not running'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(ok(`Proxy running on port ${status.port}`));
|
||||
if (status.host) {
|
||||
console.log(` Host: ${status.host}`);
|
||||
console.log(` Local URL: http://${status.host}:${status.port}`);
|
||||
}
|
||||
console.log(` Profile: ${status.profileName}`);
|
||||
console.log(` Base URL: ${status.baseUrl}`);
|
||||
if (status.model) {
|
||||
console.log(` Model: ${status.model}`);
|
||||
}
|
||||
if (status.pid) {
|
||||
console.log(` PID: ${status.pid}`);
|
||||
if (status.running && !status.profileName) {
|
||||
const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running);
|
||||
for (const entry of running) {
|
||||
printStatus(entry);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
printStatus(status);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleActivate(args: string[]): Promise<number> {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
if (hasHelpFlag(args)) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = findPositionalArg(args, ['--shell']);
|
||||
if (!profileName) {
|
||||
const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running);
|
||||
if (running.length > 1) {
|
||||
console.error(
|
||||
fail('Multiple proxies are running. Specify a profile: ccs proxy activate <profile>')
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const status = await getOpenAICompatProxyStatus(profileName);
|
||||
if (!status.running || !status.profileName || !status.port || !status.authToken) {
|
||||
console.error(fail('Proxy is not running. Start it with: ccs proxy start <profile>'));
|
||||
return 1;
|
||||
@@ -163,16 +254,20 @@ export async function handleProxyCommand(args: string[]): Promise<number> {
|
||||
case 'start':
|
||||
return handleStart(args.slice(1));
|
||||
case 'stop': {
|
||||
const result = await stopOpenAICompatProxy();
|
||||
if (hasHelpFlag(args.slice(1))) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = args[1] && !args[1].startsWith('-') ? args[1] : undefined;
|
||||
const result = await stopOpenAICompatProxy(profileName);
|
||||
if (!result.success) {
|
||||
console.error(fail(result.error || 'Failed to stop proxy'));
|
||||
return 1;
|
||||
}
|
||||
console.log(ok('Proxy stopped'));
|
||||
console.log(ok(profileName ? `Proxy stopped for profile ${profileName}` : 'Proxy stopped'));
|
||||
return 0;
|
||||
}
|
||||
case 'status':
|
||||
return handleStatus();
|
||||
return handleStatus(args.slice(1));
|
||||
case 'activate':
|
||||
return handleActivate(args.slice(1));
|
||||
default:
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DEFAULT_GLOBAL_ENV,
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
DEFAULT_CLIPROXY_SAFETY_CONFIG,
|
||||
DEFAULT_OPENAI_COMPAT_PROXY_CONFIG,
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
|
||||
DEFAULT_THINKING_CONFIG,
|
||||
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
|
||||
@@ -36,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>): 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;
|
||||
});
|
||||
|
||||
@@ -26,8 +26,9 @@ import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
|
||||
* Version 10 = Exa + Tavily WebSearch backends
|
||||
* Version 11 = Discord Channels runtime auto-enable preferences
|
||||
* Version 12 = Official Channels multi-provider support (Telegram, Discord, iMessage)
|
||||
* Version 13 = Browser automation defaults to safe manual/off exposure
|
||||
*/
|
||||
export const UNIFIED_CONFIG_VERSION = 12;
|
||||
export const UNIFIED_CONFIG_VERSION = 13;
|
||||
|
||||
/**
|
||||
* Supported CLIProxy providers.
|
||||
@@ -508,6 +509,10 @@ export interface OpenAICompatProxyRoutingConfig {
|
||||
}
|
||||
|
||||
export interface OpenAICompatProxyConfig {
|
||||
/** Default local port for OpenAI-compatible proxy instances */
|
||||
port?: number;
|
||||
/** Optional profile-scoped local port overrides */
|
||||
profile_ports?: Record<string, number>;
|
||||
routing?: OpenAICompatProxyRoutingConfig;
|
||||
}
|
||||
|
||||
@@ -816,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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface CursorAnthropicRequest {
|
||||
system?: string | AnthropicTextBlock[];
|
||||
stream?: boolean;
|
||||
tools?: CursorTool[];
|
||||
output_config?: {
|
||||
effort?: string;
|
||||
};
|
||||
thinking?: {
|
||||
type?: string;
|
||||
budget_tokens?: number;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, string>;
|
||||
runtimeEnvVars?: NodeJS.ProcessEnv;
|
||||
traceEnv?: Record<string, string>;
|
||||
}
|
||||
): Promise<ExecutionResult> {
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -40,6 +40,7 @@ interface ToolCall {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
blockIndex: number;
|
||||
}
|
||||
|
||||
interface ToolCallDelta {
|
||||
@@ -240,7 +241,6 @@ export class DeltaAccumulator {
|
||||
addToolCallDelta(toolCallDelta: ToolCallDelta): void {
|
||||
const index = toolCallDelta.index;
|
||||
|
||||
// Initialize tool call if not exists
|
||||
if (!this.toolCallsIndex[index]) {
|
||||
const toolCall: ToolCall = {
|
||||
index: index,
|
||||
@@ -250,6 +250,7 @@ export class DeltaAccumulator {
|
||||
name: '',
|
||||
arguments: '',
|
||||
},
|
||||
blockIndex: -1,
|
||||
};
|
||||
this.toolCalls.push(toolCall);
|
||||
this.toolCallsIndex[index] = toolCall;
|
||||
@@ -257,27 +258,42 @@ export class DeltaAccumulator {
|
||||
|
||||
const toolCall = this.toolCallsIndex[index];
|
||||
|
||||
// Update id if present
|
||||
if (toolCallDelta.id) {
|
||||
toolCall.id = toolCallDelta.id;
|
||||
}
|
||||
|
||||
// Update type if present
|
||||
if (toolCallDelta.type) {
|
||||
toolCall.type = toolCallDelta.type;
|
||||
}
|
||||
|
||||
// Update function name if present
|
||||
if (toolCallDelta.function?.name) {
|
||||
toolCall.function.name += toolCallDelta.function.name;
|
||||
}
|
||||
|
||||
// Update function arguments if present
|
||||
if (toolCallDelta.function?.arguments) {
|
||||
toolCall.function.arguments += toolCallDelta.function.arguments;
|
||||
}
|
||||
}
|
||||
|
||||
setToolCallBlockIndex(toolCallIndex: number, blockIndex: number): void {
|
||||
const toolCall = this.toolCallsIndex[toolCallIndex];
|
||||
if (toolCall) {
|
||||
toolCall.blockIndex = blockIndex;
|
||||
}
|
||||
}
|
||||
|
||||
getToolCallBlockIndex(toolCallIndex: number): number {
|
||||
const toolCall = this.toolCallsIndex[toolCallIndex];
|
||||
if (!toolCall || toolCall.blockIndex < 0) {
|
||||
throw new Error(`Tool call ${toolCallIndex} does not have an assigned content block`);
|
||||
}
|
||||
return toolCall.blockIndex;
|
||||
}
|
||||
|
||||
getUnstoppedBlocks(): ContentBlock[] {
|
||||
return this.contentBlocks.filter((b) => !b.stopped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tool calls
|
||||
* @returns Tool calls array
|
||||
|
||||
@@ -209,9 +209,8 @@ export class StreamParser {
|
||||
|
||||
if (!toolCallDeltas) return events;
|
||||
|
||||
// Close current content block ONCE before processing any tool calls
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
if (currentBlock && !currentBlock.stopped) {
|
||||
if (currentBlock && !currentBlock.stopped && currentBlock.type !== 'tool_use') {
|
||||
if (currentBlock.type === 'thinking') {
|
||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
||||
} else {
|
||||
@@ -220,7 +219,6 @@ export class StreamParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Process tool call deltas
|
||||
events.push(...this.toolCallHandler.processToolCallDeltas(toolCallDeltas, accumulator));
|
||||
|
||||
return events;
|
||||
@@ -251,44 +249,41 @@ export class StreamParser {
|
||||
private forceFinalization(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
|
||||
const events: AnthropicSSEEvent[] = [];
|
||||
|
||||
// Close current block if any
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
if (currentBlock && !currentBlock.stopped) {
|
||||
if (currentBlock.type === 'thinking') {
|
||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
||||
} else {
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
|
||||
accumulator.stopCurrentBlock();
|
||||
const unstoppedBlocks = accumulator.getUnstoppedBlocks();
|
||||
for (const block of unstoppedBlocks) {
|
||||
if (block.type === 'thinking') {
|
||||
const signatureEvent = this.responseBuilder.createSignatureDeltaEvent(block);
|
||||
if (signatureEvent) {
|
||||
events.push(signatureEvent);
|
||||
}
|
||||
}
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(block));
|
||||
block.stopped = true;
|
||||
}
|
||||
|
||||
// Force finalization
|
||||
events.push(...this.finalizeDelta(accumulator));
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize streaming and generate closing events
|
||||
*/
|
||||
finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
|
||||
if (accumulator.isFinalized()) {
|
||||
return []; // Already finalized
|
||||
return [];
|
||||
}
|
||||
|
||||
const events: AnthropicSSEEvent[] = [];
|
||||
|
||||
// Close current content block if any
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
if (currentBlock && !currentBlock.stopped) {
|
||||
if (currentBlock.type === 'thinking') {
|
||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
||||
} else {
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
|
||||
accumulator.stopCurrentBlock();
|
||||
const unstoppedBlocks = accumulator.getUnstoppedBlocks();
|
||||
for (const block of unstoppedBlocks) {
|
||||
if (block.type === 'thinking') {
|
||||
const signatureEvent = this.responseBuilder.createSignatureDeltaEvent(block);
|
||||
if (signatureEvent) {
|
||||
events.push(signatureEvent);
|
||||
}
|
||||
}
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(block));
|
||||
block.stopped = true;
|
||||
}
|
||||
|
||||
// Message delta (stop reason + usage) and message stop
|
||||
const stopReason = this.responseBuilder.mapStopReason(accumulator.getFinishReason() || 'stop');
|
||||
events.push(...this.responseBuilder.createFinalizationEvents(accumulator, stopReason));
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@ import type { DeltaAccumulator } from '../delta-accumulator';
|
||||
import type { OpenAIToolCallDelta, OpenAIToolCall, ContentBlock, AnthropicSSEEvent } from './types';
|
||||
|
||||
export class ToolCallHandler {
|
||||
/**
|
||||
* Process tool calls from non-streaming response
|
||||
*/
|
||||
processToolCalls(toolCalls: OpenAIToolCall[]): ContentBlock[] {
|
||||
const content: ContentBlock[] = [];
|
||||
|
||||
@@ -38,10 +35,6 @@ export class ToolCallHandler {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tool call deltas during streaming
|
||||
* Returns events for tool use blocks and input_json deltas
|
||||
*/
|
||||
processToolCallDeltas(
|
||||
toolCallDeltas: OpenAIToolCallDelta[],
|
||||
accumulator: DeltaAccumulator
|
||||
@@ -49,15 +42,15 @@ export class ToolCallHandler {
|
||||
const events: AnthropicSSEEvent[] = [];
|
||||
|
||||
for (const toolCallDelta of toolCallDeltas) {
|
||||
// Track tool call state
|
||||
const isNewToolCall = !accumulator.hasToolCall(toolCallDelta.index);
|
||||
accumulator.addToolCallDelta(toolCallDelta);
|
||||
|
||||
// Emit tool use events (start + input_json deltas)
|
||||
if (isNewToolCall) {
|
||||
// Start new tool_use block in accumulator
|
||||
// OpenAI may interleave tool_call fragments across chunks, so blocks must stay open
|
||||
// until the stream finalizes. Closing on a later index truncates earlier tool input.
|
||||
const block = accumulator.startBlock('tool_use');
|
||||
const toolCall = accumulator.getToolCall(toolCallDelta.index);
|
||||
accumulator.setToolCallBlockIndex(toolCallDelta.index, block.index);
|
||||
|
||||
events.push({
|
||||
event: 'content_block_start',
|
||||
@@ -68,27 +61,25 @@ export class ToolCallHandler {
|
||||
type: 'tool_use',
|
||||
id: toolCall?.id || `tool_${toolCallDelta.index}`,
|
||||
name: toolCall?.function?.name || '',
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Emit input_json delta if arguments present
|
||||
if (toolCallDelta.function?.arguments) {
|
||||
const currentToolBlock = accumulator.getCurrentBlock();
|
||||
if (currentToolBlock && currentToolBlock.type === 'tool_use') {
|
||||
events.push({
|
||||
event: 'content_block_delta',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
index: currentToolBlock.index,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: toolCallDelta.function.arguments,
|
||||
},
|
||||
const toolCallBlockIndex = accumulator.getToolCallBlockIndex(toolCallDelta.index);
|
||||
events.push({
|
||||
event: 'content_block_delta',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
index: toolCallBlockIndex,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: toolCallDelta.function.arguments,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './profile-router';
|
||||
export * from './proxy-daemon';
|
||||
export * from './proxy-daemon-paths';
|
||||
export * from './proxy-env';
|
||||
export * from './proxy-port-resolver';
|
||||
export * from './upstream-url';
|
||||
export * from './transformers/request-transformer';
|
||||
export * from './transformers/sse-stream-transformer';
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
getOpenAICompatProxyDir,
|
||||
getLegacyOpenAICompatProxyPidPath,
|
||||
getLegacyOpenAICompatProxySessionPath,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from './proxy-daemon-paths';
|
||||
@@ -21,9 +23,9 @@ function ensureProxyDir(): void {
|
||||
fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPid(): number | null {
|
||||
function readPid(pidPath: string): number | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim();
|
||||
const raw = fs.readFileSync(pidPath, 'utf8').trim();
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(pid) ? pid : null;
|
||||
} catch {
|
||||
@@ -31,46 +33,113 @@ export function getOpenAICompatProxyPid(): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxyPid(pid: number): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(), String(pid), 'utf8');
|
||||
export function getOpenAICompatProxyPid(profileName: string): number | null {
|
||||
return readPid(getOpenAICompatProxyPidPath(profileName));
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxyPid(): void {
|
||||
export function getLegacyOpenAICompatProxyPid(): number | null {
|
||||
return readPid(getLegacyOpenAICompatProxyPidPath());
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxyPid(profileName: string, pid: number): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8');
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxyPid(profileName: string): void {
|
||||
try {
|
||||
fs.unlinkSync(getOpenAICompatProxyPidPath());
|
||||
fs.unlinkSync(getOpenAICompatProxyPidPath(profileName));
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenAICompatProxySession(): OpenAICompatProxySession | null {
|
||||
export function removeLegacyOpenAICompatProxyPid(): void {
|
||||
try {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8')
|
||||
) as OpenAICompatProxySession;
|
||||
fs.unlinkSync(getLegacyOpenAICompatProxyPidPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function readSession(sessionPath: string): OpenAICompatProxySession | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(sessionPath, 'utf8')) as OpenAICompatProxySession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null {
|
||||
return readSession(getOpenAICompatProxySessionPath(profileName));
|
||||
}
|
||||
|
||||
export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession | null {
|
||||
return readSession(getLegacyOpenAICompatProxySessionPath());
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxySessionPath(),
|
||||
getOpenAICompatProxySessionPath(session.profileName),
|
||||
JSON.stringify(session, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxySession(): void {
|
||||
export function removeOpenAICompatProxySession(profileName: string): void {
|
||||
try {
|
||||
fs.unlinkSync(getOpenAICompatProxySessionPath());
|
||||
fs.unlinkSync(getOpenAICompatProxySessionPath(profileName));
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export function removeLegacyOpenAICompatProxySession(): void {
|
||||
try {
|
||||
fs.unlinkSync(getLegacyOpenAICompatProxySessionPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function decodeProfileKey(profileKey: string): string[] {
|
||||
try {
|
||||
return [decodeURIComponent(profileKey)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function listProfileNamesForSuffix(suffix: string, legacyName?: string): string[] {
|
||||
try {
|
||||
const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true });
|
||||
const profileKeys = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || entry.name === legacyName || !entry.name.endsWith(suffix)) {
|
||||
continue;
|
||||
}
|
||||
const profileKey = entry.name.slice(0, -suffix.length);
|
||||
if (!profileKey) {
|
||||
continue;
|
||||
}
|
||||
profileKeys.add(profileKey);
|
||||
}
|
||||
return [...profileKeys].flatMap(decodeProfileKey);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listOpenAICompatProxyProfileNames(): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
...listProfileNamesForSuffix('.session.json', 'session.json'),
|
||||
...listProfileNamesForSuffix('.daemon.pid', 'daemon.pid'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
|
||||
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
|
||||
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
|
||||
|
||||
+520
-169
@@ -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<OpenAICompatProxySession> {
|
||||
running: boolean;
|
||||
@@ -36,6 +45,37 @@ export interface StartOpenAICompatProxyResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult {
|
||||
commitState?: () => void;
|
||||
stop?: () => Promise<void>;
|
||||
bindConflict?: boolean;
|
||||
}
|
||||
|
||||
interface OpenAICompatProxyHealthPayload {
|
||||
service?: string;
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function verifyProxyProcessOwnership(
|
||||
pid: number,
|
||||
profileName?: string
|
||||
): ReturnType<typeof verifyProcessOwnership> {
|
||||
const profilePattern = profileName
|
||||
? new RegExp(`(^|\\s)--profile(?:\\s+|=)${escapeRegExp(profileName)}(?=\\s|$)`)
|
||||
: null;
|
||||
return verifyProcessOwnership(
|
||||
pid,
|
||||
(commandLine) =>
|
||||
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
||||
commandLine.includes('proxy-daemon-entry') &&
|
||||
(!profilePattern || profilePattern.test(commandLine))
|
||||
);
|
||||
}
|
||||
|
||||
function generateProxyAuthToken(): string {
|
||||
return crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
@@ -70,35 +110,59 @@ async function withOpenAICompatProxyLock<T>(operation: () => Promise<T>): Promis
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortOccupied(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
const finish = (occupied: boolean) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(occupied);
|
||||
};
|
||||
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(500, () => {
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findOpenAICompatProxyPort(): Promise<number> {
|
||||
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<void> {
|
||||
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<number> = 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<string | null> {
|
||||
@@ -113,7 +177,10 @@ async function resolveDaemonEntrypoint(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isOpenAICompatProxyRunning(port: number): Promise<boolean> {
|
||||
export async function isOpenAICompatProxyRunning(
|
||||
port: number,
|
||||
expectedProfileName?: string
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{ hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 },
|
||||
@@ -127,8 +194,11 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(body) as { service?: string };
|
||||
resolve(payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME);
|
||||
const payload = JSON.parse(body) as OpenAICompatProxyHealthPayload;
|
||||
resolve(
|
||||
payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME &&
|
||||
(!expectedProfileName || payload.profile === expectedProfileName)
|
||||
);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
@@ -144,33 +214,115 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus> {
|
||||
const session = readOpenAICompatProxySession();
|
||||
const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
const running = await isOpenAICompatProxyRunning(port);
|
||||
async function getOpenAICompatProxyStatusForProfile(
|
||||
profileName: string
|
||||
): Promise<OpenAICompatProxyStatus> {
|
||||
const state = getOpenAICompatProxyStateForProfile(profileName);
|
||||
const session = state.session;
|
||||
if (!session) {
|
||||
return { running: false, profileName, pid: state.pid || undefined };
|
||||
}
|
||||
|
||||
const port = session.port;
|
||||
const running = await isOpenAICompatProxyRunning(port, profileName);
|
||||
return {
|
||||
running,
|
||||
pid: running ? getOpenAICompatProxyPid() || undefined : undefined,
|
||||
pid: running ? state.pid || undefined : undefined,
|
||||
...session,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> {
|
||||
const pid = getOpenAICompatProxyPid();
|
||||
async function getLegacyOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus | null> {
|
||||
const session = readLegacyOpenAICompatProxySession();
|
||||
const pid = getLegacyOpenAICompatProxyPid();
|
||||
if (!session && !pid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = session?.port;
|
||||
const running =
|
||||
typeof port === 'number' ? await isOpenAICompatProxyRunning(port, session?.profileName) : false;
|
||||
return {
|
||||
running,
|
||||
pid: running ? pid || undefined : pid || undefined,
|
||||
...session,
|
||||
};
|
||||
}
|
||||
|
||||
function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatProxyStateRecord {
|
||||
const session = readOpenAICompatProxySession(profileName);
|
||||
if (session) {
|
||||
return {
|
||||
pid: getOpenAICompatProxyPid(profileName),
|
||||
session,
|
||||
source: 'profile',
|
||||
};
|
||||
}
|
||||
|
||||
const legacySession = readLegacyOpenAICompatProxySession();
|
||||
if (legacySession?.profileName === profileName) {
|
||||
return {
|
||||
pid: getLegacyOpenAICompatProxyPid(),
|
||||
session: legacySession,
|
||||
source: 'legacy',
|
||||
};
|
||||
}
|
||||
|
||||
return { pid: null, session: null, source: 'profile' };
|
||||
}
|
||||
|
||||
export async function listOpenAICompatProxyStatuses(): Promise<OpenAICompatProxyStatus[]> {
|
||||
const profileNames = new Set(listOpenAICompatProxyProfileNames());
|
||||
const legacySession = readLegacyOpenAICompatProxySession();
|
||||
if (legacySession?.profileName) {
|
||||
profileNames.add(legacySession.profileName);
|
||||
}
|
||||
const profileStatuses = await Promise.all(
|
||||
[...profileNames].map((profileName) => getOpenAICompatProxyStatusForProfile(profileName))
|
||||
);
|
||||
const statuses = profileStatuses.filter((status) => status.profileName);
|
||||
if (!legacySession?.profileName) {
|
||||
const legacyStatus = await getLegacyOpenAICompatProxyStatus();
|
||||
if (legacyStatus) {
|
||||
statuses.push(legacyStatus);
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
export async function getOpenAICompatProxyStatus(
|
||||
profileName?: string
|
||||
): Promise<OpenAICompatProxyStatus> {
|
||||
if (profileName) {
|
||||
return getOpenAICompatProxyStatusForProfile(profileName);
|
||||
}
|
||||
|
||||
const statuses = await listOpenAICompatProxyStatuses();
|
||||
const running = statuses.filter((status) => status.running);
|
||||
if (running.length === 1) {
|
||||
return running[0];
|
||||
}
|
||||
if (running.length > 1) {
|
||||
return { running: true };
|
||||
}
|
||||
const latestKnown = statuses[0];
|
||||
return latestKnown ?? { running: false };
|
||||
}
|
||||
|
||||
async function stopOpenAICompatProxyUnlocked(
|
||||
profileName: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const state = getOpenAICompatProxyStateForProfile(profileName);
|
||||
const pid = state.pid;
|
||||
if (!pid) {
|
||||
removeOpenAICompatProxySession();
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ownership = verifyProcessOwnership(
|
||||
pid,
|
||||
(commandLine) =>
|
||||
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
||||
commandLine.includes('proxy-daemon-entry')
|
||||
);
|
||||
const ownership = verifyProxyProcessOwnership(pid, profileName);
|
||||
|
||||
if (ownership === 'not-owned') {
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -182,8 +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<StartOpenAICompatProxyResult> {
|
||||
return withOpenAICompatProxyLock(async () => {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
const status = await getOpenAICompatProxyStatus(profile.profileName);
|
||||
const host = options.host?.trim() || status.host || '127.0.0.1';
|
||||
const port =
|
||||
typeof options.port === 'number'
|
||||
? options.port
|
||||
: status.running && status.profileName === profile.profileName && status.port
|
||||
? status.port
|
||||
: await findOpenAICompatProxyPort();
|
||||
if (port === 0) {
|
||||
return {
|
||||
success: false,
|
||||
port: OPENAI_COMPAT_PROXY_DEFAULT_PORT,
|
||||
error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`,
|
||||
};
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return { success: false, port, error: `Invalid port: ${port}` };
|
||||
}
|
||||
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<OpenAICompatProxyLaunchResult> =>
|
||||
new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
let stderr = '';
|
||||
const authToken = generateProxyAuthToken();
|
||||
const commitState = () => {
|
||||
if (proc.pid) {
|
||||
writeOpenAICompatProxyPid(profile.profileName, proc.pid);
|
||||
}
|
||||
writeOpenAICompatProxySession({
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host,
|
||||
port,
|
||||
error: `Proxy daemon did not start within 30 seconds on port ${port}`,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken,
|
||||
model: profile.model,
|
||||
insecure: options.insecure,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (result: OpenAICompatProxyLaunchResult) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (!result.success && persistState) {
|
||||
removeOpenAICompatProxyPid(profile.profileName);
|
||||
removeOpenAICompatProxySession(profile.profileName);
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const proc: ChildProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
daemonEntry,
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
host,
|
||||
'--profile',
|
||||
profile.profileName,
|
||||
'--settings-path',
|
||||
profile.settingsPath,
|
||||
'--auth-token',
|
||||
authToken,
|
||||
...(options.insecure ? ['--insecure'] : []),
|
||||
'--ccs-openai-proxy-daemon',
|
||||
],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'], detached: true }
|
||||
);
|
||||
|
||||
proc.unref();
|
||||
proc.stderr?.setEncoding('utf8');
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
attempts += 1;
|
||||
if (await isOpenAICompatProxyRunning(port, profile.profileName)) {
|
||||
if (persistState) {
|
||||
commitState();
|
||||
}
|
||||
finish({
|
||||
success: true,
|
||||
pid: proc.pid,
|
||||
port,
|
||||
authToken,
|
||||
...(persistState
|
||||
? {}
|
||||
: {
|
||||
commitState,
|
||||
stop: async () => {
|
||||
await terminateDaemonProcess(proc.pid);
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (attempts >= 30) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon did not start within 30 seconds on port ${port}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(poll, 1000);
|
||||
};
|
||||
|
||||
timeout = setTimeout(poll, 1000);
|
||||
};
|
||||
|
||||
timeout = setTimeout(poll, 1000);
|
||||
proc.on('error', (error) => {
|
||||
finish({ success: false, port, error: error.message });
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
proc.on('error', (error) => {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: 'Proxy daemon exited before becoming healthy',
|
||||
error: error.message,
|
||||
bindConflict: isPortBindConflictMessage(error.message),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (code !== null) {
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
const bindConflict = isPortBindConflictMessage(stderr);
|
||||
if (code === 0) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: 'Proxy daemon exited before becoming healthy',
|
||||
bindConflict,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (code !== null) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: stderr.trim() || `Proxy daemon exited with code ${code}`,
|
||||
bindConflict,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon exited with code ${code}`,
|
||||
error: `Proxy daemon was killed by signal ${signal}`,
|
||||
bindConflict,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon was killed by signal ${signal}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const launchProxy = async (persistState: boolean): Promise<OpenAICompatProxyLaunchResult> => {
|
||||
const attemptedPorts = new Set<number>();
|
||||
let lastResult: OpenAICompatProxyLaunchResult | null = null;
|
||||
const candidates = listOpenAICompatProxyCandidatePorts(
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<number> = new Set()
|
||||
): number[] {
|
||||
const candidates = new Set<number>();
|
||||
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;
|
||||
}
|
||||
@@ -139,20 +139,13 @@ export function attachDisconnectAbortHandlers(
|
||||
|
||||
const cleanupFns = [
|
||||
registerOnceListener(req, 'aborted', () => abortOnDisconnect('req.aborted')),
|
||||
registerOnceListener(req, 'close', () => abortOnDisconnect('req.close')),
|
||||
registerOnceListener(req.socket, 'close', () => abortOnDisconnect('req.socket.close')),
|
||||
registerOnceListener(res, 'close', () => abortOnDisconnect('res.close')),
|
||||
registerOnceListener(res.socket, 'close', () => abortOnDisconnect('res.socket.close')),
|
||||
];
|
||||
|
||||
const disconnectPoll = setInterval(() => {
|
||||
if (
|
||||
req.destroyed ||
|
||||
res.destroyed ||
|
||||
req.socket?.destroyed === true ||
|
||||
res.socket?.destroyed === true
|
||||
) {
|
||||
abortOnDisconnect('poll.destroyed');
|
||||
if (req.socket?.destroyed === true) {
|
||||
abortOnDisconnect('poll.socket.destroyed');
|
||||
}
|
||||
}, 50);
|
||||
|
||||
|
||||
@@ -35,32 +35,42 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt
|
||||
const pathname =
|
||||
parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname;
|
||||
|
||||
if (method === 'GET' && pathname === '/health') {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
host,
|
||||
profile: options.profile.profileName,
|
||||
port: options.port,
|
||||
});
|
||||
if ((method === 'GET' || method === 'HEAD') && pathname === '/health') {
|
||||
if (method === 'HEAD') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end();
|
||||
} else {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
host,
|
||||
profile: options.profile.profileName,
|
||||
port: options.port,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/') {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
bind: {
|
||||
host,
|
||||
port: options.port,
|
||||
},
|
||||
profile: {
|
||||
name: options.profile.profileName,
|
||||
provider: options.profile.provider,
|
||||
model: options.profile.model || null,
|
||||
},
|
||||
endpoints: ['/health', '/v1/messages', '/v1/models'],
|
||||
});
|
||||
if ((method === 'GET' || method === 'HEAD') && pathname === '/') {
|
||||
if (method === 'HEAD') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end();
|
||||
} else {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
bind: {
|
||||
host,
|
||||
port: options.port,
|
||||
},
|
||||
profile: {
|
||||
name: options.profile.profileName,
|
||||
provider: options.profile.provider,
|
||||
model: options.profile.model || null,
|
||||
},
|
||||
endpoints: ['/health', '/v1/messages', '/v1/models'],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
const handleUserPart = (
|
||||
part: OpenAIContentPart,
|
||||
blockIndex: number,
|
||||
kind: 'text' | 'image'
|
||||
) => {
|
||||
if (!pendingToolUseIds || pendingToolUseIds.size === 0) {
|
||||
userParts.push(part);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedToolUseIds.size === 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] ${kind} is not allowed before tool_result blocks for pending tool_use ids`
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedToolUseIds.size !== pendingToolUseIds.size) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] ${kind} is not allowed between tool_result blocks for pending tool_use ids`
|
||||
);
|
||||
}
|
||||
|
||||
followUpParts.push(part);
|
||||
};
|
||||
|
||||
content.forEach((block, blockIndex) => {
|
||||
const parsed = assertObject(
|
||||
block,
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,6 +29,17 @@ const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES';
|
||||
const LEGACY_TARGET_ALIAS_ENV_VARS: Partial<Record<TargetType, string>> =
|
||||
getLegacyTargetAliasEnvVars();
|
||||
const RESERVED_BIN_NAMES = new Set<string>(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]);
|
||||
const INTERNAL_RUNTIME_ENTRY_BASENAMES = new Set([
|
||||
'droid-runtime',
|
||||
'droid-runtime.ts',
|
||||
'droid-runtime.js',
|
||||
'codex-runtime',
|
||||
'codex-runtime.ts',
|
||||
'codex-runtime.js',
|
||||
'ccsxp-runtime',
|
||||
'ccsxp-runtime.ts',
|
||||
'ccsxp-runtime.js',
|
||||
]);
|
||||
|
||||
function addAliasToMap(map: Record<string, TargetType>, alias: string, target: TargetType): void {
|
||||
const normalizedAlias = alias.trim().toLowerCase();
|
||||
@@ -102,6 +113,11 @@ function resolveEntrypointTarget(): TargetType | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entryScript = path.basename(process.argv[1] || '');
|
||||
if (!INTERNAL_RUNTIME_ENTRY_BASENAMES.has(entryScript)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTarget = rawTarget.trim().toLowerCase();
|
||||
return isRuntimeTargetType(normalizedTarget) ? normalizedTarget : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { BrowserToolPolicy } from '../../config/unified-config-types';
|
||||
|
||||
export type BrowserLaunchOverride = 'force-enable' | 'force-disable';
|
||||
|
||||
export interface BrowserLaunchFlagResolution {
|
||||
override?: BrowserLaunchOverride;
|
||||
argsWithoutFlags: string[];
|
||||
}
|
||||
|
||||
export interface ResolvedBrowserExposure {
|
||||
enabled: boolean;
|
||||
policy: BrowserToolPolicy;
|
||||
override?: BrowserLaunchOverride;
|
||||
exposeByDefault: boolean;
|
||||
exposeForLaunch: boolean;
|
||||
}
|
||||
|
||||
const ENABLE_BROWSER_FLAG = '--browser';
|
||||
const DISABLE_BROWSER_FLAG = '--no-browser';
|
||||
|
||||
export function resolveBrowserLaunchFlagResolution(args: string[]): BrowserLaunchFlagResolution {
|
||||
let override: BrowserLaunchOverride | undefined;
|
||||
const argsWithoutFlags: string[] = [];
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === '--') {
|
||||
argsWithoutFlags.push(...args.slice(index));
|
||||
break;
|
||||
}
|
||||
|
||||
if (arg === ENABLE_BROWSER_FLAG) {
|
||||
if (override === 'force-disable') {
|
||||
throw new Error('Use either `--browser` or `--no-browser`, not both.');
|
||||
}
|
||||
override = 'force-enable';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === DISABLE_BROWSER_FLAG) {
|
||||
if (override === 'force-enable') {
|
||||
throw new Error('Use either `--browser` or `--no-browser`, not both.');
|
||||
}
|
||||
override = 'force-disable';
|
||||
continue;
|
||||
}
|
||||
|
||||
argsWithoutFlags.push(arg);
|
||||
}
|
||||
|
||||
return {
|
||||
override,
|
||||
argsWithoutFlags,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBrowserExposure(
|
||||
config: { enabled: boolean; policy: BrowserToolPolicy },
|
||||
override?: BrowserLaunchOverride
|
||||
): ResolvedBrowserExposure {
|
||||
const exposeByDefault = config.enabled && config.policy === 'auto';
|
||||
const exposeForLaunch =
|
||||
config.enabled &&
|
||||
(override === 'force-enable' || (override !== 'force-disable' && exposeByDefault));
|
||||
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
policy: config.policy,
|
||||
override,
|
||||
exposeByDefault,
|
||||
exposeForLaunch,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeBrowserPolicy(policy: BrowserToolPolicy): string {
|
||||
return policy === 'manual' ? 'manual' : 'auto';
|
||||
}
|
||||
|
||||
export function describeDefaultBrowserExposure(policy: BrowserToolPolicy): string {
|
||||
return policy === 'manual' ? 'hidden until `--browser`' : 'auto-exposed';
|
||||
}
|
||||
|
||||
export function getBlockedBrowserOverrideWarning(
|
||||
laneLabel: string,
|
||||
exposure: ResolvedBrowserExposure
|
||||
): string | undefined {
|
||||
if (exposure.override === 'force-enable' && !exposure.enabled) {
|
||||
return `Browser tooling was requested with \`--browser\`, but ${laneLabel} is disabled in CCS config.`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,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);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import type { BrowserConfig } from '../../config/unified-config-types';
|
||||
import { getNodePlatformKey } from './platform';
|
||||
import { type BrowserStatusPayload, getBrowserStatus } from './browser-status';
|
||||
import { ensureBrowserMcp } from './mcp-installer';
|
||||
import {
|
||||
ensureManagedBrowserUserDataDir,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
getRecommendedBrowserUserDataDir,
|
||||
isManagedClaudeBrowserAttachConfig,
|
||||
} from './browser-settings';
|
||||
|
||||
export interface BrowserSetupResult {
|
||||
configUpdated: boolean;
|
||||
createdUserDataDir: boolean;
|
||||
mcpReady: boolean;
|
||||
overrideActive: boolean;
|
||||
ready: boolean;
|
||||
launchCommand: string;
|
||||
status: BrowserStatusPayload;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface BrowserSetupDeps {
|
||||
getBrowserConfig: typeof getBrowserConfig;
|
||||
mutateUnifiedConfig: typeof mutateUnifiedConfig;
|
||||
ensureBrowserMcp: typeof ensureBrowserMcp;
|
||||
getBrowserStatus: typeof getBrowserStatus;
|
||||
}
|
||||
|
||||
const defaultBrowserSetupDeps: BrowserSetupDeps = {
|
||||
getBrowserConfig,
|
||||
mutateUnifiedConfig,
|
||||
ensureBrowserMcp,
|
||||
getBrowserStatus,
|
||||
};
|
||||
|
||||
export async function runBrowserSetup(
|
||||
deps: BrowserSetupDeps = defaultBrowserSetupDeps
|
||||
): Promise<BrowserSetupResult> {
|
||||
const initialConfig = deps.getBrowserConfig();
|
||||
const configUpdated = persistBrowserSetupConfig(deps, initialConfig);
|
||||
const persistedConfig = deps.getBrowserConfig();
|
||||
const effectiveConfig = getEffectiveClaudeBrowserAttachConfig(persistedConfig);
|
||||
const createdUserDataDir = isManagedClaudeBrowserAttachConfig(effectiveConfig)
|
||||
? ensureManagedBrowserUserDataDir(effectiveConfig).createdProfileDir
|
||||
: false;
|
||||
const mcpReady = deps.ensureBrowserMcp();
|
||||
const status = await deps.getBrowserStatus();
|
||||
const notes: string[] = [];
|
||||
|
||||
if (effectiveConfig.overrideActive) {
|
||||
notes.push(
|
||||
`Current session is using ${effectiveConfig.source}; remove that override if you want saved browser settings to take effect in future shells.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!mcpReady) {
|
||||
notes.push('CCS could not fully prepare the local browser MCP runtime.');
|
||||
}
|
||||
|
||||
if (!isManagedClaudeBrowserAttachConfig(effectiveConfig)) {
|
||||
notes.push(
|
||||
'Setup did not create the current browser user-data dir because it is not the CCS-managed default path.'
|
||||
);
|
||||
}
|
||||
|
||||
const platform = getNodePlatformKey();
|
||||
return {
|
||||
configUpdated,
|
||||
createdUserDataDir,
|
||||
mcpReady,
|
||||
overrideActive: effectiveConfig.overrideActive,
|
||||
ready: status.claude.state === 'ready' && mcpReady,
|
||||
launchCommand: status.claude.launchCommands[platform],
|
||||
status,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
function persistBrowserSetupConfig(deps: BrowserSetupDeps, currentConfig: BrowserConfig): boolean {
|
||||
const before = JSON.stringify(currentConfig);
|
||||
|
||||
deps.mutateUnifiedConfig((config) => {
|
||||
const existingBrowser = config.browser ?? currentConfig;
|
||||
const currentUserDataDir = existingBrowser.claude.user_data_dir?.trim();
|
||||
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
policy: existingBrowser.claude.policy,
|
||||
user_data_dir: currentUserDataDir || getRecommendedBrowserUserDataDir(),
|
||||
devtools_port: currentConfig.claude.devtools_port,
|
||||
},
|
||||
codex: {
|
||||
enabled: existingBrowser.codex.enabled,
|
||||
policy: existingBrowser.codex.policy,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return JSON.stringify(deps.getBrowserConfig()) !== before;
|
||||
}
|
||||
@@ -1,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<BrowserStatusPayload> {
|
||||
const browserConfig = getBrowserConfig();
|
||||
const browserConfig = getUserFacingBrowserConfig();
|
||||
return {
|
||||
claude: await buildClaudeBrowserStatus(browserConfig),
|
||||
codex: buildCodexBrowserStatus(browserConfig),
|
||||
};
|
||||
}
|
||||
|
||||
type PersistedBrowserConfig = {
|
||||
claude?: Partial<BrowserConfig['claude']>;
|
||||
codex?: Partial<BrowserConfig['codex']>;
|
||||
};
|
||||
|
||||
function resolveSafeBrowserPolicy(policy: BrowserToolPolicy | undefined): BrowserToolPolicy {
|
||||
return policy === 'auto' || policy === 'manual' ? policy : 'manual';
|
||||
}
|
||||
|
||||
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<ClaudeBrowserStatus> {
|
||||
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<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
|
||||
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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
+100
-22
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -228,6 +228,13 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
|
||||
cacheCreationPerMillion: 6.25,
|
||||
cacheReadPerMillion: 0.5,
|
||||
},
|
||||
// Claude 4.7 Opus ($5/$25)
|
||||
'claude-opus-4-7': {
|
||||
inputPerMillion: 5.0,
|
||||
outputPerMillion: 25.0,
|
||||
cacheCreationPerMillion: 6.25,
|
||||
cacheReadPerMillion: 0.5,
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI Models - Source: better-ccusage
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import type { BrowserConfig, 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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isValidBrowserPolicy(value: string): value is 'auto' | 'manual' {
|
||||
return value === 'auto' || value === 'manual';
|
||||
}
|
||||
|
||||
function 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<void> => {
|
||||
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<void> => {
|
||||
});
|
||||
|
||||
router.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
if (
|
||||
req.body === null ||
|
||||
req.body === undefined ||
|
||||
typeof req.body !== 'object' ||
|
||||
Array.isArray(req.body)
|
||||
) {
|
||||
if (!isPlainObject(req.body)) {
|
||||
res.status(400).json({ error: 'Invalid request body. Must be an object.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { claude, codex } = req.body as BrowserRouteBody;
|
||||
if (claude && (typeof claude !== 'object' || Array.isArray(claude))) {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const rootKeys = Object.keys(body);
|
||||
const unknownRootKeys = rootKeys.filter((key) => key !== 'claude' && key !== 'codex');
|
||||
if (unknownRootKeys.length > 0) {
|
||||
res.status(400).json({
|
||||
error: `Unknown browser config field(s): ${unknownRootKeys.join(', ')}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { claude, codex } = body as BrowserRouteBody;
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'claude') && !isPlainObject(claude)) {
|
||||
res.status(400).json({ error: 'Invalid value for claude. Must be an object.' });
|
||||
return;
|
||||
}
|
||||
if (codex && (typeof codex !== 'object' || Array.isArray(codex))) {
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'codex') && !isPlainObject(codex)) {
|
||||
res.status(400).json({ error: 'Invalid value for codex. Must be an object.' });
|
||||
return;
|
||||
}
|
||||
const unknownClaudeKeys = Object.keys(claude ?? {}).filter(
|
||||
(key) =>
|
||||
key !== 'enabled' &&
|
||||
key !== 'policy' &&
|
||||
key !== 'userDataDir' &&
|
||||
key !== 'devtoolsPort' &&
|
||||
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<void> => {
|
||||
});
|
||||
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<void> => {
|
||||
}
|
||||
});
|
||||
|
||||
function toBrowserRouteConfig(config: ReturnType<typeof getBrowserConfig>) {
|
||||
function toBrowserRouteConfig(config: BrowserConfig) {
|
||||
return {
|
||||
claude: {
|
||||
enabled: config.claude.enabled,
|
||||
policy: config.claude.policy,
|
||||
userDataDir: config.claude.user_data_dir,
|
||||
devtoolsPort: config.claude.devtools_port,
|
||||
evalMode: config.claude.eval_mode ?? 'readonly',
|
||||
},
|
||||
codex: {
|
||||
enabled: config.codex.enabled,
|
||||
policy: config.codex.policy,
|
||||
evalMode: config.codex.eval_mode ?? 'readonly',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<DashboardCliproxyInstallResult> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -21,7 +21,32 @@ function runCli(args: string[], extraEnv: Record<string, string> = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, data: unknown) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function createProfileConfig(profiles: Record<string, Record<string, string>>) {
|
||||
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 <start|stop|status|activate> [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 <profile>'
|
||||
);
|
||||
}, 35000);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<number> {
|
||||
if (server.listening) {
|
||||
return resolveListeningPort(server);
|
||||
}
|
||||
|
||||
return new Promise<number>((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> | void
|
||||
): Promise<void> {
|
||||
@@ -28,9 +50,8 @@ async function startUpstream(
|
||||
upstreamSockets.delete(socket);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((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<Response> {
|
||||
@@ -46,8 +67,6 @@ async function requestProxy(payload: unknown, signal?: AbortSignal): Promise<Res
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
upstreamPort = await getPort();
|
||||
proxyPort = await getPort();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-edge-'));
|
||||
originalTimeoutEnv = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
@@ -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<void>((resolve) => {
|
||||
const request = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: proxyPort,
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'test-proxy-token',
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: proxyPort,
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'test-proxy-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
() => resolve()
|
||||
);
|
||||
() => resolve()
|
||||
);
|
||||
|
||||
request.write(
|
||||
JSON.stringify({
|
||||
model: 'hf-model',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
})
|
||||
);
|
||||
request.end();
|
||||
request.write(
|
||||
JSON.stringify({
|
||||
model: 'hf-model',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
})
|
||||
);
|
||||
request.end();
|
||||
|
||||
setTimeout(() => {
|
||||
request.destroy(new Error('client aborted'));
|
||||
resolve();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const logPath = path.join(tempDir, '.ccs', 'logs', 'current.jsonl');
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
if (fs.existsSync(logPath)) {
|
||||
const content = fs.readFileSync(logPath, 'utf8');
|
||||
if (content.includes('"event":"request.disconnect"')) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt > 1500) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('proxy did not log disconnect cleanup'));
|
||||
}
|
||||
setTimeout(() => {
|
||||
request.socket?.destroy();
|
||||
resolve();
|
||||
}, 50);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const logPath = path.join(tempDir, '.ccs', 'logs', 'current.jsonl');
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
if (fs.existsSync(logPath)) {
|
||||
const content = fs.readFileSync(logPath, 'utf8');
|
||||
if (content.includes('"event":"request.disconnect"')) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt > 1500) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('proxy did not log disconnect cleanup'));
|
||||
}
|
||||
}, 50);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<number> {
|
||||
if (server.listening) {
|
||||
return resolveListeningPort(server);
|
||||
}
|
||||
|
||||
if (parsed.stream) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
return new Promise<number>((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<void> {
|
||||
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<void> {
|
||||
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<Response> {
|
||||
@@ -74,8 +122,6 @@ async function requestProxy(payload: unknown): Promise<Response> {
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<number> {
|
||||
if (server.listening) {
|
||||
return resolveListeningPort(server);
|
||||
}
|
||||
|
||||
return new Promise<number>((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<void> {
|
||||
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<number> {
|
||||
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, string>): 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',
|
||||
|
||||
@@ -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<string, unknown>): 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<string, { paused?: boolean; pausedAt?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
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<Record<string, string>>;
|
||||
};
|
||||
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<string, string>;
|
||||
presets?: Array<Record<string, string>>;
|
||||
};
|
||||
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', () => {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user