Merge pull request #1467 from kaitranntt/dev

feat(release): promote dev to main — Qoder provider, Opus 4.8, pricing & security batch
This commit is contained in:
Kai (Tam Nhu) Tran
2026-06-06 18:20:13 -04:00
committed by GitHub
209 changed files with 6076 additions and 1084 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

+4
View File
@@ -48,6 +48,7 @@ jobs:
with:
app-id: ${{ secrets.CCS_REVIEWER_APP_ID }}
private-key: ${{ secrets.CCS_REVIEWER_PRIVATE_KEY }}
permission-issues: write
- name: Post /review command to the PR
uses: actions/github-script@v7
@@ -111,6 +112,9 @@ jobs:
with:
app-id: ${{ secrets.CCS_REVIEWER_APP_ID }}
private-key: ${{ secrets.CCS_REVIEWER_PRIVATE_KEY }}
permission-issues: write
permission-pull-requests: write
permission-contents: read
- name: Run PR-Agent
uses: qodo-ai/pr-agent@v0.34
+34
View File
@@ -160,3 +160,37 @@ jobs:
- name: Assert compose parity (compose.yaml vs docker-compose.integrated.yml)
run: bash tests/docker/compose-parity.sh
# Single always-reporting status that branch protection requires.
#
# The validate/build/test jobs are gated to trusted author associations so
# untrusted fork code never executes on the self-hosted runners. A job skipped
# by a job-level `if` reports no status, so requiring those job names directly
# leaves fork PRs stuck on "Expected - waiting for status to be reported"
# forever. Requiring this gate instead keeps the contract satisfiable:
# - trusted PR: gate is green only if every gated job actually succeeded
# - fork PR: gated jobs skip, gate reports green so the PR is mergeable
# (fork code is still reviewed by a maintainer before merge)
#
# No checkout here: the gate only inspects upstream job results, so it runs no
# third-party code and is safe on the self-hosted runner.
ci-gate:
if: always()
needs: [validate, build, test]
runs-on: [self-hosted, linux, x64]
name: CI Gate
steps:
- name: Verify gated jobs did not fail
env:
VALIDATE: ${{ needs.validate.result }}
BUILD: ${{ needs.build.result }}
TEST: ${{ needs.test.result }}
run: |
echo "validate=$VALIDATE build=$BUILD test=$TEST"
for result in "$VALIDATE" "$BUILD" "$TEST"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
echo "[X] A required CI job did not pass (result: $result)"
exit 1
fi
done
echo "[OK] CI gate satisfied (success or skipped-for-fork)"
+2 -2
View File
@@ -139,7 +139,7 @@ jobs:
COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true)
ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \
grep -oE '#[0-9]+' || true)
PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \
@@ -155,7 +155,7 @@ jobs:
done
ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \
grep -oE '#[0-9]+' || true)
ISSUES=$(printf '%s\n%s\n' "$ISSUES_FROM_COMMITS" "$ISSUES_FROM_PRS" | \
+3 -3
View File
@@ -72,7 +72,7 @@ jobs:
if: steps.tag.outputs.publish == 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.target.outputs.tag }}
ref: ${{ format('refs/tags/{0}', steps.target.outputs.tag) }}
fetch-depth: 0
persist-credentials: false
@@ -207,7 +207,7 @@ jobs:
if: steps.tag.outputs.publish == 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.target.outputs.tag }}
ref: ${{ format('refs/tags/{0}', steps.target.outputs.tag) }}
persist-credentials: false
- name: Set up QEMU
@@ -314,7 +314,7 @@ jobs:
- name: Checkout release tag (for test scripts)
uses: actions/checkout@v4
with:
ref: ${{ needs.publish-integrated.outputs.version != '' && format('v{0}', needs.publish-integrated.outputs.version) || github.ref }}
ref: ${{ needs.publish-integrated.outputs.version != '' && format('refs/tags/v{0}', needs.publish-integrated.outputs.version) || github.ref }}
persist-credentials: false
- name: Derive image reference
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
$PR_BODY
$COMMIT_TEXT"
PR_ISSUES=$(printf '%s\n' "$PR_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \
grep -oE '#[0-9]+' || true)
if [[ -n "$PR_ISSUES" ]]; then
ALL_REFERENCED_ISSUES=$(printf '%s\n%s\n' "$ALL_REFERENCED_ISSUES" "$PR_ISSUES")
+1 -71
View File
@@ -102,74 +102,4 @@ jobs:
if: success() && steps.release.outputs.released == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(jq -r '.version' package.json)
RELEASE_BODY=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null || echo "")
PREVIOUS_STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --sort=-v:refname | \
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
grep -vx "v${VERSION}" | head -1 || echo "")
RANGE="${PREVIOUS_STABLE_TAG:+${PREVIOUS_STABLE_TAG}..HEAD~1}"
if [[ -z "$RANGE" ]]; then
RANGE="HEAD~50..HEAD~1"
fi
COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true)
PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \
grep -oE "Merge pull request #[0-9]+|\\(#[0-9]+\\)" | \
grep -oE "[0-9]+" | sort -u || true)
PR_TEXT=""
for PR_NUM in $PR_CANDIDATES; do
DETAILS=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json title,body --jq '.title + "\n" + (.body // "")' 2>/dev/null || true)
if [[ -n "$DETAILS" ]]; then
PR_TEXT="${PR_TEXT}"$'\n'"${DETAILS}"
fi
done
RELEASE_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RELEASE_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RELEASE_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RELEASE_ISSUES=$(printf '%s\n%s\n%s\n' \
"$RELEASE_ISSUES_FROM_BODY" \
"$RELEASE_ISSUES_FROM_COMMITS" \
"$RELEASE_ISSUES_FROM_PRS" | sort -u || true)
RESOLVED_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RESOLVED_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RESOLVED_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RESOLVED_ISSUES=$(printf '%s\n%s\n%s\n' \
"$RESOLVED_ISSUES_FROM_BODY" \
"$RESOLVED_ISSUES_FROM_COMMITS" \
"$RESOLVED_ISSUES_FROM_PRS" | sort -u || true)
if [[ -z "$RELEASE_ISSUES" ]]; then
echo "No release-scoped issues found for v${VERSION}"
exit 0
fi
for NUM in $RELEASE_ISSUES; do
echo "Cleaning release state on issue #$NUM"
gh issue edit "$NUM" \
--remove-label "released-dev" \
--remove-label "pending-release" \
--repo "${{ github.repository }}" 2>/dev/null || true
HAS_RELEASED=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name | select(. == "released")] | length' 2>/dev/null || echo "0")
if [[ "$HAS_RELEASED" -gt 0 ]] && printf '%s\n' "$RESOLVED_ISSUES" | grep -qx "$NUM"; then
gh issue close "$NUM" \
--comment "[bot] Closing issue because this fix/feature is now in stable release (@latest)." \
--repo "${{ github.repository }}" || true
fi
done
run: node scripts/github/stable-release-issue-cleanup.mjs
+4 -4
View File
@@ -2,9 +2,9 @@
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kiro",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "kiro",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kiro",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kiro",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kiro"
"ANTHROPIC_MODEL": "kiro-claude-sonnet-4-6",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kiro-claude-opus-4-7",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kiro-claude-sonnet-4-6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kiro-claude-haiku-4-5"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/qoder",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "qoder/auto",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "qoder/ultimate",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "qoder/performance",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "qoder/efficient"
}
}
+8 -15
View File
@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1
# syntax=docker/dockerfile:1.7
# =============================================================================
# Build stage: compile TypeScript and build UI
@@ -36,13 +36,10 @@ RUN bun run build:all
# Validate build artifacts exist
RUN test -d dist && test -d lib && echo "[OK] Build artifacts validated"
# Generate a fresh npm lockfile from package.json immediately after build.
# This lockfile is ephemeral — never committed; bun.lock remains the dev lockfile.
# --ignore-scripts: only lockfile generation, no install side-effects needed here.
# postinstall (scripts/postinstall.js) is a user-facing setup script; it must NOT
# run during the Docker build (it writes to ~/.ccs/ which does not exist here).
RUN --mount=type=cache,target=/root/.npm \
npm install --package-lock-only --ignore-scripts
# Build runtime deps from the committed bun.lock to keep Docker installs pinned.
# --ignore-scripts avoids user-home side effects from postinstall during image build.
RUN --mount=type=cache,target=/root/.bun \
bun install --frozen-lockfile --production --ignore-scripts
# =============================================================================
# Runtime stage: Node-only, no Bun
@@ -60,13 +57,9 @@ RUN apt-get update \
WORKDIR /app
# Copy the ephemeral lockfile generated in the build stage (never committed).
# --ignore-scripts: postinstall writes ~/.ccs/ config which is not needed inside
# the Docker image; runtime deps (bcrypt v6+, express, etc.) have no native
# compilation steps requiring postinstall.
COPY --from=build /app/package.json /app/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
# Copy pinned production dependencies resolved from the committed bun.lock.
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY docker/entrypoint.sh /usr/local/bin/ccs-entrypoint
RUN chmod +x /usr/local/bin/ccs-entrypoint
+18 -10
View File
@@ -113,10 +113,11 @@ environment:
Running `ccs config auth setup` on the outer host shell updates that machine's own `~/.ccs`, not the Docker volume mounted into `ccs-cliproxy`. For the integrated stack, configure auth inside the container or provide the auth env vars in Compose.
Generate a bcrypt hash:
Generate a bcrypt hash without putting the password in shell history or process arguments:
```bash
docker exec ccs-cliproxy node -e "console.log(require('bcrypt').hashSync('your-password', 10))"
docker exec -i ccs-cliproxy node -e "const fs=require('fs'); const bcrypt=require('bcrypt'); const password=fs.readFileSync(0,'utf8').trimEnd(); console.log(bcrypt.hashSync(password, 10));"
# then type/paste the password followed by Enter (stdin is not exposed via argv)
```
> **Note:** Do not commit the password hash in `docker-compose.yml`. Use Docker secrets or a `.env` file (not tracked in git) for sensitive values like `CCS_DASHBOARD_PASSWORD_HASH`.
@@ -160,15 +161,20 @@ docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy
For remote deployments via `ccs docker up --host`:
```bash
# Copy tokens into the running container (no root/sudo needed)
scp /path/to/auth/*.json my-server:/tmp/ccs-auth/
ssh my-server 'for f in /tmp/ccs-auth/*.json; do docker cp "$f" ccs-cliproxy:/root/.ccs/cliproxy/auth/; done'
# Create a private staging directory (0700) and print its path
STAGE_DIR=$(ssh my-server 'umask 077 && mktemp -d "${HOME}/.ccs-auth.XXXXXX"')
# Copy only JSON token files into the private staging directory
scp /path/to/auth/*.json "my-server:${STAGE_DIR}/"
# Restrict file permissions and import each staged token into the container
ssh my-server "chmod 600 \"${STAGE_DIR}\"/*.json && for f in \"${STAGE_DIR}\"/*.json; do docker cp \"\$f\" ccs-cliproxy:/root/.ccs/cliproxy/auth/; done"
# Restart CLIProxy to load new tokens
ssh my-server "docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy"
# Clean up temp files
ssh my-server "rm -rf /tmp/ccs-auth"
# Clean up private staging files
ssh my-server "rm -rf \"${STAGE_DIR}\""
```
> **Tip:** `docker cp` is preferred over writing directly to Docker volume mountpoints, which require root access.
@@ -191,10 +197,12 @@ docker exec ccs-cliproxy curl -fsS http://127.0.0.1:3000/api/health \
# 4. Verify auth tokens loaded (check client count)
docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log
# 5. Test dashboard API (from remote -- requires auth)
curl -fsS -X POST http://<host>:3000/api/auth/login \
# 5. Test dashboard API (from remote -- requires auth + HTTPS)
read -r -s CCS_DASHBOARD_PASSWORD && echo
curl -fsS -X POST https://<host>:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"your-password"}'
-d "{\"username\":\"admin\",\"password\":\"${CCS_DASHBOARD_PASSWORD}\"}"
unset CCS_DASHBOARD_PASSWORD
```
Expected healthy output:
+1 -1
View File
@@ -136,7 +136,7 @@ export function resolveTargetType(
// 4. Check profile config
if (profileConfig?.target) {
// Persisted targets intentionally exclude runtime-only codex.
// Persisted targets are validated before profile configuration is saved.
return profileConfig.target;
}
+31 -15
View File
@@ -159,6 +159,8 @@ const DEFAULT_WAIT_TIMEOUT_MS = 2000;
const DEFAULT_WAIT_POLL_INTERVAL_MS = 100;
const DEFAULT_DRAG_STEPS = 5;
const MAX_POINTER_ACTIONS = 25;
const MAX_CLICK_COUNT = 25;
const MAX_KEY_REPEAT = 25;
const SESSION_START_SETTLE_WINDOW_MS = 250;
const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024;
const MAX_LOCAL_TRANSFER_FILE_BYTES = 10 * 1024 * 1024;
@@ -454,6 +456,7 @@ function getTools() {
clickCount: {
type: 'integer',
minimum: 1,
maximum: MAX_CLICK_COUNT,
description: 'Optional click count. Defaults to 1.',
},
},
@@ -519,6 +522,7 @@ function getTools() {
repeat: {
type: 'integer',
minimum: 1,
maximum: MAX_KEY_REPEAT,
description: 'Optional repeat count. Defaults to 1.',
},
},
@@ -1230,11 +1234,16 @@ function getTools() {
];
}
async function fetchJson(url, options = undefined) {
async function fetchOk(url, options = undefined) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response;
}
async function fetchJson(url, options = undefined) {
const response = await fetchOk(url, options);
return await response.json();
}
@@ -2305,10 +2314,13 @@ function requirePositiveIntegerOrDefault(value, label, fallback) {
return value;
}
function requirePositiveInteger(value, label) {
function requirePositiveInteger(value, label, maximum = undefined) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive integer`);
}
if (maximum !== undefined && value > maximum) {
throw new Error(`${label} must be less than or equal to ${maximum}`);
}
return value;
}
@@ -3555,7 +3567,7 @@ async function handleClick(toolArgs) {
const clickCount =
toolArgs.clickCount === undefined
? 1
: requirePositiveInteger(toolArgs.clickCount, 'clickCount');
: requirePositiveInteger(toolArgs.clickCount, 'clickCount', MAX_CLICK_COUNT);
const expression = `(() => {
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))});
@@ -3805,7 +3817,9 @@ async function handlePressKey(toolArgs) {
'Shift',
]);
const repeat =
toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat');
toolArgs.repeat === undefined
? 1
: requirePositiveInteger(toolArgs.repeat, 'repeat', MAX_KEY_REPEAT);
const modifierMask =
(modifiers.includes('Alt') ? 1 : 0) |
(modifiers.includes('Control') ? 2 : 0) |
@@ -5097,16 +5111,18 @@ async function ensureInterceptSession(page) {
})
);
}
pushRecentRequest({
requestId: String(paused.requestId || ''),
pageId: page.id,
url: String(paused.request?.url || ''),
method: String(paused.request?.method || ''),
resourceType: String(paused.resourceType || ''),
matchedRuleId: matchedRule ? matchedRule.ruleId : '',
action,
statusCode: action === 'fulfill' ? matchedRule.statusCode : 0,
});
if (matchedRule) {
pushRecentRequest({
requestId: String(paused.requestId || ''),
pageId: page.id,
url: String(paused.request?.url || ''),
method: String(paused.request?.method || ''),
resourceType: String(paused.resourceType || ''),
matchedRuleId: matchedRule.ruleId,
action,
statusCode: action === 'fulfill' ? matchedRule.statusCode : 0,
});
}
})();
activityChain = activityChain
.catch(() => {})
@@ -5193,7 +5209,7 @@ async function handleClosePage(toolArgs) {
activeRecordingSession = null;
}
await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`);
await fetchOk(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`, { method: 'PUT' });
const interceptSession = interceptSessionsByPageId.get(page.id);
if (interceptSession) {
closeSocket(interceptSession.ws);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "8.1.4",
"version": "8.1.4-dev.8",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
-12
View File
@@ -22,18 +22,6 @@ __ccs_completion_run() {
local current="$1"
shift || true
local script_dir repo_root repo_cli
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/../.." && pwd)"
repo_cli="${repo_root}/dist/ccs.js"
if [[ ! -f "${repo_cli}" ]]; then
repo_cli="${repo_root}/bin/ccs.js"
fi
if [[ -f "${repo_cli}" ]]; then
node "${repo_cli}" __complete --shell bash --current "${current}" -- "$@" 2>/dev/null
return 0
fi
if command -v ccs >/dev/null 2>&1; then
ccs __complete --shell bash --current "${current}" -- "$@" 2>/dev/null
fi
-11
View File
@@ -11,17 +11,6 @@ function __fish_ccs_complete
set -e tokens_before_current[-1]
end
set -l script_file (status filename)
set -l repo_root (realpath (dirname $script_file)/../.. 2>/dev/null)
set -l repo_cli "$repo_root/dist/ccs.js"
if not test -f "$repo_cli"
set repo_cli "$repo_root/bin/ccs.js"
end
if test -f "$repo_cli"
node "$repo_cli" __complete --shell fish --current "$current" -- $tokens_before_current 2>/dev/null
return
end
if command -sq ccs
ccs __complete --shell fish --current "$current" -- $tokens_before_current 2>/dev/null
end
-10
View File
@@ -6,16 +6,6 @@ function Invoke-CcsCompletionBackend {
[string[]]$TokensBeforeCurrent
)
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$repoCli = Join-Path $repoRoot 'dist\ccs.js'
if (-not (Test-Path $repoCli)) {
$repoCli = Join-Path $repoRoot 'bin\ccs.js'
}
if (Test-Path $repoCli) {
& node $repoCli __complete --shell powershell --current $CurrentWord -- @TokensBeforeCurrent 2>$null
return
}
if (Get-Command ccs -ErrorAction SilentlyContinue) {
& ccs __complete --shell powershell --current $CurrentWord -- @TokensBeforeCurrent 2>$null
}
-13
View File
@@ -22,19 +22,6 @@ __ccs_completion_run() {
local current="$1"
shift || true
local script_path script_dir repo_root repo_cli
script_path="${(%):-%N}"
script_dir="${script_path:A:h}"
repo_root="${script_dir:h:h}"
repo_cli="${repo_root}/dist/ccs.js"
if [[ ! -f "${repo_cli}" ]]; then
repo_cli="${repo_root}/bin/ccs.js"
fi
if [[ -f "${repo_cli}" ]]; then
node "${repo_cli}" __complete --shell zsh --current "${current}" -- "$@" 2>/dev/null
return 0
fi
if (( $+commands[ccs] )); then
ccs __complete --shell zsh --current "${current}" -- "$@" 2>/dev/null
fi
@@ -0,0 +1,115 @@
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
const ISSUE_REF_PATTERN = /#([0-9]+)/g;
const ACTION_VERB_PATTERN =
/\b(?:fixes|closes|resolves|refs?)\b\s+(#\d+\b(?:\s*(?:,|and)?\s*#\d+\b)*)/gi;
const RESOLVE_VERB_PATTERN =
/\b(?:fixes|closes|resolves)\b\s+(#\d+\b(?:\s*(?:,|and)?\s*#\d+\b)*)/gi;
const PR_REF_PATTERN = /(?:Merge pull request #|\(#)([0-9]+)/g;
const STABLE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+$/;
export function extractIssueNumbers(text, { includeRefs = true } = {}) {
const pattern = includeRefs ? ACTION_VERB_PATTERN : RESOLVE_VERB_PATTERN;
const issues = new Set();
let actionMatch;
pattern.lastIndex = 0;
while ((actionMatch = pattern.exec(text || '')) !== null) {
const tail = actionMatch[1] || '';
let issueMatch;
ISSUE_REF_PATTERN.lastIndex = 0;
while ((issueMatch = ISSUE_REF_PATTERN.exec(tail)) !== null) {
issues.add(Number(issueMatch[1]));
}
}
return [...issues].sort((a, b) => a - b);
}
export function extractPrNumbers(text) {
const prs = new Set();
let match;
PR_REF_PATTERN.lastIndex = 0;
while ((match = PR_REF_PATTERN.exec(text || '')) !== null) {
prs.add(Number(match[1]));
}
return [...prs].sort((a, b) => a - b);
}
export function planIssueCleanup({ releaseIssues, resolvedIssues, issueStates }) {
const resolved = new Set(resolvedIssues);
return releaseIssues.map((number) => {
const state = issueStates.get(number) || { labels: [], state: 'UNKNOWN' };
const labels = new Set(state.labels);
const wasReleasedDev = labels.has('released-dev');
const shouldClose = state.state === 'OPEN' && (wasReleasedDev || resolved.has(number));
return {
number,
removeLabels: ['released-dev', 'pending-release'],
addReleasedLabel: shouldClose,
close: shouldClose,
reason: wasReleasedDev ? 'promoted from dev to stable' : 'resolved by stable release',
};
});
}
export function getStableReleaseContext({ env = process.env, exec = runCommand } = {}) {
const repo = env.GITHUB_REPOSITORY;
if (!repo) throw new Error('GITHUB_REPOSITORY is required');
const version = JSON.parse(readFileSync('package.json', 'utf8')).version;
const currentTag = `v${version}`;
const releaseBody = exec('gh', [
'release',
'view',
currentTag,
'--repo',
repo,
'--json',
'body',
'--jq',
'.body',
]);
const tags = exec('git', ['tag', '-l', 'v[0-9]*.[0-9]*.[0-9]*', '--sort=-v:refname'])
.split('\n')
.map((tag) => tag.trim())
.filter((tag) => STABLE_TAG_PATTERN.test(tag) && tag !== currentTag);
const previousStableTag = tags[0] || '';
const range = previousStableTag ? `${previousStableTag}..HEAD~1` : 'HEAD~50..HEAD~1';
const commitText = exec('git', ['log', range, '--pretty=format:%s%n%b'], { optional: true });
return { repo, version, currentTag, releaseBody, range, commitText };
}
export function buildReleaseIssueSet({ releaseBody, commitText, prText }) {
const releaseIssues = new Set([
...extractIssueNumbers(releaseBody, { includeRefs: true }),
...extractIssueNumbers(commitText, { includeRefs: true }),
...extractIssueNumbers(prText, { includeRefs: true }),
]);
const resolvedIssues = new Set([
...extractIssueNumbers(releaseBody, { includeRefs: false }),
...extractIssueNumbers(commitText, { includeRefs: false }),
...extractIssueNumbers(prText, { includeRefs: false }),
]);
return {
releaseIssues: [...releaseIssues].sort((a, b) => a - b),
resolvedIssues: [...resolvedIssues].sort((a, b) => a - b),
};
}
export function runCommand(command, args, { optional = false } = {}) {
const result = spawnSync(command, args, { encoding: 'utf8' });
if (result.status !== 0) {
if (optional) return '';
throw new Error(
`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`
);
}
return result.stdout.trim();
}
@@ -0,0 +1,134 @@
import {
buildReleaseIssueSet,
extractPrNumbers,
getStableReleaseContext,
planIssueCleanup,
runCommand,
} from './stable-release-issue-cleanup-lib.mjs';
function gh(args, options) {
return runCommand('gh', args, options);
}
function fetchPrText(repo, commitText) {
let prText = '';
for (const prNumber of extractPrNumbers(commitText)) {
const details = gh(
[
'pr',
'view',
String(prNumber),
'--repo',
repo,
'--json',
'title,body',
'--jq',
'.title + "\n" + (.body // "")',
],
{ optional: true }
);
if (details) prText += `\n${details}`;
}
return prText;
}
function fetchIssueStates(repo, issueNumbers) {
const states = new Map();
for (const number of issueNumbers) {
const raw = gh(
[
'issue',
'view',
String(number),
'--repo',
repo,
'--json',
'state,labels',
'--jq',
'{state:.state,labels:[.labels[].name]}',
],
{ optional: true }
);
if (!raw) continue;
states.set(number, JSON.parse(raw));
}
return states;
}
function ensureReleasedLabel(repo) {
gh(
[
'label',
'create',
'released',
'--color',
'ededed',
'--description',
'Fix available in stable npm channel',
'--repo',
repo,
],
{ optional: true }
);
}
function applyAction(repo, action) {
console.log(`Cleaning release state on issue #${action.number}`);
for (const label of action.removeLabels) {
gh(['issue', 'edit', String(action.number), '--remove-label', label, '--repo', repo], {
optional: true,
});
}
if (!action.close) return;
gh(['issue', 'edit', String(action.number), '--add-label', 'released', '--repo', repo], {
optional: true,
});
gh(
[
'issue',
'close',
String(action.number),
'--comment',
'[bot] Closing issue because this fix/feature is now in stable release (@latest).',
'--repo',
repo,
],
{ optional: true }
);
console.log(`Closed issue #${action.number}: ${action.reason}`);
}
export function main() {
const context = getStableReleaseContext();
console.log(`Checking stable release issue lifecycle for ${context.currentTag}`);
console.log(`Checking commits in range: ${context.range}`);
const prText = fetchPrText(context.repo, context.commitText);
const { releaseIssues, resolvedIssues } = buildReleaseIssueSet({
releaseBody: context.releaseBody,
commitText: context.commitText,
prText,
});
if (releaseIssues.length === 0) {
console.log(`No release-scoped issues found for ${context.currentTag}`);
return;
}
ensureReleasedLabel(context.repo);
const issueStates = fetchIssueStates(context.repo, releaseIssues);
for (const action of planIssueCleanup({ releaseIssues, resolvedIssues, issueStates })) {
applyAction(context.repo, action);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
try {
main();
} catch (error) {
console.error(error);
process.exit(1);
}
}
+1 -1
View File
@@ -809,7 +809,7 @@ export function getOfficialChannelsLegacyEnableHelp(): string {
}
export function getOfficialChannelTokenHelp(): string {
return 'Use --set-token <channel>=<token>. If no channel is provided, Discord is assumed for backward compatibility.';
return 'Use --set-token <channel> and pass the token via that channel env var (for example TELEGRAM_BOT_TOKEN=... ccs config channels --set-token telegram).';
}
export function getOfficialChannelClearTokenHelp(): string {
@@ -55,6 +55,18 @@ describe('model-catalog compatibility lookups', () => {
expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']);
});
it('falls back to a visible live model when the static default is absent', () => {
const catalog = mergeCatalog('gemini', [
{
id: 'gemini-live-only',
display_name: 'Gemini Live Only',
},
]);
expect(catalog?.defaultModel).toBe('gemini-live-only');
expect(catalog?.models.map((model) => model.id)).toContain(catalog?.defaultModel);
});
it('preserves static maxLevel when live thinking metadata omits it', () => {
const catalog = mergeCatalog('claude', [
{
@@ -155,6 +155,19 @@ describe('Model Catalog', () => {
assert.strictEqual(opus47.extendedContext, true);
});
it('includes Claude Opus 4.8 with adaptive levels and extended context', () => {
const { MODEL_CATALOG } = modelCatalog;
const opus48 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-4-8');
assert(opus48, 'Should include Claude Opus 4.8');
assert.strictEqual(opus48.name, 'Claude Opus 4.8');
// Mirrors 4.7: Anthropic only accepts adaptive thinking levels on the
// current Opus generation; budget_tokens is rejected with 400.
assert.strictEqual(opus48.thinking.type, 'levels');
assert.deepStrictEqual(opus48.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']);
assert.strictEqual(opus48.thinking.maxLevel, 'max');
assert.strictEqual(opus48.extendedContext, true);
});
it('retains previous 4.5 snapshot models for explicit selection', () => {
const { MODEL_CATALOG } = modelCatalog;
const ids = MODEL_CATALOG.claude.models.map((m) => m.id);
@@ -46,6 +46,7 @@ describe('provider-capabilities', () => {
'gitlab',
'codebuddy',
'kilo',
'qoder',
]);
});
@@ -66,6 +67,7 @@ describe('provider-capabilities', () => {
'cursor',
'codebuddy',
'kilo',
'qoder',
]);
expect(getProvidersByOAuthFlow('authorization_code')).toEqual([
'gemini',
@@ -85,6 +87,7 @@ describe('provider-capabilities', () => {
'kimi',
'codebuddy',
'kilo',
'qoder',
]);
});
@@ -51,6 +51,15 @@ describe('Thinking Validator', () => {
expect(result.warning).toBeUndefined();
});
it('should treat max as a distinct top tier on Opus 4.8', () => {
// Opus 4.8 inherits 4.7's adaptive thinking surface; max must remain
// distinct from xhigh.
const result = validateThinking('claude', 'claude-opus-4-8', 'max');
expect(result.valid).toBe(true);
expect(result.value).toBe('max');
expect(result.warning).toBeUndefined();
});
it('should still alias max -> xhigh for models without a max level (backcompat)', () => {
// Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'.
// User input "max" should map down to xhigh rather than be rejected.
@@ -280,6 +280,40 @@ describe('account registry integrity', () => {
});
});
it('strips alternate token file prefixes when inferring missing emails', async () => {
const cases = [
{ provider: 'gemini', tokenFile: 'google-user@example.com.json', type: 'gemini' },
{ provider: 'codex', tokenFile: 'openai-user@example.com.json', type: 'codex' },
{ provider: 'agy', tokenFile: 'antigravity-user@example.com.json', type: 'antigravity' },
{
provider: 'ghcp',
tokenFile: 'github-copilot-user@example.com.json',
type: 'github-copilot',
},
] as const;
for (const testCase of cases) {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(
path.join(authDir, testCase.tokenFile),
JSON.stringify({ type: testCase.type }),
'utf8'
);
const { discoverExistingAccounts, loadAccountsRegistry } = await loadRegistryModule();
discoverExistingAccounts();
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[testCase.provider];
expect(Object.keys(providerAccounts?.accounts ?? {})).toEqual(['user@example.com']);
expect(providerAccounts?.accounts['user@example.com']?.email).toBe('user@example.com');
expect(providerAccounts?.accounts['user@example.com']?.tokenFile).toBe(testCase.tokenFile);
});
}
});
it('preserves the corrupted registry backup when recovery cannot rewrite accounts.json', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
@@ -21,6 +21,7 @@ import {
restoreExpiredQuotaPauses,
} from '../../accounts/account-safety';
import { sanitizeEmail } from '../../auth/auth-utils';
import { pauseAccount } from '../registry';
// Setup test isolation
let tmpDir: string;
@@ -1228,6 +1229,65 @@ describe('Quota Exhaustion Handlers', () => {
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
});
it('does not auto-resume when a user manually re-pauses a quota-paused account', 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 quotaPausedPath = path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json');
const originalPausedAt = '2026-01-01T00:00:00.000Z';
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as {
providers: {
agy: {
accounts: Record<string, { paused?: boolean; pausedAt?: string }>;
};
};
};
const quotaPaused = JSON.parse(fs.readFileSync(quotaPausedPath, 'utf8')) as {
entries: Array<{ pausedAt: string }>;
};
registry.providers.agy.accounts['cooldown@gmail.com'].pausedAt = originalPausedAt;
quotaPaused.entries[0].pausedAt = originalPausedAt;
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
fs.writeFileSync(quotaPausedPath, JSON.stringify(quotaPaused, null, 2));
expect(pauseAccount('agy', 'cooldown@gmail.com')).toBe(true);
const refreshedRegistry = JSON.parse(
fs.readFileSync(registryPath, 'utf8')
) as typeof registry;
expect(refreshedRegistry.providers.agy.accounts['cooldown@gmail.com'].pausedAt).not.toBe(
originalPausedAt
);
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
const { getAccount } = await import('../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(quotaPausedPath)).toBe(false);
});
it('does not auto-resume quota-paused accounts when pausedAt metadata is missing', async () => {
writeRegistry({
agy: {
+15 -4
View File
@@ -7,6 +7,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { CLIProxyProvider } from '../types';
import { PROVIDER_CAPABILITIES } from '../provider-capabilities';
import { PROVIDER_TYPE_VALUES } from '../auth/auth-types';
import { getAuthDir, getCliproxyDir } from '../config/config-generator';
import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types';
@@ -61,15 +62,21 @@ function resolveProviderFromTokenType(typeValue: string): CLIProxyProvider | und
const EMAIL_FILE_NAME_PATTERN = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
function stripTokenFileProviderPrefix(baseName: string, provider: CLIProxyProvider): string {
const knownPrefixes = [...PROVIDER_CAPABILITIES[provider].authFilePrefixes, `${provider}-`].sort(
(a, b) => b.length - a.length
);
const prefix = knownPrefixes.find((knownPrefix) => baseName.startsWith(knownPrefix));
return prefix ? baseName.slice(prefix.length) : baseName;
}
function inferEmailFromTokenFileName(
tokenFile: string,
provider: CLIProxyProvider
): string | undefined {
const baseName = tokenFile.replace(/\.json$/i, '');
const providerPrefix = `${provider}-`;
const candidate = baseName.startsWith(providerPrefix)
? baseName.slice(providerPrefix.length)
: baseName;
const candidate = stripTokenFileProviderPrefix(baseName, provider);
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
const scopedCandidate = candidate.slice(candidate.indexOf('-') + 1);
@@ -660,6 +667,10 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
const accountMeta = providerAccounts.accounts[accountId];
if (accountMeta.paused) {
// Treat an explicit pause request for an already paused account as a fresh
// manual decision. This changes the pause metadata so quota cooldown
// restore cannot later mistake the pause for its original auto-pause.
accountMeta.pausedAt = new Date().toISOString();
return true;
}
@@ -78,6 +78,21 @@ describe('codex plan compatibility', () => {
).toBe('gpt-5.4-mini');
});
it('prefers a rejected model explicit free-plan fallback over saved paid-only models', () => {
expect(
resolveRuntimeCodexFallbackModel({
requestedModel: 'gpt-5.3-codex-spark',
modelMap: {
defaultModel: 'gpt-5.3-codex',
opusModel: 'gpt-5.3-codex',
sonnetModel: 'gpt-5.3-codex',
haikuModel: 'gpt-5.3-codex-spark',
},
excludeModels: ['gpt-5.3-codex-spark'],
})
).toBe('gpt-5.4-mini');
});
it('tracks Codex thinking caps for current safe defaults, paid models, and legacy aliases', () => {
expect(getModelMaxLevel('codex', 'gpt-5.5')).toBe('xhigh');
expect(getModelMaxLevel('codex', 'gpt-5.4')).toBe('xhigh');
@@ -76,6 +76,53 @@ function postJson(
});
}
function postJsonText(
url: string,
body: JsonRecord,
timeoutMs = 2_000
): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const payload = JSON.stringify(body);
let timeout: ReturnType<typeof setTimeout>;
const req = http.request(
{
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
(res) => {
let responseBody = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
responseBody += chunk;
});
res.on('end', () => {
clearTimeout(timeout);
resolve({ statusCode: res.statusCode ?? 0, body: responseBody });
});
}
);
timeout = setTimeout(() => {
req.destroy(new Error(`Timed out waiting for proxy response after ${timeoutMs}ms`));
}, timeoutMs);
req.on('error', (error) => {
clearTimeout(timeout);
reject(error);
});
req.write(payload);
req.end();
});
}
describe('CodexReasoningProxy extended-context compatibility', () => {
const cleanupServers: http.Server[] = [];
@@ -147,6 +194,84 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
});
it('ends streaming responses when upstream stalls after headers', async () => {
const upstream = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write(
'event: message_start\n' +
'data: {"type":"message_start","message":{"id":"msg_stall","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n'
);
});
});
cleanupServers.push(upstream);
const upstreamPort = await listenOnRandomPort(upstream);
const proxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
modelMap: { defaultModel: 'gpt-5.3-codex' },
timeoutMs: 100,
});
const proxyPort = await proxy.start();
try {
const response = await postJsonText(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.3-codex',
messages: [],
stream: true,
}
);
expect(response.statusCode).toBe(200);
expect(response.body).toContain('message_start');
expect(response.body).toContain('timeout_error');
expect(response.body).toContain('Upstream response timed out');
} finally {
proxy.stop();
}
});
it('returns 504 when non-stream upstream stalls after headers', async () => {
const upstream = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.flushHeaders();
});
});
cleanupServers.push(upstream);
const upstreamPort = await listenOnRandomPort(upstream);
const proxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
modelMap: { defaultModel: 'gpt-5.3-codex' },
timeoutMs: 100,
});
const proxyPort = await proxy.start();
try {
const response = await postJsonText(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.3-codex',
messages: [],
}
);
expect(response.statusCode).toBe(504);
expect(response.body).toContain('Upstream response timed out');
} finally {
proxy.stop();
}
});
it('translates codex fast model suffixes into service_tier', async () => {
const capturedBodies: JsonRecord[] = [];
@@ -296,6 +421,39 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
});
it('rejects oversized non-2xx upstream error bodies', async () => {
const upstream = http.createServer((_req, res) => {
res.writeHead(500, { 'Content-Type': 'application/json' });
const chunk = 'x'.repeat(1024 * 1024);
for (let i = 0; i < 11; i += 1) {
res.write(chunk);
}
res.end();
});
cleanupServers.push(upstream);
const upstreamPort = await listenOnRandomPort(upstream);
const proxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
modelMap: { defaultModel: 'gpt-5.4' },
defaultEffort: 'medium',
});
const proxyPort = await proxy.start();
const response = await postJson(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.4',
messages: [],
}
);
proxy.stop();
expect(response.statusCode).toBe(502);
expect(response.body.error).toContain('Upstream error response exceeded 10MB limit');
});
it('keeps fast service tier when disableEffort is enabled', async () => {
let capturedBody: JsonRecord | null = null;
@@ -114,8 +114,8 @@ export function resolveRuntimeCodexFallbackModel(options: {
(options.excludeModels ?? []).map((model) => normalizeCodexModelId(model)).filter(Boolean)
);
const candidates = [
options.modelMap.defaultModel,
getFreePlanFallbackCodexModel(requestedModel),
options.modelMap.defaultModel,
options.modelMap.opusModel,
options.modelMap.sonnetModel,
options.modelMap.haikuModel,
@@ -7,6 +7,10 @@ import {
resolveRuntimeCodexFallbackModel,
} from './codex-plan-compatibility';
import { getModelMaxLevel } from '../model-catalog';
import {
attachUpstreamResponseTimeout,
writeForwardResponseHead,
} from '../proxy/upstream-response-timeout';
export type CodexReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type CodexServiceTier = 'fast';
@@ -301,7 +305,7 @@ export class CodexReasoningProxy {
headers: http.IncomingHttpHeaders,
responseBody: string
): void {
clientRes.writeHead(statusCode, headers);
writeForwardResponseHead(clientRes, statusCode, headers);
clientRes.end(responseBody);
}
@@ -635,10 +639,38 @@ export class CodexReasoningProxy {
),
(upstreamRes) => {
clearResponseTimeout();
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
upstreamRes.pipe(clientRes);
upstreamRes.on('end', () => resolve());
upstreamRes.on('error', reject);
const statusCode = upstreamRes.statusCode || 200;
let responseStarted = false;
const writeResponseHead = () => {
if (responseStarted) return;
responseStarted = true;
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
};
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
upstreamReq,
upstreamRes,
clientRes,
timeoutMs: this.config.timeoutMs,
onTimeout: () => resolve(),
});
upstreamRes.on('data', (chunk: Buffer) => {
writeResponseHead();
const canContinue = clientRes.write(chunk);
if (!canContinue) {
upstreamRes.pause();
clientRes.once('drain', () => upstreamRes.resume());
}
});
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
writeResponseHead();
clientRes.end();
resolve();
});
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
}
);
@@ -672,17 +704,60 @@ export class CodexReasoningProxy {
(upstreamRes) => {
clearResponseTimeout();
const statusCode = upstreamRes.statusCode || 200;
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
upstreamReq,
upstreamRes,
clientRes,
timeoutMs: this.config.timeoutMs,
onTimeout: () => resolve(504),
});
if (statusCode >= 200 && statusCode < 300) {
clientRes.writeHead(statusCode, upstreamRes.headers);
upstreamRes.pipe(clientRes);
upstreamRes.on('end', () => resolve(statusCode));
upstreamRes.on('error', reject);
let responseStarted = false;
const writeResponseHead = () => {
if (responseStarted) return;
responseStarted = true;
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
};
upstreamRes.on('data', (chunk: Buffer) => {
writeResponseHead();
const canContinue = clientRes.write(chunk);
if (!canContinue) {
upstreamRes.pause();
clientRes.once('drain', () => upstreamRes.resume());
}
});
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
writeResponseHead();
clientRes.end();
resolve(statusCode);
});
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
return;
}
const maxErrorResponseSize = 10 * 1024 * 1024; // 10MB
let totalResponseBytes = 0;
let responseTooLarge = false;
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
upstreamRes.on('data', (chunk: Buffer) => {
totalResponseBytes += chunk.length;
if (totalResponseBytes > maxErrorResponseSize) {
responseTooLarge = true;
upstreamRes.destroy(new Error('Upstream error response exceeded 10MB limit'));
return;
}
chunks.push(chunk);
});
upstreamRes.on('end', async () => {
clearUpstreamResponseTimeout();
if (responseTooLarge) {
reject(new Error('Upstream error response exceeded 10MB limit'));
return;
}
try {
const responseBody = Buffer.concat(chunks).toString('utf8');
const unsupportedError =
@@ -741,7 +816,10 @@ export class CodexReasoningProxy {
reject(error);
}
});
upstreamRes.on('error', reject);
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
}
);
@@ -306,7 +306,11 @@ describe('handlePasteCallbackMode traceability', () => {
},
{
url: /\/v0\/management\/oauth-callback$/,
response: { status: 'error', error: 'invalid_grant: expired code' },
response: {
status: 'error',
error:
'invalid_grant: bad redirect http://localhost:1455/callback?code=oauth-code-secret&state=upstream-state-secret',
},
status: 400,
},
]);
@@ -120,9 +120,29 @@ describe('createOAuthTraceRecorder', () => {
expect(snap[0].error).toEqual({ code: 'E1', message: 'boom' });
});
test('Error instance accepted', () => {
test('redacts OAuth secrets from error messages before they reach sinks', () => {
const lines: string[] = [];
const { rec } = makeRecorder(true, lines);
rec.record(OAuthTracePhase.Error, undefined, {
code: 'CALLBACK_REJECTED',
message:
'bad redirect http://localhost:1455/callback?code=AUTHCODE_SECRET&state=STATE_SECRET',
});
const blob = JSON.stringify(rec.snapshot()) + '\n' + lines.join('\n');
expect(blob).not.toContain('AUTHCODE_SECRET');
expect(blob).not.toContain('STATE_SECRET');
expect(blob).toContain(REDACTED_PLACEHOLDER);
});
test('Error instance accepted and redacted', () => {
const { rec } = makeRecorder();
rec.record(OAuthTracePhase.Error, undefined, new Error('plain'));
expect(rec.snapshot()[0].error?.message).toBe('plain');
rec.record(
OAuthTracePhase.Error,
undefined,
new Error('bad redirect http://localhost:1455/callback?code=AUTHCODE_SECRET')
);
expect(rec.snapshot()[0].error?.message).toContain(REDACTED_PLACEHOLDER);
expect(rec.snapshot()[0].error?.message).not.toContain('AUTHCODE_SECRET');
});
});
@@ -85,6 +85,13 @@ describe('redactString', () => {
expect(out).not.toContain('AT_SECRET');
expect(out).toBe(`access_token=${REDACTED_PLACEHOLDER}&keep=1`);
});
test('redacts ampersand-delimited generic token query params', () => {
const out = redactString('access_token=AT_SECRET&token=TOKEN_SECRET');
expect(out).not.toContain('AT_SECRET');
expect(out).not.toContain('TOKEN_SECRET');
expect(out).toBe(`access_token=${REDACTED_PLACEHOLDER}&token=${REDACTED_PLACEHOLDER}`);
});
});
describe('redactUrl', () => {
+9
View File
@@ -306,6 +306,13 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
scopes: [],
authFlag: '--kilo-login',
},
qoder: {
provider: 'qoder',
displayName: 'Qoder',
authUrl: 'https://qoder.com/device/selectAccounts',
scopes: [],
authFlag: '--qoder-login',
},
};
/**
@@ -389,6 +396,8 @@ export interface OAuthOptions {
account?: string;
add?: boolean;
nickname?: string;
/** Existing account id to update during reauthentication. */
expectedAccountId?: string;
/** If true, caller explicitly accepts Antigravity OAuth risk for this command/session. */
acceptAgyRisk?: boolean;
/** Kiro auth method override (CLI + Dashboard parity). */
+20 -11
View File
@@ -82,6 +82,7 @@ import { generateSessionId } from './project-selection-handler';
import { createFileSink } from './oauth-trace/sink-file';
import { createOAuthTraceRecorder, OAuthTracePhase, type OAuthTraceRecorder } from './oauth-trace';
import { diagnoseFailure, formatErrorMessage } from './oauth-trace/diagnose-failure';
import { redactString } from './oauth-trace/redactor';
interface PasteCallbackStartData {
url?: string;
@@ -889,8 +890,9 @@ export async function handlePasteCallbackMode(
if (!callbackResponse.ok || callbackData.status === 'error') {
const callbackError =
callbackData.error || `OAuth callback failed with status ${callbackResponse.status}`;
console.log(fail(callbackError));
warnPossible403Ban(provider, callbackError);
const redactedCallbackError = redactString(callbackError);
console.log(fail(redactedCallbackError));
warnPossible403Ban(provider, redactedCallbackError);
trace.record(
OAuthTracePhase.Error,
{ status: callbackResponse.status },
@@ -915,8 +917,9 @@ export async function handlePasteCallbackMode(
);
if (tokenWaitError) {
console.log(fail(tokenWaitError));
warnPossible403Ban(provider, tokenWaitError);
const redactedTokenWaitError = redactString(tokenWaitError);
console.log(fail(redactedTokenWaitError));
warnPossible403Ban(provider, redactedTokenWaitError);
trace.record(
OAuthTracePhase.Error,
{},
@@ -999,8 +1002,8 @@ async function handleGitLabPatLogin(
const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl);
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
const suppliedToken = options?.gitlabPersonalAccessToken?.trim();
const personalAccessToken =
suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
const envPersonalAccessToken = process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
const personalAccessToken = suppliedToken || envPersonalAccessToken;
let token = personalAccessToken;
if (!token) {
@@ -1015,6 +1018,11 @@ async function handleGitLabPatLogin(
return null;
}
// PAT provided via process env should never be forwarded to downstream runtime.
if (!suppliedToken && envPersonalAccessToken) {
delete process.env['GITLAB_PERSONAL_ACCESS_TOKEN'];
}
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
headers: {
@@ -1126,8 +1134,9 @@ export async function triggerOAuth(
// Check for existing accounts
const existingAccounts = getProviderAccounts(provider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const targetAccountId = options.expectedAccountId || existingNameMatch?.id;
const nicknameError = !fromUI
? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id)
? getCliAuthNicknameError(provider, nickname, existingAccounts, targetAccountId)
: null;
if (nicknameError) {
console.log(fail(nicknameError));
@@ -1139,7 +1148,7 @@ export async function triggerOAuth(
const tokenDir = getProviderTokenDir(provider);
const success = await importKiroToken(verbose);
if (success) {
return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id);
return registerAccountFromToken(provider, tokenDir, nickname, verbose, targetAccountId);
}
return null;
}
@@ -1230,7 +1239,7 @@ export async function triggerOAuth(
verbose,
tokenDir,
nickname,
existingNameMatch?.id,
targetAccountId,
{
gitlabBaseUrl: resolvedGitLabBaseUrl,
gitlabPersonalAccessToken: options.gitlabPersonalAccessToken,
@@ -1246,7 +1255,7 @@ export async function triggerOAuth(
verbose,
tokenDir,
nickname,
existingNameMatch?.id,
targetAccountId,
{
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
@@ -1331,7 +1340,7 @@ export async function triggerOAuth(
verbose,
isCLI,
nickname,
expectedAccountId: existingNameMatch?.id,
expectedAccountId: targetAccountId,
authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code',
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
manualCallback: useSelectedKiroLocalPasteCallback,
@@ -10,6 +10,7 @@
const SENSITIVE_QUERY_KEYS = [
'code',
'state',
'token',
'access_token',
'refresh_token',
'id_token',
@@ -1,7 +1,7 @@
import { OAuthTraceEvent, OAuthTracePhase, OAuthTraceSink } from './trace-events';
import { createMemorySink } from './sink-memory';
import { createVerboseStdoutSink } from './sink-verbose-stdout';
import { redactJsonShallow } from './redactor';
import { redactJsonShallow, redactString } from './redactor';
export interface OAuthTraceRecorder {
record(
@@ -53,9 +53,9 @@ export function createOAuthTraceRecorder(options: OAuthTraceRecorderOptions): OA
): { code?: string; message: string } | undefined {
if (!err) return undefined;
if (err instanceof Error) {
return { message: err.message };
return { message: redactString(err.message) };
}
return { code: err.code, message: err.message };
return { code: err.code, message: redactString(err.message) };
}
return {
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
import { compareCliproxyVersions, isNewerVersion } from '../version-checker';
describe('cliproxy version comparison', () => {
it('treats missing fork release suffix as zero', () => {
expect(compareCliproxyVersions('6.6.81', '6.6.81-0')).toBe(0);
expect(isNewerVersion('6.6.81-0', '6.6.81')).toBe(false);
});
it('orders patched fork release suffixes after core version equality', () => {
expect(compareCliproxyVersions('7.1.31-1', '7.1.31-0')).toBe(1);
expect(compareCliproxyVersions('7.1.31-0', '7.1.31-1')).toBe(-1);
expect(isNewerVersion('7.1.31-1', '7.1.31-0')).toBe(true);
});
it('lets core version precedence win before fork release suffixes', () => {
expect(compareCliproxyVersions('7.1.32-0', '7.1.31-99')).toBe(1);
expect(isNewerVersion('7.1.31-99', '7.1.32-0')).toBe(false);
});
});
+40 -19
View File
@@ -33,28 +33,49 @@ interface FetchAllVersionsDeps {
fetchJsonFn?: typeof fetchJson;
}
interface ParsedCliproxyVersion {
major: number;
minor: number;
patch: number;
forkRelease: number;
}
function parseCliproxyVersion(version: string): ParsedCliproxyVersion {
const normalized = version.trim().replace(/^v/, '');
const [coreVersion, forkReleaseValue = '0'] = normalized.split('-', 2);
const [major = 0, minor = 0, patch = 0] = coreVersion
.split('.')
.map((part) => parseInt(part, 10) || 0);
const forkRelease = /^\d+$/.test(forkReleaseValue) ? parseInt(forkReleaseValue, 10) || 0 : 0;
return { major, minor, patch, forkRelease };
}
function compareVersionPart(a: number, b: number): number {
if (a > b) return 1;
if (a < b) return -1;
return 0;
}
export function compareCliproxyVersions(a: string, b: string): number {
const left = parseCliproxyVersion(a);
const right = parseCliproxyVersion(b);
return (
compareVersionPart(left.major, right.major) ||
compareVersionPart(left.minor, right.minor) ||
compareVersionPart(left.patch, right.patch) ||
compareVersionPart(left.forkRelease, right.forkRelease)
);
}
/**
* Compare semver versions (true if latest > current)
* Handles CLIProxyAPIPlus versioning: strips -0 suffix before comparison
* Compare CLIProxy release versions (true if latest > current).
* Missing fork suffixes are treated as -0, while patched fork suffixes such as
* 7.1.31-1 still sort newer than 7.1.31-0.
*/
export function isNewerVersion(latest: string, current: string): boolean {
// Strip -0 suffix from CLIProxyAPIPlus versions (e.g., "6.6.40-0" -> "6.6.40")
const cleanLatest = latest.replace(/-\d+$/, '');
const cleanCurrent = current.replace(/-\d+$/, '');
const latestParts = cleanLatest.split('.').map((p) => parseInt(p, 10) || 0);
const currentParts = cleanCurrent.split('.').map((p) => parseInt(p, 10) || 0);
// Pad arrays to same length
while (latestParts.length < 3) latestParts.push(0);
while (currentParts.length < 3) currentParts.push(0);
for (let i = 0; i < 3; i++) {
if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false;
}
return false; // Equal versions
return compareCliproxyVersions(latest, current) > 0;
}
/**
@@ -8,6 +8,7 @@ describe('base-config-loader new providers', () => {
['gitlab', '/api/provider/gitlab', 'gitlab-duo'],
['codebuddy', '/api/provider/codebuddy', 'auto'],
['kilo', '/api/provider/kilo', 'kilo/auto'],
['qoder', '/api/provider/qoder', 'qoder/auto'],
] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => {
const config = loadBaseConfig(provider);
@@ -2,6 +2,7 @@ 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 * as http from 'http';
import { execClaudeWithCLIProxy, hasGitLabTokenLoginFlag, readOptionValue } from '../index';
describe('readOptionValue', () => {
@@ -57,18 +58,84 @@ describe('execClaudeWithCLIProxy browser flag validation', () => {
fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fs.chmodSync(fakeClaudePath, 0o755);
originalCcsHome = process.env.CCS_HOME;
process.exitCode = 0;
process.env.CCS_HOME = tmpHome;
});
async function waitForFile(filePath: string): Promise<boolean> {
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
if (fs.existsSync(filePath)) return true;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return fs.existsSync(filePath);
}
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
process.exitCode = 0;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('validates conflicting browser launch flags before remote proxy checks', async () => {
let requestCount = 0;
const server = http.createServer((_req, res) => {
requestCount += 1;
res.writeHead(200, { 'content-type': 'application/json' });
res.end('{"ok":true}');
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
throw new Error('Test server did not bind to a TCP port');
}
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',
[
'--proxy-host',
'127.0.0.1',
'--proxy-port',
String(address.port),
'--proxy-auth-token',
'SECRET_TOKEN_FOR_VALIDATION',
'--remote-only',
'--browser',
'--no-browser',
],
{}
);
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(
'[X] Use either `--browser` or `--no-browser`, not both.'
);
expect(requestCount).toBe(0);
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it('exits cleanly when conflicting browser launch flags are provided', async () => {
const exitSpy = jest
.spyOn(process, 'exit')
@@ -87,4 +154,69 @@ describe('execClaudeWithCLIProxy browser flag validation', () => {
errorSpy.mockRestore();
}
});
it('does not treat a stale global exitCode as a current parse failure', async () => {
const markerPath = path.join(tmpHome, 'fake-claude-launched');
fs.writeFileSync(
fakeClaudePath,
`#!/bin/sh\nprintf launched > ${JSON.stringify(markerPath)}\nexit 0\n`,
{ mode: 0o755 }
);
fs.chmodSync(fakeClaudePath, 0o755);
let requestCount = 0;
const server = http.createServer((_req, res) => {
requestCount += 1;
res.writeHead(200, { 'content-type': 'application/json' });
res.end('{"ok":true}');
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
throw new Error('Test server did not bind to a TCP port');
}
const exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
process.exitCode = 1;
await execClaudeWithCLIProxy(
fakeClaudePath,
'gemini',
[
'--proxy-host',
'127.0.0.1',
'--proxy-port',
String(address.port),
'--proxy-auth-token',
'SECRET_TOKEN_FOR_VALIDATION',
'--remote-only',
'--print',
'hello',
],
{}
);
expect(await waitForFile(markerPath)).toBe(true);
expect(requestCount).toBeGreaterThan(0);
expect(exitSpy).toHaveBeenCalledWith(0);
} finally {
exitSpy.mockRestore();
logSpy.mockRestore();
errorSpy.mockRestore();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
});
@@ -5,7 +5,7 @@
* logic extracted from executor/index.ts.
*/
import { beforeEach, describe, expect, it, jest } from 'bun:test';
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
import type { ResolveExecutorProxyContext } from '../proxy-resolver';
import type { ExecutorConfig } from '../../types';
import type { UnifiedConfig } from '../../../config/schemas/unified-config';
@@ -15,11 +15,29 @@ import type { UnifiedConfig } from '../../../config/schemas/unified-config';
const mockEnsureCLIProxyBinary = jest.fn().mockResolvedValue('/usr/local/bin/cliproxy');
const mockGetConfiguredBackend = jest.fn().mockReturnValue('original');
const mockGetPlusBackendUnavailableMessage = jest.fn().mockReturnValue('Plus backend unavailable');
const mockInstallCliproxyVersion = jest.fn().mockResolvedValue(undefined);
const mockFetchLatestCliproxyVersion = jest.fn().mockResolvedValue('test-version');
const mockCheckCliproxyUpdate = jest.fn().mockResolvedValue({ available: false });
jest.mock('../../binary-manager', () => ({
ensureCLIProxyBinary: mockEnsureCLIProxyBinary,
getConfiguredBackend: mockGetConfiguredBackend,
getPlusBackendUnavailableMessage: mockGetPlusBackendUnavailableMessage,
getStoredConfiguredBackend: mockGetConfiguredBackend,
getCLIProxyPath: jest.fn().mockReturnValue('/usr/local/bin/cliproxy'),
getInstalledCliproxyVersion: jest.fn().mockReturnValue('test-version'),
isCLIProxyInstalled: jest.fn().mockReturnValue(true),
resolveLocalBackend: mockGetConfiguredBackend,
syncPlusFallbackStateIfNeeded: jest.fn(),
installCliproxyVersion: mockInstallCliproxyVersion,
fetchLatestCliproxyVersion: mockFetchLatestCliproxyVersion,
checkCliproxyUpdate: mockCheckCliproxyUpdate,
getPinnedVersion: jest.fn().mockReturnValue(null),
savePinnedVersion: jest.fn(),
clearPinnedVersion: jest.fn(),
isVersionPinned: jest.fn().mockReturnValue(false),
getVersionPinPath: jest.fn().mockReturnValue('/tmp/cliproxy-version-pin'),
BinaryManager: class {},
}));
const mockCheckRemoteProxy = jest.fn();
@@ -30,21 +48,28 @@ jest.mock('../../services/remote-proxy-client', () => ({
jest.mock('../retry-handler', () => ({
isNetworkError: jest.fn().mockReturnValue(false),
handleNetworkError: jest.fn(),
}));
const mockResolveProxyConfig = jest.fn();
jest.mock('../../proxy/proxy-config-resolver', () => ({
resolveProxyConfig: mockResolveProxyConfig,
}));
jest.mock('../../config/config-generator', () => ({
CLIPROXY_DEFAULT_PORT: 8317,
validatePort: jest.fn((port: number | undefined) => port ?? 8317),
handleTokenExpiration: jest.fn(),
handleQuotaCheck: jest.fn(),
PROVIDER_ERROR_PATTERNS: [],
detectFailedTier: jest.fn().mockReturnValue(null),
isProviderError: jest.fn().mockReturnValue(false),
}));
// ── Import after mocks ────────────────────────────────────────────────────────
const { resolveExecutorProxy } = await import('../proxy-resolver');
const { resolveExecutorProxy, resolveExecutorProxyConfig } = await import('../proxy-resolver');
const PROXY_ENV_KEYS = [
'CCS_PROXY_HOST',
'CCS_PROXY_PORT',
'CCS_PROXY_PROTOCOL',
'CCS_PROXY_AUTH_TOKEN',
'CCS_PROXY_TIMEOUT',
'CCS_PROXY_FALLBACK_ENABLED',
'CCS_ALLOW_SELF_SIGNED',
] as const;
let proxyEnvSnapshot: Record<string, string | undefined> = {};
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -76,51 +101,37 @@ function makeContext(
};
}
/** Mock resolveProxyConfig to return a local-mode config */
function mockLocalProxyConfig(remainingArgs: string[] = []): void {
mockResolveProxyConfig.mockReturnValue({
config: {
mode: 'local',
port: 8317,
protocol: 'http',
fallbackEnabled: false,
autoStartLocal: false,
remoteOnly: false,
forceLocal: true,
},
remainingArgs,
});
}
/** Mock resolveProxyConfig to return a remote-mode config */
function mockRemoteProxyConfig(remainingArgs: string[] = []): void {
mockResolveProxyConfig.mockReturnValue({
config: {
mode: 'remote',
host: '192.168.1.100',
port: 8317,
protocol: 'http',
fallbackEnabled: false,
autoStartLocal: false,
remoteOnly: false,
forceLocal: false,
},
remainingArgs,
});
async function resolveProxyForTest(args: string[], context = makeContext()) {
const resolvedConfig = resolveExecutorProxyConfig(args, context);
return resolveExecutorProxy(resolvedConfig, context);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
beforeEach(() => {
jest.clearAllMocks();
proxyEnvSnapshot = Object.fromEntries(PROXY_ENV_KEYS.map((key) => [key, process.env[key]]));
for (const key of PROXY_ENV_KEYS) {
delete process.env[key];
}
mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy');
mockGetConfiguredBackend.mockReturnValue('original');
});
afterEach(() => {
for (const key of PROXY_ENV_KEYS) {
const value = proxyEnvSnapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
describe('resolveExecutorProxy — local mode', () => {
it('returns useRemoteProxy=false and correct binary for local mode', async () => {
mockLocalProxyConfig(['--verbose']);
const result = await resolveExecutorProxy(['--verbose'], makeContext());
const result = await resolveProxyForTest(['--local-proxy', '--verbose']);
expect(result.useRemoteProxy).toBe(false);
expect(result.localBackend).toBe('original');
@@ -129,16 +140,14 @@ describe('resolveExecutorProxy — local mode', () => {
});
it('strips proxy flags and passes remainingArgs through', async () => {
mockLocalProxyConfig(['clean-arg']);
const result = await resolveExecutorProxy(['--local-proxy', 'clean-arg'], makeContext());
const result = await resolveProxyForTest(['--local-proxy', 'clean-arg']);
expect(result.argsWithoutProxy).toEqual(['clean-arg']);
expect(result.useRemoteProxy).toBe(false);
});
it('does not call checkRemoteProxy in local mode', async () => {
mockLocalProxyConfig();
await resolveExecutorProxy([], makeContext());
await resolveProxyForTest(['--local-proxy']);
expect(mockCheckRemoteProxy).not.toHaveBeenCalled();
});
@@ -146,19 +155,17 @@ describe('resolveExecutorProxy — local mode', () => {
describe('resolveExecutorProxy — remote mode reachable', () => {
it('returns useRemoteProxy=true when remote proxy is reachable', async () => {
mockRemoteProxyConfig();
mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 12, error: undefined });
const result = await resolveExecutorProxy([], makeContext());
const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']);
expect(result.useRemoteProxy).toBe(true);
});
it('skips binary acquisition when remote proxy is reachable', async () => {
mockRemoteProxyConfig();
mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 5, error: undefined });
const result = await resolveExecutorProxy([], makeContext());
const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']);
expect(result.binaryPath).toBeUndefined();
expect(mockEnsureCLIProxyBinary).not.toHaveBeenCalled();
@@ -167,65 +174,27 @@ describe('resolveExecutorProxy — remote mode reachable', () => {
describe('resolveExecutorProxy — remote mode unreachable', () => {
it('throws expected message when remoteOnly=true and remote is unreachable', async () => {
mockResolveProxyConfig.mockReturnValue({
config: {
mode: 'remote',
host: '192.168.1.100',
port: 8317,
protocol: 'http',
fallbackEnabled: false,
autoStartLocal: false,
remoteOnly: true,
forceLocal: false,
},
remainingArgs: [],
});
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Connection refused' });
await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow(
'Remote proxy unreachable and --remote-only specified'
);
await expect(
resolveProxyForTest(['--proxy-host', '192.168.1.100', '--remote-only'])
).rejects.toThrow('Remote proxy unreachable and --remote-only specified');
});
it('throws when fallback disabled and remote is unreachable', async () => {
mockResolveProxyConfig.mockReturnValue({
config: {
mode: 'remote',
host: '192.168.1.100',
port: 8317,
protocol: 'http',
fallbackEnabled: false,
autoStartLocal: false,
remoteOnly: false,
forceLocal: false,
},
remainingArgs: [],
});
process.env.CCS_PROXY_FALLBACK_ENABLED = '0';
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' });
await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow(
await expect(resolveProxyForTest(['--proxy-host', '192.168.1.100'])).rejects.toThrow(
'Remote proxy unreachable and fallback disabled'
);
});
it('falls back to local and acquires binary when autoStartLocal=true', async () => {
mockResolveProxyConfig.mockReturnValue({
config: {
mode: 'remote',
host: '192.168.1.100',
port: 8317,
protocol: 'http',
fallbackEnabled: true,
autoStartLocal: true,
remoteOnly: false,
forceLocal: false,
},
remainingArgs: [],
});
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' });
mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy');
const result = await resolveExecutorProxy([], makeContext());
const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']);
expect(result.useRemoteProxy).toBe(false);
expect(result.binaryPath).toBe('/usr/local/bin/cliproxy');
@@ -235,9 +204,7 @@ describe('resolveExecutorProxy — remote mode unreachable', () => {
describe('resolveExecutorProxy — proxyConfig propagated in result', () => {
it('returns the resolved proxyConfig object', async () => {
mockLocalProxyConfig();
const result = await resolveExecutorProxy([], makeContext());
const result = await resolveProxyForTest(['--local-proxy']);
expect(result.proxyConfig).toBeDefined();
expect(result.proxyConfig.mode).toBe('local');
@@ -245,10 +212,9 @@ describe('resolveExecutorProxy — proxyConfig propagated in result', () => {
});
it('returns mutated cfg with validated port', async () => {
mockLocalProxyConfig();
const ctx = makeContext();
const result = await resolveExecutorProxy([], ctx);
const result = await resolveProxyForTest(['--local-proxy'], ctx);
// cfg is mutated in place and also returned
expect(result.cfg).toBe(ctx.cfg);
+24 -11
View File
@@ -8,7 +8,9 @@
* - parseExecutorFlags() flag extraction block (lines ~411-639 in original)
* - validateFlagCombinations() cross-flag guard block (lines ~531-585)
*
* IMPORTANT: process.exit semantics are kept identical to original index.ts.
* IMPORTANT: process.exit semantics are kept identical to original index.ts,
* with explicit parseFailed/validation return state for callers that must not
* depend on ambient process.exitCode.
* All console.error messages are byte-identical.
*/
@@ -153,6 +155,7 @@ export function filterCcsFlags(args: string[]): string[] {
/** Result of parsing CCS executor flags from args. */
export interface ParsedExecutorFlags {
parseFailed?: boolean;
forceAuth: boolean;
pasteCallback: boolean;
portForward: boolean;
@@ -181,7 +184,7 @@ export interface ParsedExecutorFlags {
/**
* Parse all CCS executor flags from args.
*
* Exits with code 1 (process.exitCode = 1 + return) on invalid flag values.
* Exits with code 1 (process.exitCode = 1 + parseFailed return) on invalid flag values.
* Exits with process.exit(1) on conflicting flag combinations identical to
* the original index.ts behavior.
*
@@ -247,7 +250,7 @@ export function parseExecutorFlags(
console.error(fail('--kiro-auth-method requires a value'));
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.exitCode = 1;
// Caller must check process.exitCode = 1 and bail — matching original return behavior
// Caller must check parseFailed and bail — matching original return behavior
return buildPartialFlags({
forceAuth,
pasteCallback,
@@ -272,6 +275,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
parseFailed: true,
});
}
const normalized = rawMethod.trim().toLowerCase();
@@ -303,6 +307,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
parseFailed: true,
});
}
kiroAuthMethod = normalizeKiroAuthMethod(normalized);
@@ -339,6 +344,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
parseFailed: true,
});
}
@@ -373,6 +379,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
parseFailed: true,
});
}
@@ -408,6 +415,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
parseFailed: true,
});
}
const normalized = rawFlow.trim().toLowerCase();
@@ -439,6 +447,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
parseFailed: true,
});
}
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
@@ -475,6 +484,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined,
extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args),
parseFailed: true,
});
}
@@ -538,6 +548,7 @@ export function parseExecutorFlags(
gitlabBaseUrl,
extendedContextOverride,
thinkingParse,
parseFailed: false,
};
}
@@ -550,8 +561,8 @@ function buildPartialFlags(fields: ParsedExecutorFlags): ParsedExecutorFlags {
/**
* Validate flag combinations that are mutually exclusive or provider-scoped.
* Calls process.exit(1) on any violation identical to original index.ts.
* Call AFTER parseExecutorFlags() and only if process.exitCode is still 0.
* Sets process.exitCode=1 and returns false on any violation.
* Call AFTER parseExecutorFlags() and only if parseFailed is false.
*
* @param parsed Result of parseExecutorFlags()
* @param context Provider context (provider string + compositeProviders list)
@@ -561,7 +572,7 @@ export function validateFlagCombinations(
parsed: ParsedExecutorFlags,
context: { provider: string; compositeProviders: string[] },
args: string[]
): void {
): boolean {
const { provider, compositeProviders } = context;
const {
kiroAuthMethod,
@@ -575,7 +586,7 @@ export function validateFlagCombinations(
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
process.exitCode = 1;
return;
return false;
}
if (
@@ -589,7 +600,7 @@ export function validateFlagCombinations(
)
);
process.exitCode = 1;
return;
return false;
}
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
@@ -598,7 +609,7 @@ export function validateFlagCombinations(
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
);
process.exitCode = 1;
return;
return false;
}
if (
@@ -612,13 +623,15 @@ export function validateFlagCombinations(
)
);
process.exitCode = 1;
return;
return false;
}
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url';
console.error(fail(`${flagName} is only valid for ccs gitlab`));
process.exitCode = 1;
return;
return false;
}
return true;
}
@@ -9,7 +9,7 @@
* 4. Browser MCP ensure + sync-to-config-dir
*/
import { warn } from '../../utils/ui';
import { fail, warn } from '../../utils/ui';
import {
type BrowserLaunchOverride,
ensureBrowserMcpOrThrow,
@@ -41,6 +41,7 @@ export interface BrowserLaunchSetupResult {
export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
browserLaunchOverride: BrowserLaunchOverride | undefined;
argsWithoutBrowserFlags: string[];
parseFailed: boolean;
} {
let browserLaunchOverride: BrowserLaunchOverride | undefined;
let argsWithoutBrowserFlags = argsWithoutProxy;
@@ -49,9 +50,10 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
browserLaunchOverride = browserLaunchFlags.override;
argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags;
} catch (error) {
console.error(warn((error as Error).message));
console.error(fail((error as Error).message));
process.exitCode = 1;
process.exit(1);
return { browserLaunchOverride: undefined, argsWithoutBrowserFlags };
return { browserLaunchOverride: undefined, argsWithoutBrowserFlags, parseFailed: true };
}
const browserConfig = getBrowserConfig();
@@ -71,7 +73,7 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
console.error(warn(blockedBrowserOverrideWarning));
}
return { browserLaunchOverride, argsWithoutBrowserFlags };
return { browserLaunchOverride, argsWithoutBrowserFlags, parseFailed: false };
}
/**
+25 -9
View File
@@ -63,7 +63,7 @@ import {
} from './thinking-override-resolver';
import { shouldStartHttpsTunnel } from './https-tunnel-policy';
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
import { resolveExecutorProxy } from './proxy-resolver';
import { resolveExecutorProxy, resolveExecutorProxyConfig } from './proxy-resolver';
import { buildProxyChain } from './proxy-chain-builder';
import { warnBrokenModels } from './model-warnings';
import { launchClaude } from './claude-launcher';
@@ -129,8 +129,23 @@ export async function execClaudeWithCLIProxy(
// Collect all providers to validate (default + composite tiers)
const allProviders = [provider, ...compositeProviders];
const proxyResolution = resolveExecutorProxyConfig(args, {
unifiedConfig,
allProviders,
verbose,
cfg,
log,
});
const {
browserLaunchOverride,
argsWithoutBrowserFlags,
parseFailed: browserLaunchParseFailed,
} = resolveBrowserLaunchFlags(proxyResolution.argsWithoutProxy);
if (browserLaunchParseFailed) return;
const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } =
await resolveExecutorProxy(args, {
await resolveExecutorProxy(proxyResolution, {
unifiedConfig,
allProviders,
verbose,
@@ -138,9 +153,6 @@ export async function execClaudeWithCLIProxy(
log,
});
const { browserLaunchOverride, argsWithoutBrowserFlags } =
resolveBrowserLaunchFlags(argsWithoutProxy);
// Setup first-class CCS WebSearch runtime
ensureWebSearchMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
@@ -158,11 +170,15 @@ export async function execClaudeWithCLIProxy(
compositeProviders,
unifiedConfig,
});
if (process.exitCode === 1) return;
if (parsedFlags.parseFailed) return;
// Validate cross-flag combinations (exits with code 1 on violation)
validateFlagCombinations(parsedFlags, { provider, compositeProviders }, argsWithoutProxy);
if (process.exitCode === 1) return;
// Validate cross-flag combinations (reports failure without relying on ambient exitCode)
const flagCombinationsValid = validateFlagCombinations(
parsedFlags,
{ provider, compositeProviders },
argsWithoutProxy
);
if (!flagCombinationsValid) return;
const {
forceConfig,
+27 -11
View File
@@ -24,20 +24,23 @@ import type { ResolvedProxyConfig } from '../types';
import type { UnifiedConfig } from '../../config/schemas/unified-config';
import { isNetworkError, handleNetworkError } from './retry-handler';
/** Result returned from resolveExecutorProxy */
export interface ResolvedProxy {
export interface ResolvedExecutorProxyConfig {
/** Resolved proxy config after merging CLI > ENV > config.yaml > defaults */
proxyConfig: ResolvedProxyConfig;
/** Args after proxy-related flags are stripped out */
argsWithoutProxy: string[];
/** Mutated executor config (port resolved and validated) */
cfg: ExecutorConfig;
}
/** Result returned from resolveExecutorProxy */
export interface ResolvedProxy extends ResolvedExecutorProxyConfig {
/** Whether to use the remote proxy (vs spawning a local one) */
useRemoteProxy: boolean;
/** Which local backend binary to use ('original' | 'plus') */
localBackend: CLIProxyBackend;
/** Absolute path to CLIProxy binary; undefined when useRemoteProxy=true */
binaryPath: string | undefined;
/** Args after proxy-related flags are stripped out */
argsWithoutProxy: string[];
/** Mutated executor config (port resolved and validated) */
cfg: ExecutorConfig;
}
/** Dependencies injected by the orchestrator */
@@ -50,16 +53,15 @@ export interface ResolveExecutorProxyContext {
}
/**
* Resolves proxy configuration, checks remote reachability, selects the local
* backend, and ensures the CLIProxy binary is present when running locally.
* Resolves side-effect-free proxy configuration and strips proxy flags.
*
* Mutates `context.cfg.port` in-place (same as original orchestrator behaviour).
*/
export async function resolveExecutorProxy(
export function resolveExecutorProxyConfig(
args: string[],
context: ResolveExecutorProxyContext
): Promise<ResolvedProxy> {
const { unifiedConfig, allProviders, verbose: _verbose, cfg, log } = context;
): ResolvedExecutorProxyConfig {
const { unifiedConfig, cfg, log } = context;
// Resolve proxy config from CLI flags > ENV > config.yaml > defaults
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
@@ -98,6 +100,20 @@ export async function resolveExecutorProxy(
log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`);
}
return { proxyConfig, argsWithoutProxy, cfg };
}
/**
* Resolves proxy configuration, checks remote reachability, selects the local
* backend, and ensures the CLIProxy binary is present when running locally.
*/
export async function resolveExecutorProxy(
resolvedConfig: ResolvedExecutorProxyConfig,
context: ResolveExecutorProxyContext
): Promise<ResolvedProxy> {
const { allProviders, verbose: _verbose } = context;
const { proxyConfig, argsWithoutProxy, cfg } = resolvedConfig;
// Check remote proxy reachability
let useRemoteProxy = false;
let localBackend: CLIProxyBackend = 'original';
+79 -2
View File
@@ -265,6 +265,68 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
},
],
},
qoder: {
provider: 'qoder',
displayName: 'Qoder',
defaultModel: 'qoder/auto',
models: [
{
id: 'qoder/auto',
name: 'Qoder Auto',
description: 'Auto selects the best Qoder model for your prompt',
},
{
id: 'qoder/ultimate',
name: 'Qoder Ultimate',
description: 'Highest quality Qoder tier',
},
{
id: 'qoder/performance',
name: 'Qoder Performance',
description: 'Balanced quality and speed',
},
{
id: 'qoder/efficient',
name: 'Qoder Efficient',
description: 'Cost-efficient Qoder tier',
},
{
id: 'qoder/lite',
name: 'Qoder Lite',
description: 'Fastest and most affordable Qoder tier',
},
{
id: 'qoder/qmodel',
name: 'Qwen 3.6 Plus (via Qoder)',
description: 'Qwen 3.6 Plus frontier model',
},
{
id: 'qoder/dmodel',
name: 'DeepSeek V4 Pro (via Qoder)',
description: 'DeepSeek V4 Pro frontier model',
},
{
id: 'qoder/dfmodel',
name: 'DeepSeek V4 Flash (via Qoder)',
description: 'DeepSeek V4 Flash frontier model',
},
{
id: 'qoder/gm51model',
name: 'GLM 5.1 (via Qoder)',
description: 'GLM 5.1 frontier model',
},
{
id: 'qoder/kmodel',
name: 'Kimi K2.6 (via Qoder)',
description: 'Kimi K2.6 frontier model',
},
{
id: 'qoder/mmodel',
name: 'MiniMax M2.7 (via Qoder)',
description: 'MiniMax M2.7 frontier model',
},
],
},
kimi: {
provider: 'kimi',
displayName: 'Kimi (Moonshot)',
@@ -307,10 +369,25 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
displayName: 'Claude (Anthropic)',
defaultModel: 'claude-sonnet-4-6',
models: [
{
id: 'claude-opus-4-8',
name: 'Claude Opus 4.8',
description: 'Latest flagship model',
nativeImageInput: true,
// Mirrors 4.7: Anthropic accepts only adaptive thinking levels on the
// current Opus generation; manual budget_tokens is rejected with 400.
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
maxLevel: 'max',
dynamicAllowed: true,
},
extendedContext: true,
},
{
id: 'claude-opus-4-7',
name: 'Claude Opus 4.7',
description: 'Latest flagship model',
description: 'Previous 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.
@@ -327,7 +404,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
{
id: 'claude-opus-4-6',
name: 'Claude Opus 4.6',
description: 'Previous flagship model',
description: 'Older flagship model',
nativeImageInput: true,
thinking: {
type: 'budget',
+12
View File
@@ -182,6 +182,18 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
tokenTypeValues: ['kilo'],
aliases: [],
},
qoder: {
displayName: 'Qoder',
description: 'Qoder AI coding assistant',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'qoder',
authUrlProviderName: 'qoder',
refreshOwnership: 'unsupported',
authFilePrefixes: ['qoder-'],
tokenTypeValues: ['qoder'],
aliases: [],
},
};
export const CLIPROXY_PROVIDER_IDS = Object.freeze(
@@ -294,6 +294,33 @@ describe('proxy-config-resolver', () => {
expect(config.managementKey).toBe('remote-management-key');
});
it('should clear YAML management key when CLI overrides remote host', () => {
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host.example.com'], {
remote: {
host: 'yaml-host.example.com',
auth_token: 'remote-auth-token',
management_key: 'remote-management-key',
},
});
expect(config.mode).toBe('remote');
expect(config.host).toBe('cli-host.example.com');
expect(config.managementKey).toBeUndefined();
});
it('should clear YAML management key when ENV overrides auth token', () => {
process.env.CCS_PROXY_AUTH_TOKEN = 'env-auth-token';
const { config } = resolveProxyConfig([], {
remote: {
host: 'yaml-host.example.com',
auth_token: 'remote-auth-token',
management_key: 'remote-management-key',
},
});
expect(config.mode).toBe('remote');
expect(config.authToken).toBe('env-auth-token');
expect(config.managementKey).toBeUndefined();
});
it('should allow CLI --proxy-host to override YAML enabled:false', () => {
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], {
remote: { enabled: false, host: 'yaml-host' },
@@ -436,6 +436,155 @@ describe('ToolSanitizationProxy Integration', () => {
}
});
it('translates Codex fast aliases before forwarding to provider routes', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages?beta=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.5-fast',
messages: [],
}),
});
const sentBody = lastRequest!.body as Record<string, unknown>;
expect(sentBody.model).toBe('gpt-5.5');
expect(sentBody.service_tier).toBe('priority');
} finally {
proxy.stop();
}
});
it('translates Codex effort and fast aliases before forwarding to provider routes', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.5-fast-high',
messages: [],
reasoning: { summary: 'auto' },
}),
});
const sentBody = lastRequest!.body as Record<string, unknown>;
expect(sentBody.model).toBe('gpt-5.5');
expect((sentBody.reasoning as Record<string, unknown>).summary).toBe('auto');
expect((sentBody.reasoning as Record<string, unknown>).effort).toBe('high');
expect(sentBody.service_tier).toBe('priority');
} finally {
proxy.stop();
}
});
it('folds Codex system messages into the first user message before forwarding', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages?beta=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.4',
system: [{ type: 'text', text: 'Always be concise.' }],
messages: [
{ role: 'system', content: 'Use JSON.' },
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
],
}),
});
const sentBody = lastRequest!.body as Record<string, unknown>;
const sentMessages = sentBody.messages as Array<Record<string, unknown>>;
expect(sentBody.system).toBeUndefined();
expect(sentMessages).toHaveLength(1);
expect(sentMessages[0].role).toBe('user');
expect(sentMessages[0].content).toEqual([
{ type: 'text', text: 'Always be concise.\n\nUse JSON.' },
{ type: 'text', text: 'hello' },
]);
} finally {
proxy.stop();
}
});
it('strips blank Codex system messages before forwarding', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.4',
system: ' ',
messages: [
{ role: 'system', content: [{ type: 'text', text: ' ' }] },
{ role: 'user', content: 'hello' },
],
}),
});
const sentBody = lastRequest!.body as Record<string, unknown>;
const sentMessages = sentBody.messages as Array<Record<string, unknown>>;
expect(sentBody.system).toBeUndefined();
expect(sentMessages).toEqual([{ role: 'user', content: 'hello' }]);
} finally {
proxy.stop();
}
});
it('does not apply Codex alias or system rewrites on explicit non-Codex provider routes', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
await fetch(`http://127.0.0.1:${port}/api/provider/gemini/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.5-fast',
system: 'Keep this as a provider-level system field.',
messages: [
{ role: 'system', content: 'Keep this as a message.' },
{ role: 'user', content: 'hello' },
],
}),
});
const sentBody = lastRequest!.body as Record<string, unknown>;
expect(sentBody.model).toBe('gpt-5.5-fast');
expect(sentBody.service_tier).toBeUndefined();
expect(sentBody.system).toBe('Keep this as a provider-level system field.');
expect(sentBody.messages).toEqual([
{ role: 'system', content: 'Keep this as a message.' },
{ role: 'user', content: 'hello' },
]);
} finally {
proxy.stop();
}
});
for (const model of [
'gpt-5.3-codex-xhigh',
'gpt-5.1-codex-mini',
@@ -825,6 +974,49 @@ describe('ToolSanitizationProxy Integration', () => {
proxy.stop();
}
});
it('returns 504 when raw upstream passthrough stalls after headers', async () => {
const upstream = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.flushHeaders();
});
});
const upstreamPort = await new Promise<number>((resolve, reject) => {
upstream.once('error', reject);
upstream.listen(0, '127.0.0.1', () => {
const address = upstream.address();
if (typeof address !== 'object' || !address) {
reject(new Error('Failed to resolve upstream port'));
return;
}
resolve(address.port);
});
});
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
timeoutMs: 100,
});
const port = await proxy.start();
try {
const response = await fetch(`http://127.0.0.1:${port}/health`);
const text = await Promise.race([
response.text(),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error('Timed out waiting for proxy response')), 2_000)
),
]);
expect(response.status).toBe(504);
expect(text).toContain('Upstream response timed out');
} finally {
proxy.stop();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
});
describe('Multiple Tools Sanitization', () => {
@@ -1086,5 +1278,65 @@ describe('ToolSanitizationProxy Integration', () => {
proxy.stop();
}
});
it('ends stalled streaming responses after upstream headers', async () => {
const upstream = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write(
'event: message_start\n' +
'data: {"type":"message_start","message":{"id":"msg_stall","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n'
);
});
});
const upstreamPort = await new Promise<number>((resolve, reject) => {
upstream.once('error', reject);
upstream.listen(0, '127.0.0.1', () => {
const address = upstream.address();
if (typeof address !== 'object' || !address) {
reject(new Error('Failed to resolve upstream port'));
return;
}
resolve(address.port);
});
});
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
timeoutMs: 100,
});
const port = await proxy.start();
try {
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stream: true,
tools: [{ name: 'valid_tool' }],
}),
});
const text = await Promise.race([
response.text(),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error('Timed out waiting for proxy response')), 2_000)
),
]);
expect(response.status).toBe(200);
expect(text).toContain('message_start');
expect(text).toContain('timeout_error');
expect(text).toContain('Upstream response timed out');
} finally {
proxy.stop();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
});
});
+13 -1
View File
@@ -291,7 +291,19 @@ export function resolveProxyConfig(
// Merge auth token: CLI > ENV > config.yaml
resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token;
resolved.managementKey = yamlConfig.remote?.management_key;
// Keep YAML management key only when remote target/auth are not overridden via CLI/ENV.
// Prevents leaking a saved management key to a different runtime target.
const hasRuntimeRemoteOverride =
cliFlags.host !== undefined ||
envConfig.host !== undefined ||
cliFlags.port !== undefined ||
envConfig.port !== undefined ||
cliFlags.protocol !== undefined ||
envConfig.protocol !== undefined ||
cliFlags.authToken !== undefined ||
envConfig.authToken !== undefined;
resolved.managementKey = hasRuntimeRemoteOverride ? undefined : yamlConfig.remote?.management_key;
// Merge timeout: CLI > ENV > config.yaml > default (2000ms in executor)
resolved.timeout = cliFlags.timeout ?? envConfig.timeout ?? yamlConfig.remote?.timeout;
+185 -8
View File
@@ -22,12 +22,17 @@ import {
extractProviderFromPathname,
getDeniedModelIdReasonForProvider,
normalizeModelIdForRouting,
parseCodexModelTuningAlias,
stripCodexEffortSuffix,
} from '../ai-providers/model-id-normalizer';
import { getModelMaxLevel } from '../model-catalog';
import { createLogger } from '../../services/logging';
import { getCcsDir } from '../../config/config-loader-facade';
import {
attachUpstreamResponseTimeout,
writeForwardResponseHead,
} from './upstream-response-timeout';
export interface ToolSanitizationProxyConfig {
/** Upstream CLIProxy URL */
@@ -59,6 +64,7 @@ const GEMINI_UNSUPPORTED_TOOL_FIELDS = new Set([
]);
const CODEX_UNSUPPORTED_TOOL_FIELDS = new Set(['cache_control']);
const CODEX_FAST_SERVICE_TIER = 'priority';
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
const LEGACY_CODEX_MODEL_ID_REGEX = /^gpt-5(?:\.\d+)?-codex(?:-(?:mini|max))?$/i;
@@ -86,6 +92,118 @@ function isKnownCodexModelId(model: string | undefined): boolean {
);
}
function isCodexRequest(providerFromPath: string | null, model: unknown): boolean {
return (
providerFromPath === 'codex' ||
(providerFromPath === null && typeof model === 'string' && isKnownCodexModelId(model))
);
}
function applyCodexModelTuningAlias(body: Record<string, unknown>): Record<string, unknown> {
if (typeof body.model !== 'string') {
return body;
}
const parsed = parseCodexModelTuningAlias(body.model);
if (!parsed || !isKnownCodexModelId(parsed.baseModel)) {
return body;
}
const tunedBody: Record<string, unknown> = { ...body, model: parsed.baseModel };
if (parsed.effort) {
const existingReasoning = isRecord(body.reasoning) ? body.reasoning : {};
tunedBody.reasoning = {
...existingReasoning,
effort: parsed.effort,
};
}
if (parsed.serviceTier) {
tunedBody.service_tier = CODEX_FAST_SERVICE_TIER;
}
return tunedBody;
}
function extractSystemText(content: unknown): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return '';
}
return content
.filter((block): block is { type: unknown; text?: unknown } => isRecord(block))
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text as string)
.join('\n\n');
}
function prependSystemTextToContent(content: unknown, systemText: string): unknown {
if (Array.isArray(content)) {
return [{ type: 'text', text: systemText }, ...content];
}
if (typeof content === 'string') {
return `${systemText}\n\n${content}`;
}
return systemText;
}
function foldCodexSystemMessages(body: Record<string, unknown>): Record<string, unknown> {
const systemTexts: string[] = [];
let removedSystem = false;
const nextBody = { ...body };
if (body.system !== undefined) {
removedSystem = true;
delete nextBody.system;
const systemText = extractSystemText(body.system).trim();
if (systemText) {
systemTexts.push(systemText);
}
}
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
const messages = rawMessages.filter((message) => {
if (!isRecord(message) || message.role !== 'system') {
return true;
}
removedSystem = true;
const systemText = extractSystemText(message.content).trim();
if (systemText) {
systemTexts.push(systemText);
}
return false;
});
if (!removedSystem) {
return body;
}
if (systemTexts.length > 0) {
const systemPrefix = systemTexts.join('\n\n');
const firstUserIndex = messages.findIndex(
(message) => isRecord(message) && message.role === 'user'
);
if (firstUserIndex >= 0 && isRecord(messages[firstUserIndex])) {
const firstUserMessage = messages[firstUserIndex];
messages[firstUserIndex] = {
...firstUserMessage,
content: prependSystemTextToContent(firstUserMessage.content, systemPrefix),
};
} else {
messages.unshift({ role: 'user', content: systemPrefix });
}
}
nextBody.messages = messages;
return nextBody;
}
function getUnsupportedToolFields(
providerFromPath: string | null,
model: string | undefined
@@ -348,6 +466,11 @@ export class ToolSanitizationProxy {
}
}
if (isRecord(modifiedBody) && isCodexRequest(providerFromPath, modifiedBody.model)) {
const tunedBody = applyCodexModelTuningAlias(modifiedBody);
modifiedBody = foldCodexSystemMessages(tunedBody);
}
// Sanitize tools if present
if (isRecord(modifiedBody) && Array.isArray(modifiedBody.tools)) {
// Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties)
@@ -521,10 +644,38 @@ export class ToolSanitizationProxy {
),
(upstreamRes) => {
clearResponseTimeout();
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
upstreamRes.pipe(clientRes);
upstreamRes.on('end', () => resolve());
upstreamRes.on('error', reject);
const statusCode = upstreamRes.statusCode || 200;
let responseStarted = false;
const writeResponseHead = () => {
if (responseStarted) return;
responseStarted = true;
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
};
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
upstreamReq,
upstreamRes,
clientRes,
timeoutMs: this.config.timeoutMs,
onTimeout: () => resolve(),
});
upstreamRes.on('data', (chunk: Buffer) => {
writeResponseHead();
const canContinue = clientRes.write(chunk);
if (!canContinue) {
upstreamRes.pause();
clientRes.once('drain', () => upstreamRes.resume());
}
});
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
writeResponseHead();
clientRes.end();
resolve();
});
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
}
);
@@ -560,10 +711,18 @@ export class ToolSanitizationProxy {
),
(upstreamRes) => {
clearResponseTimeout();
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
upstreamReq,
upstreamRes,
clientRes,
timeoutMs: this.config.timeoutMs,
onTimeout: () => resolve(),
});
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
try {
const responseBody = Buffer.concat(chunks).toString('utf8');
const contentType = upstreamRes.headers['content-type'] || '';
@@ -598,7 +757,10 @@ export class ToolSanitizationProxy {
reject(err);
}
});
upstreamRes.on('error', reject);
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
}
);
@@ -636,7 +798,14 @@ export class ToolSanitizationProxy {
),
(upstreamRes) => {
clearResponseTimeout();
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
upstreamReq,
upstreamRes,
clientRes,
timeoutMs: this.config.timeoutMs,
onTimeout: () => resolve(),
});
writeForwardResponseHead(clientRes, upstreamRes.statusCode || 200, upstreamRes.headers);
// Track upstream SSE lifecycle events (guards against empty proxy responses)
const lifecycle = {
@@ -673,6 +842,7 @@ export class ToolSanitizationProxy {
}
});
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
try {
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
this.writeLog(
@@ -693,7 +863,10 @@ export class ToolSanitizationProxy {
}
resolve();
});
upstreamRes.on('error', reject);
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
return;
}
@@ -719,6 +892,7 @@ export class ToolSanitizationProxy {
});
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
try {
// Process any remaining buffer
if (buffer.trim()) {
@@ -749,7 +923,10 @@ export class ToolSanitizationProxy {
resolve();
});
upstreamRes.on('error', reject);
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
}
);
@@ -0,0 +1,87 @@
import * as http from 'http';
export const UPSTREAM_RESPONSE_TIMEOUT_MESSAGE =
'Upstream response timed out while streaming response body';
export function buildTimeoutSafeResponseHeaders(
headers: http.IncomingHttpHeaders
): http.OutgoingHttpHeaders {
const safeHeaders: http.OutgoingHttpHeaders = {};
for (const [name, value] of Object.entries(headers)) {
const normalized = name.toLowerCase();
if (normalized === 'content-length' || normalized === 'transfer-encoding') {
continue;
}
safeHeaders[name] = value;
}
return safeHeaders;
}
export function writeForwardResponseHead(
clientRes: http.ServerResponse,
statusCode: number,
headers: http.IncomingHttpHeaders
): void {
if (clientRes.headersSent) return;
clientRes.writeHead(statusCode, buildTimeoutSafeResponseHeaders(headers));
}
export function writeTimeoutResponse(
clientRes: http.ServerResponse,
headers: http.IncomingHttpHeaders,
message = UPSTREAM_RESPONSE_TIMEOUT_MESSAGE
): void {
if (clientRes.destroyed || clientRes.writableEnded) return;
const contentType = String(headers['content-type'] ?? '').toLowerCase();
try {
if (contentType.includes('text/event-stream')) {
const payload = {
type: 'error',
error: {
type: 'timeout_error',
message,
},
};
clientRes.write(`event: error\ndata: ${JSON.stringify(payload)}\n\n`);
} else if (!clientRes.headersSent) {
clientRes.writeHead(504, { 'Content-Type': 'application/json' });
clientRes.write(JSON.stringify({ error: message }));
} else {
clientRes.write(`\n${JSON.stringify({ error: message })}`);
}
clientRes.end();
} catch {
// Client may have disconnected while the upstream response was stalled.
}
}
export function attachUpstreamResponseTimeout(options: {
upstreamReq: http.ClientRequest;
upstreamRes: http.IncomingMessage;
clientRes: http.ServerResponse;
timeoutMs: number;
onTimeout?: (error: Error) => void;
}): () => void {
const { upstreamReq, upstreamRes, clientRes, timeoutMs, onTimeout } = options;
let settled = false;
const clear = () => {
settled = true;
upstreamRes.setTimeout(0);
};
upstreamRes.setTimeout(timeoutMs, () => {
if (settled) return;
settled = true;
const error = new Error(UPSTREAM_RESPONSE_TIMEOUT_MESSAGE);
writeTimeoutResponse(clientRes, upstreamRes.headers, error.message);
onTimeout?.(error);
upstreamRes.destroy(error);
upstreamReq.destroy(error);
});
return clear;
}
@@ -382,6 +382,44 @@ describe('Codex Quota Fetcher', () => {
expect(windows[0].featureLabel).toBe('Custom-Feature');
expect(windows[0].usedPercent).toBe(50);
});
it('should remove terminal control characters from additional limit labels', () => {
const response = {
additional_rate_limits: [
{
limit_name: '\u001b[2JGPT-5.3-Codex-Spark\u001b]52;c;payload\u0007',
rate_limit: {
primary_window: { used_percent: 25, reset_after_seconds: 3600 },
},
},
],
};
const windows = buildCodexQuotaWindows(response);
expect(windows).toHaveLength(1);
expect(windows[0].featureLabel).toBe('GPT-5.3-Codex-Spark');
expect(windows[0].label).toBe('GPT-5.3-Codex-Spark (Primary)');
});
it('should bound additional limit labels before storing them', () => {
const response = {
additional_rate_limits: [
{
limit_name: `Feature-${'x'.repeat(120)}`,
rate_limit: {
primary_window: { used_percent: 25, reset_after_seconds: 3600 },
},
},
],
};
const windows = buildCodexQuotaWindows(response);
expect(windows).toHaveLength(1);
expect(windows[0].featureLabel).toHaveLength(80);
expect(windows[0].label).toBe(`${windows[0].featureLabel} (Primary)`);
});
});
describe('buildCodexCoreUsageSummary', () => {
+4 -7
View File
@@ -11,6 +11,7 @@ import { getAuthDir } from '../config/config-generator';
import { getAccount, getProviderAccounts, getPausedDir } from '../accounts/account-manager';
import { sanitizeEmail, isTokenExpired } from '../auth/auth-utils';
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
import { sanitizeCodexFeatureLabel } from './quota-label-sanitizer';
import { extractCanonicalEmailFromAccountId } from '../accounts/email-account-identity';
/** ChatGPT backend API base URL */
@@ -58,8 +59,8 @@ interface CodexRateLimitWindow {
* Each entry surfaces its own primary/secondary windows under a feature-specific limit name.
*/
interface CodexAdditionalRateLimit {
limit_name?: string;
limitName?: string;
limit_name?: unknown;
limitName?: unknown;
metered_feature?: string;
meteredFeature?: string;
rate_limit?: CodexRateLimitWindow;
@@ -380,11 +381,7 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[]
const entryRateLimit = entry.rate_limit || entry.rateLimit;
if (!entryRateLimit) continue;
const rawFeatureLabel = entry.limit_name ?? entry.limitName;
const featureLabel =
typeof rawFeatureLabel === 'string' && rawFeatureLabel.trim().length > 0
? rawFeatureLabel.trim()
: 'Additional';
const featureLabel = sanitizeCodexFeatureLabel(entry.limit_name ?? entry.limitName);
addWindow(
`${featureLabel} (Primary)`,
entryRateLimit.primary_window || entryRateLimit.primaryWindow,
@@ -0,0 +1,29 @@
const CODEX_FEATURE_LABEL_FALLBACK = 'Additional';
const CODEX_FEATURE_LABEL_MAX_LENGTH = 80;
const TERMINAL_ESCAPE_SEQUENCE_REGEX =
/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b\[[0-?]*[ -/]*[@-~]/g;
const TERMINAL_CONTROL_CHARS_REGEX = /[\u0000-\u001f\u007f-\u009f]/g;
/**
* Sanitize upstream Codex feature labels before storing or rendering them.
*
* The quota API is not schema-validated at runtime, so additional-rate-limit
* labels must be constrained to safe, printable strings before they reach the
* terminal.
*/
export function sanitizeCodexFeatureLabelOrNull(value: unknown): string | null {
if (typeof value !== 'string') return null;
const sanitized = value
.replace(TERMINAL_ESCAPE_SEQUENCE_REGEX, '')
.replace(TERMINAL_CONTROL_CHARS_REGEX, '')
.trim()
.slice(0, CODEX_FEATURE_LABEL_MAX_LENGTH)
.trimEnd();
return sanitized.length > 0 ? sanitized : null;
}
export function sanitizeCodexFeatureLabel(value: unknown): string {
return sanitizeCodexFeatureLabelOrNull(value) ?? CODEX_FEATURE_LABEL_FALLBACK;
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'bun:test';
import * as https from 'https';
import { describe, expect, it, mock, spyOn } from 'bun:test';
import type { ProxyTarget } from '../../proxy/proxy-target-resolver';
async function loadRoutingHttpModule() {
@@ -22,6 +23,73 @@ describe('routing-strategy-http', () => {
);
});
it('rejects malformed self-signed HTTPS URLs before arming the request timeout', async () => {
const { fetchCliproxyRoutingResponse } = await loadRoutingHttpModule();
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => {
void handler;
void timeout;
return 1 as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout);
const target: ProxyTarget = {
host: 'bad host',
port: 443,
protocol: 'https',
allowSelfSigned: true,
isRemote: true,
};
try {
await expect(fetchCliproxyRoutingResponse(target, 'GET')).rejects.toThrow();
expect(globalThis.setTimeout).not.toHaveBeenCalled();
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
it('clears the request timeout when https.request throws synchronously', async () => {
const { fetchCliproxyRoutingResponse } = await loadRoutingHttpModule();
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const activeTimers = new Set<number>();
globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => {
void handler;
void timeout;
const timerId = activeTimers.size + 1;
activeTimers.add(timerId);
return timerId as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout);
globalThis.clearTimeout = mock(((timerId?: ReturnType<typeof setTimeout>) => {
activeTimers.delete(timerId as unknown as number);
}) as typeof clearTimeout);
const requestSpy = spyOn(https, 'request').mockImplementation(() => {
throw new Error('sync request failure');
});
const target: ProxyTarget = {
host: 'proxy.example.com',
port: 443,
protocol: 'https',
allowSelfSigned: true,
isRemote: true,
};
try {
await expect(fetchCliproxyRoutingResponse(target, 'GET')).rejects.toThrow(
'sync request failure'
);
expect(globalThis.setTimeout).toHaveBeenCalledTimes(1);
expect(globalThis.clearTimeout).toHaveBeenCalledTimes(1);
expect(activeTimers.size).toBe(0);
} finally {
requestSpy.mockRestore();
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
it('builds the remote management URL for routing strategy writes', async () => {
const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule();
const target: ProxyTarget = {
+46 -36
View File
@@ -40,6 +40,8 @@ export async function fetchCliproxyRoutingResponse(
}
}
const requestUrl = new URL(url);
return new Promise<Response>((resolve, reject) => {
const agent = new https.Agent({ rejectUnauthorized: false });
let settled = false;
@@ -51,57 +53,65 @@ export async function fetchCliproxyRoutingResponse(
callback();
};
let req: ReturnType<typeof https.request> | undefined;
const timeoutId = setTimeout(() => {
const error = new Error('Request timeout');
req.destroy(error);
req?.destroy(error);
settle(() => reject(error));
}, ROUTING_TIMEOUT_MS);
const req = https.request(
url,
{
method,
headers,
agent,
timeout: ROUTING_TIMEOUT_MS,
},
(res) => {
let payload = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
payload += chunk;
});
res.on('end', () => {
settle(() =>
resolve(
new Response(payload, {
status: res.statusCode || 500,
statusText: res.statusMessage ?? '',
headers:
typeof res.headers['content-type'] === 'string'
? { 'Content-Type': res.headers['content-type'] }
: undefined,
})
)
);
});
}
);
try {
req = https.request(
requestUrl,
{
method,
headers,
agent,
timeout: ROUTING_TIMEOUT_MS,
},
(res) => {
let payload = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
payload += chunk;
});
res.on('end', () => {
settle(() =>
resolve(
new Response(payload, {
status: res.statusCode || 500,
statusText: res.statusMessage ?? '',
headers:
typeof res.headers['content-type'] === 'string'
? { 'Content-Type': res.headers['content-type'] }
: undefined,
})
)
);
});
}
);
} catch (error) {
settle(() => reject(error));
return;
}
req.on('error', (error) => {
const request = req;
request.on('error', (error) => {
settle(() => reject(error));
});
req.on('timeout', () => {
request.on('timeout', () => {
const error = new Error('Request timeout');
req.destroy(error);
request.destroy(error);
settle(() => reject(error));
});
if (body) {
req.write(JSON.stringify(body));
request.write(JSON.stringify(body));
}
req.end();
request.end();
});
}
+7 -4
View File
@@ -38,6 +38,7 @@ const CHANNEL_TO_PROVIDER: Record<string, CLIProxyProvider> = {
kimi: 'kimi',
kiro: 'kiro',
'github-copilot': 'ghcp',
qoder: 'qoder',
};
/** CCS provider → channel name mapping (reverse) */
@@ -249,7 +250,6 @@ export function mergeCatalog(
if (!staticCatalog && filteredRemoteModels.length === 0) return undefined;
const displayName = staticCatalog?.displayName || provider;
const defaultModel = staticCatalog?.defaultModel || (filteredRemoteModels[0]?.id ?? '');
// Build map of static models by lowercase ID for fast lookup
const staticMap = new Map<string, ModelEntry>();
@@ -260,14 +260,11 @@ export function mergeCatalog(
}
// Process remote models: merge with static entries
const mergedIds = new Set<string>();
const mergedModels: ModelEntry[] = [];
for (const remote of filteredRemoteModels) {
const remoteEntry = mapRemoteToModelEntry(remote);
const staticEntry = staticMap.get(remote.id.toLowerCase());
mergedIds.add(remote.id.toLowerCase());
if (staticEntry) {
const mergedThinking = remoteEntry.thinking
? {
@@ -292,6 +289,12 @@ export function mergeCatalog(
}
}
const staticDefaultModel = staticCatalog?.defaultModel;
const hasStaticDefaultModel =
typeof staticDefaultModel === 'string' &&
mergedModels.some((model) => model.id.toLowerCase() === staticDefaultModel.toLowerCase());
const defaultModel = hasStaticDefaultModel ? staticDefaultModel : (mergedModels[0]?.id ?? '');
return {
provider,
displayName,
+3 -1
View File
@@ -16,7 +16,8 @@ export type CLIProxyProvider =
| 'cursor'
| 'gitlab'
| 'codebuddy'
| 'kilo';
| 'kilo'
| 'qoder';
/** CLIProxy backend selection */
export type CLIProxyBackend = 'original' | 'plus';
@@ -32,6 +33,7 @@ export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = [
'gitlab',
'codebuddy',
'kilo',
'qoder',
];
/** Model mapping for each provider */
+25 -4
View File
@@ -94,7 +94,7 @@ export function ensureSharedConfigSymlink(
target: targetPath,
});
} catch (err) {
copySharedConfigFallback(targetPath, linkPath, err);
handleSymlinkFailure(targetPath, linkPath, err);
}
}
@@ -106,9 +106,30 @@ function isRegularConfigCopyUnmodified(linkPath: string, targetPath: string): bo
}
}
function copySharedConfigFallback(targetPath: string, linkPath: string, err: unknown): void {
fs.copyFileSync(targetPath, linkPath);
fs.chmodSync(linkPath, 0o600);
function handleSymlinkFailure(targetPath: string, linkPath: string, err: unknown): void {
const code = (err as NodeJS.ErrnoException | null)?.code;
if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOSYS') {
throw err;
}
const tmpPath = path.join(
path.dirname(linkPath),
`.config.toml.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
);
try {
fs.copyFileSync(targetPath, tmpPath, fs.constants.COPYFILE_EXCL);
fs.chmodSync(tmpPath, 0o600);
fs.renameSync(tmpPath, linkPath);
} catch (copyErr) {
try {
fs.rmSync(tmpPath, { force: true });
} catch {
// best effort cleanup
}
throw copyErr;
}
process.stderr.write(
`[!] codex-auth: symlink unavailable; copied shared config.toml to ${linkPath}. ` +
`Config edits won't propagate automatically.\n`
@@ -3,7 +3,7 @@
*
* Migrates legacy ~/.codex/auth.json into a named profile (non-destructive).
* Implements C3 torn-write protection: read-with-retry + JWT-shape validation
* + pgrep Codex-running detection + atomic write.
* + current-user Codex-running detection + atomic write.
*
* Usage: ccsx auth import-default <name> [--with-history] [--force] [--force-while-running]
*/
@@ -40,51 +40,37 @@ function sleep(ms: number): Promise<void> {
}
/**
* Detect a running `codex` process via pgrep + ps validation (best-effort, never throws).
* Returns the PID string if found, null otherwise.
* Detect a running Codex CLI process from the current user's process table
* (best-effort, never throws). Returns the PID string if found, null otherwise.
*/
function detectCodexRunning(): string | null {
try {
const result = childProcess.spawnSync('pgrep', ['-f', 'codex'], {
encoding: 'utf8',
timeout: 2000,
});
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
const pids = parsePgrepPids(result.stdout);
if (pids.length === 0) return null;
const psResult = childProcess.spawnSync(
const result = childProcess.spawnSync(
'ps',
['-p', pids.join(','), '-o', 'pid=', '-o', 'command='],
['-A', '-o', 'pid=', '-o', 'uid=', '-o', 'command='],
{
encoding: 'utf8',
timeout: 2000,
}
);
if (psResult.status !== 0 || !psResult.stdout) return null;
return selectCodexPidFromPsOutput(psResult.stdout, pids);
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
return selectCodexPidFromPsOutput(result.stdout);
} catch {
return null;
}
}
function parsePgrepPids(stdout: string): string[] {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((pid) => /^\d+$/.test(pid) && pid !== String(process.pid));
}
function selectCodexPidFromPsOutput(stdout: string, candidatePids: string[]): string | null {
const candidates = new Set(candidatePids);
function selectCodexPidFromPsOutput(stdout: string): string | null {
const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
for (const line of stdout.split(/\r?\n/)) {
const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/);
if (!match) continue;
const [, pid, command] = match;
if (!pid || !command || !candidates.has(pid)) continue;
const [, pid, uid, command] = match;
if (!pid || !uid || !command || pid === String(process.pid)) continue;
if (currentUid !== null && uid !== String(currentUid)) continue;
if (isLikelyCodexProcessCommand(command)) return pid;
}
return null;
+1 -1
View File
@@ -62,7 +62,7 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P
` ${color('--1m / --no-1m', 'command')} Write or clear [1m] on compatible Claude mappings`
);
writeLine(
` ${color('--target <cli>', 'command')} Default target: claude or droid (create)`
` ${color('--target <cli>', 'command')} Default target: claude, droid, or codex (create)`
);
writeLine(` ${color('--register', 'command')} Register discovered orphan settings`);
writeLine(` ${color('--json', 'command')} JSON output for discover command`);
+103 -51
View File
@@ -36,55 +36,75 @@ function formatBytes(bytes: number): string {
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
}
/** Calculate total size of regular top-level files in a directory */
function getDirSize(dirPath: string): number {
if (!fs.existsSync(dirPath)) return 0;
interface DirectorySummary {
fileCount: number;
size: number;
}
let totalSize = 0;
const entries = fs.readdirSync(dirPath);
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
);
}
function pathExistsForCleanup(dirPath: string): boolean {
try {
fs.lstatSync(dirPath);
return true;
} catch (error) {
if (isMissingPathError(error)) return false;
throw error;
}
}
/** Return entries for a real directory, rejecting symlinked directory targets. */
function readRealDirectory(dirPath: string): string[] {
let stats: fs.Stats;
try {
stats = fs.lstatSync(dirPath);
} catch (error) {
if (isMissingPathError(error)) return [];
throw error;
}
if (!stats.isDirectory() || stats.isSymbolicLink()) return [];
return fs.readdirSync(dirPath);
}
/** Summarize regular top-level files in a real directory */
function summarizeDirectory(dirPath: string): DirectorySummary {
const summary = { fileCount: 0, size: 0 };
const entries = readRealDirectory(dirPath);
for (const entry of entries) {
const filePath = path.join(dirPath, entry);
try {
const stats = fs.lstatSync(filePath);
if (stats.isFile() && !stats.isSymbolicLink()) {
totalSize += stats.size;
summary.fileCount++;
summary.size += stats.size;
}
} catch {
// File may have been deleted between readdir and stat - skip
}
}
return totalSize;
return summary;
}
/** Count files in a directory */
function countFiles(dirPath: string): number {
if (!fs.existsSync(dirPath)) return 0;
let count = 0;
const entries = fs.readdirSync(dirPath);
for (const entry of entries) {
const filePath = path.join(dirPath, entry);
try {
const stats = fs.lstatSync(filePath);
if (stats.isFile() && !stats.isSymbolicLink()) {
count++;
}
} catch {
// File may have been deleted - skip
}
}
return count;
}
/** Delete all regular files in a directory (skips symlinks for safety) */
/** Delete all regular files in a real directory (skips symlinks for safety) */
function cleanDirectory(dirPath: string): { deleted: number; freedBytes: number } {
if (!fs.existsSync(dirPath)) return { deleted: 0, freedBytes: 0 };
let deleted = 0;
let freedBytes = 0;
const files = fs.readdirSync(dirPath);
const files = readRealDirectory(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
@@ -116,11 +136,9 @@ interface ErrorLogInfo {
/** Get error log files with metadata */
function getErrorLogFiles(logsDir: string): ErrorLogInfo[] {
if (!fs.existsSync(logsDir)) return [];
const now = Date.now();
const files: ErrorLogInfo[] = [];
const entries = fs.readdirSync(logsDir);
const entries = readRealDirectory(logsDir);
for (const entry of entries) {
// Only process error-*.log files
@@ -150,10 +168,9 @@ function getErrorLogFiles(logsDir: string): ErrorLogInfo[] {
/** Delete error logs older than specified days */
function cleanErrorLogs(
logsDir: string,
files: ErrorLogInfo[],
maxAgeDays: number
): { deleted: number; freedBytes: number; kept: number } {
const files = getErrorLogFiles(logsDir);
let deleted = 0;
let freedBytes = 0;
let kept = 0;
@@ -251,14 +268,23 @@ async function handleErrorLogCleanup(
dryRun: boolean,
force: boolean
): Promise<void> {
// Check if logs directory exists
if (!fs.existsSync(logsDir)) {
console.log(info('No CLIProxy logs directory found.'));
try {
if (!pathExistsForCleanup(logsDir)) {
console.log(info('No CLIProxy logs directory found.'));
return;
}
} catch (error) {
console.log(warn(`Could not inspect CLIProxy logs: ${getErrorMessage(error)}`));
return;
}
// Get error log files
const errorLogs = getErrorLogFiles(logsDir);
let errorLogs: ErrorLogInfo[];
try {
errorLogs = getErrorLogFiles(logsDir);
} catch (error) {
console.log(warn(`Could not read CLIProxy logs: ${getErrorMessage(error)}`));
return;
}
if (errorLogs.length === 0) {
console.log(info('No error logs found.'));
return;
@@ -323,7 +349,14 @@ async function handleErrorLogCleanup(
}
// Perform cleanup
const { deleted, freedBytes, kept } = cleanErrorLogs(logsDir, maxAgeDays);
let result: { deleted: number; freedBytes: number; kept: number };
try {
result = cleanErrorLogs(errorLogs, maxAgeDays);
} catch (error) {
console.log(warn(`Could not clean CLIProxy logs: ${getErrorMessage(error)}`));
return;
}
const { deleted, freedBytes, kept } = result;
console.log(ok(`Deleted ${deleted} error logs, freed ${formatBytes(freedBytes)}`));
if (kept > 0) {
console.log(info(`Kept ${kept} recent error logs (less than ${maxAgeDays} days old)`));
@@ -340,15 +373,30 @@ async function handleMainLogCleanup(options: {
dryRun: boolean;
force: boolean;
}): Promise<void> {
const targets = [
const targets: Array<{ label: string; dir: string } & DirectorySummary> = [];
const unreadableTargets: Array<{ label: string; dir: string; error: unknown }> = [];
for (const target of [
{ label: 'CCS Logs', dir: options.ccsLogsDir },
{ label: 'CCS Log Archives', dir: options.ccsArchiveDir },
{ label: 'CLIProxy Logs', dir: options.cliproxyLogsDir },
].map((target) => ({
...target,
fileCount: countFiles(target.dir),
size: getDirSize(target.dir),
}));
]) {
try {
targets.push({
...target,
...summarizeDirectory(target.dir),
});
} catch (error) {
unreadableTargets.push({ ...target, error });
}
}
if (unreadableTargets.length > 0) {
for (const target of unreadableTargets) {
console.log(warn(`Could not read ${target.label}: ${getErrorMessage(target.error)}`));
console.log(` ${target.dir}`);
}
return;
}
const activeTargets = targets.filter((target) => target.fileCount > 0);
if (activeTargets.length === 0) {
@@ -396,9 +444,13 @@ async function handleMainLogCleanup(options: {
let deleted = 0;
let freedBytes = 0;
for (const target of activeTargets) {
const result = cleanDirectory(target.dir);
deleted += result.deleted;
freedBytes += result.freedBytes;
try {
const result = cleanDirectory(target.dir);
deleted += result.deleted;
freedBytes += result.freedBytes;
} catch (error) {
console.log(warn(`Could not clean ${target.label}: ${getErrorMessage(error)}`));
}
}
console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`));
+19 -5
View File
@@ -18,6 +18,10 @@ import {
} from '../../cliproxy/accounts/account-manager';
import { fetchAllProviderQuotas } from '../../cliproxy/quota/quota-fetcher';
import { fetchAllCodexQuotas } from '../../cliproxy/quota/quota-fetcher-codex';
import {
sanitizeCodexFeatureLabel,
sanitizeCodexFeatureLabelOrNull,
} from '../../cliproxy/quota/quota-label-sanitizer';
import { fetchAllClaudeQuotas } from '../../cliproxy/quota/quota-fetcher-claude';
import { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota/quota-fetcher-claude-normalizer';
import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota/quota-fetcher-gemini-cli';
@@ -279,9 +283,12 @@ function inferCodeReviewCadence(
* Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the
* remainder into a Codex-prefixed display name. Other labels pass through unchanged.
*/
function prettifyCodexFeatureLabel(featureLabel: string): string {
const trimmed = featureLabel.trim();
if (!trimmed) return 'Additional';
function prettifyCodexFeatureLabel(featureLabel: unknown, fallbackLabel?: unknown): string {
const trimmed =
sanitizeCodexFeatureLabelOrNull(featureLabel) ??
(fallbackLabel === undefined
? sanitizeCodexFeatureLabel(featureLabel)
: sanitizeCodexFeatureLabel(fallbackLabel));
const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, '');
if (stripped !== trimmed && stripped.length > 0) {
return `Codex ${stripped}`;
@@ -302,7 +309,7 @@ function getCodexWindowDisplayLabel(
}
if (window.category === 'additional') {
const pretty = prettifyCodexFeatureLabel(window.featureLabel || window.label || 'Additional');
const pretty = prettifyCodexFeatureLabel(window.featureLabel, window.label);
if (window.cadence === '5h') return `${pretty} (5h)`;
if (window.cadence === 'weekly') return `${pretty} (weekly)`;
return pretty;
@@ -850,7 +857,9 @@ const QUOTA_PROVIDER_RUNTIME: Record<QuotaSupportedProvider, QuotaProviderRuntim
};
export const __testExports = {
getCodexWindowDisplayLabel,
getQuotaFailureDisplayEntries,
prettifyCodexFeatureLabel,
resolveDisplayedTier,
};
@@ -1056,8 +1065,13 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
}
if (account.paused) {
const refreshed = pauseAccount(provider, account.id);
const refreshedAccount = refreshed ? findAccountByQuery(provider, account.id) : account;
console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`));
console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`));
if (refreshed) {
console.log(info('Manual pause refreshed; account will stay out of quota rotation'));
}
console.log(info(`Paused at: ${refreshedAccount?.pausedAt || account.pausedAt || 'unknown'}`));
return;
}
+1
View File
@@ -205,6 +205,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
gitlab: 'GitLab Duo via CLIProxy OAuth',
codebuddy: 'CodeBuddy via CLIProxy OAuth',
kilo: 'Kilo AI via CLIProxy OAuth',
qoder: 'Qoder AI via CLIProxy OAuth',
}[name] || 'CLIProxy OAuth provider',
})
);
+24 -31
View File
@@ -54,31 +54,13 @@ interface ChannelsCommandOptions {
setSelectionMissing: boolean;
clearTokenAll: boolean;
clearTokenChannel?: OfficialChannelId;
setToken?: { channelId: OfficialChannelId; token: string };
setTokenChannel?: OfficialChannelId;
setTokenMissing: boolean;
clearTokenInvalid?: string;
setTokenInvalid?: string;
help: boolean;
}
function parseTokenAssignment(value: string): {
channelId: OfficialChannelId;
token: string;
} | null {
const separatorIndex = value.indexOf('=');
if (separatorIndex === -1) {
return value.trim() ? { channelId: 'discord', token: value.trim() } : null;
}
const channelId = value.slice(0, separatorIndex).trim().toLowerCase();
const token = value.slice(separatorIndex + 1).trim();
if (!isOfficialChannelId(channelId) || !token) {
return null;
}
return { channelId, token };
}
export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions {
const setSelection = extractOption(args, ['--set']);
const setToken = extractOption(args, ['--set-token']);
@@ -100,11 +82,13 @@ export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions
}
}
let parsedSetToken: { channelId: OfficialChannelId; token: string } | undefined;
let parsedSetTokenChannel: OfficialChannelId | undefined;
let setTokenInvalid: string | undefined;
if (setToken.found && !setToken.missingValue && setToken.value) {
parsedSetToken = parseTokenAssignment(setToken.value) ?? undefined;
if (!parsedSetToken) {
const channelId = setToken.value.trim().toLowerCase();
if (isOfficialChannelId(channelId)) {
parsedSetTokenChannel = channelId;
} else {
setTokenInvalid = setToken.value;
}
}
@@ -120,7 +104,7 @@ export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions
clearTokenAll,
clearTokenChannel,
clearTokenInvalid,
setToken: parsedSetToken,
setTokenChannel: parsedSetTokenChannel,
setTokenMissing: setToken.found && setToken.missingValue,
setTokenInvalid,
help: hasAnyFlag(args, ['--help', '-h']),
@@ -153,7 +137,7 @@ function showHelp(): void {
` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions`
);
console.log(` ${color('--no-unattended', 'command')} Disable unattended runtime flag`);
console.log(` ${color('--set-token <spec>', 'command')} ${getOfficialChannelTokenHelp()}`);
console.log(` ${color('--set-token <channel>', 'command')} ${getOfficialChannelTokenHelp()}`);
console.log(
` ${color('--clear-token [channel]', 'command')} ${getOfficialChannelClearTokenHelp()}`
);
@@ -173,7 +157,7 @@ function showHelp(): void {
` $ ${color('ccs config channels --set all', 'command')} ${dim('# Enable all official channels')}`
);
console.log(
` $ ${color('ccs config channels --set-token telegram=123:abc', 'command')} ${dim('# Save TELEGRAM_BOT_TOKEN')}`
` $ ${color('TELEGRAM_BOT_TOKEN=123:abc ccs config channels --set-token telegram', 'command')} ${dim('# Save TELEGRAM_BOT_TOKEN')}`
);
console.log(
` $ ${color('ccs config channels --clear-token discord', 'command')} ${dim('# Clear one token')}`
@@ -395,7 +379,9 @@ export async function handleConfigChannelsCommand(args: string[]): Promise<void>
}
if (options.setTokenInvalid) {
console.error(
fail(`Invalid --set-token value: ${options.setTokenInvalid} (use <channel>=<token>)`)
fail(
`Invalid --set-token value: ${options.setTokenInvalid} (use ${getOfficialChannelChoices()})`
)
);
process.exitCode = 1;
return;
@@ -444,12 +430,19 @@ export async function handleConfigChannelsCommand(args: string[]): Promise<void>
updateConfig({ channels: nextConfig });
}
if (options.setToken) {
if (!getOfficialChannelTokenIds().includes(options.setToken.channelId)) {
throw new Error(`${options.setToken.channelId} does not use a bot token.`);
if (options.setTokenChannel) {
if (!getOfficialChannelTokenIds().includes(options.setTokenChannel)) {
throw new Error(`${options.setTokenChannel} does not use a bot token.`);
}
setConfiguredOfficialChannelToken(options.setToken.channelId, options.setToken.token);
console.log(ok(`${getOfficialChannelDisplayName(options.setToken.channelId)} token saved`));
const envKey = getOfficialChannelEnvKey(options.setTokenChannel);
const token = envKey ? process.env[envKey]?.trim() : '';
if (!token) {
throw new Error(
`${getOfficialChannelDisplayName(options.setTokenChannel)} token missing. Set ${envKey} in your environment and rerun.`
);
}
setConfiguredOfficialChannelToken(options.setTokenChannel, token);
console.log(ok(`${getOfficialChannelDisplayName(options.setTokenChannel)} token saved`));
console.log('');
}
+1
View File
@@ -16,6 +16,7 @@ export const RESERVED_PROFILE_NAMES = [
'gitlab',
'codebuddy',
'kilo',
'qoder',
// Copilot API (GitHub Copilot proxy)
'copilot',
// Cursor IDE (Cursor proxy daemon)
+1 -1
View File
@@ -183,7 +183,7 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
codex: 'gpt-5.1-codex-mini',
kiro: 'kiro-claude-haiku-4-5',
ghcp: 'claude-haiku-4.5',
claude: 'claude-haiku-4.5-20251001',
claude: 'claude-haiku-4-5-20251001',
qwen: 'vision-model',
iflow: 'qwen3-vl-plus',
kimi: 'vision-model',
+24
View File
@@ -0,0 +1,24 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
const DAEMON_TOKEN_FILE = 'cursor-daemon-token';
export function getCursorDaemonToken(): string {
const tokenPath = path.join(getCcsDir(), DAEMON_TOKEN_FILE);
try {
const token = fs.readFileSync(tokenPath, 'utf8').trim();
if (token.length > 0) {
return token;
}
} catch {
// Token file missing or unreadable; regenerate below.
}
const token = crypto.randomBytes(32).toString('hex');
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
fs.writeFileSync(tokenPath, token, { mode: 0o600 });
return token;
}
+59
View File
@@ -55,6 +55,23 @@ interface OpenAIChatRequest {
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
function getAnthropicRequestToken(headers: http.IncomingHttpHeaders): string {
const xApiKey = headers['x-api-key'];
if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) {
return xApiKey.trim();
}
const authorization = headers.authorization;
if (typeof authorization === 'string') {
const match = authorization.match(/^Bearer\s+(.+)$/i);
if (match && match[1].trim().length > 0) {
return match[1].trim();
}
}
return '';
}
function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
@@ -108,6 +125,23 @@ function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
});
}
function hasValidDaemonToken(req: http.IncomingMessage): boolean {
const expectedToken = process.env.CCS_CURSOR_DAEMON_TOKEN;
if (!expectedToken) {
return false;
}
const provided = req.headers['x-ccs-cursor-token'];
if (typeof provided === 'string') {
return provided === expectedToken;
}
if (Array.isArray(provided)) {
return provided.includes(expectedToken);
}
return false;
}
function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] {
if (!Array.isArray(raw)) {
throw new Error('messages must be an array');
@@ -202,6 +236,10 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
try {
if (method === 'GET' && requestUrl === '/health') {
if (!hasValidDaemonToken(req)) {
writeJson(res, 401, { error: 'Unauthorized' });
return;
}
writeJson(res, 200, { ok: true, service: 'cursor-daemon' });
return;
}
@@ -234,6 +272,11 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
return;
}
if (!hasValidDaemonToken(req)) {
writeJson(res, 401, { error: 'Unauthorized' });
return;
}
const rawBody = await readJsonBody(req);
const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined;
const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {});
@@ -283,6 +326,22 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
return;
}
if (isAnthropicRoute) {
const expectedToken = (process.env.ANTHROPIC_AUTH_TOKEN || 'cursor-managed').trim();
const requestToken = getAnthropicRequestToken(req.headers);
if (!expectedToken || requestToken !== expectedToken) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(
401,
'authentication_error',
'Invalid Anthropic auth token. Set ANTHROPIC_AUTH_TOKEN and send it via x-api-key or Authorization Bearer.'
),
res
);
return;
}
}
const daemonCredentials = {
accessToken: authStatus.credentials.accessToken,
machineId: authStatus.credentials.machineId,
+30 -7
View File
@@ -6,6 +6,7 @@
*/
import { spawn, ChildProcess } from 'child_process';
import { randomBytes } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
@@ -56,7 +57,7 @@ async function resolveDaemonEntrypoint(): Promise<string | null> {
* Check if cursor daemon is running on the specified port.
* Uses 127.0.0.1 instead of localhost for more reliable local connections.
*/
export async function isDaemonRunning(port: number): Promise<boolean> {
export async function isDaemonRunning(port: number, daemonToken?: string): Promise<boolean> {
return new Promise((resolve) => {
const req = http.request(
{
@@ -65,6 +66,7 @@ export async function isDaemonRunning(port: number): Promise<boolean> {
path: '/health',
method: 'GET',
timeout: 3000,
headers: daemonToken ? { 'x-ccs-cursor-token': daemonToken } : undefined,
},
(res) => {
let body = '';
@@ -125,14 +127,26 @@ export async function getDaemonStatus(port: number): Promise<CursorDaemonStatus>
*/
export async function startDaemon(
config: CursorDaemonConfig
): Promise<{ success: boolean; pid?: number; error?: string }> {
): Promise<{ success: boolean; pid?: number; error?: string; daemonToken?: string }> {
// Auto-generate daemon token if not provided so /health and protected routes
// can be probed by the caller during startup polling.
const daemonToken =
config.daemon_token && config.daemon_token.trim()
? config.daemon_token
: randomBytes(32).toString('hex');
const effectiveConfig: CursorDaemonConfig = { ...config, daemon_token: daemonToken };
// Check if already running
if (await isDaemonRunning(config.port)) {
if (await isDaemonRunning(effectiveConfig.port, effectiveConfig.daemon_token)) {
logger.stage('dispatch', 'cursor.daemon.already_running', 'Cursor daemon already running', {
provider: 'cursor',
port: config.port,
});
return { success: true, pid: getPidFromFile() ?? undefined };
return {
success: true,
pid: getPidFromFile() ?? undefined,
daemonToken: effectiveConfig.daemon_token,
};
}
// Validate port before interpolation (prevents injection)
@@ -159,7 +173,12 @@ export async function startDaemon(
let proc: ChildProcess;
let resolved = false;
const safeResolve = (result: { success: boolean; pid?: number; error?: string }) => {
const safeResolve = (result: {
success: boolean;
pid?: number;
error?: string;
daemonToken?: string;
}) => {
if (resolved) return;
resolved = true;
if (checkTimeout) clearTimeout(checkTimeout);
@@ -205,6 +224,10 @@ export async function startDaemon(
proc = spawn(process.execPath, args, {
stdio: 'ignore',
detached: true,
env: {
...process.env,
CCS_CURSOR_DAEMON_TOKEN: effectiveConfig.daemon_token || '',
},
});
// Unref so parent can exit
@@ -220,8 +243,8 @@ export async function startDaemon(
const pollHealth = async () => {
attempts++;
if (await isDaemonRunning(config.port)) {
safeResolve({ success: true, pid: proc.pid });
if (await isDaemonRunning(effectiveConfig.port, effectiveConfig.daemon_token)) {
safeResolve({ success: true, pid: proc.pid, daemonToken: effectiveConfig.daemon_token });
} else if (attempts >= maxAttempts) {
// Kill orphaned process
if (proc.pid) {
+2 -1
View File
@@ -8,6 +8,7 @@ import * as http from 'http';
import type { CursorModel } from './types';
import type { CursorApiCredentials } from './cursor-protobuf-schema';
import { isDaemonRunning } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { buildCursorModelsHeaders } from './cursor-client-policy';
import {
DEFAULT_CURSOR_MODEL,
@@ -285,7 +286,7 @@ export async function fetchModelsFromDaemon(port: number): Promise<CursorModel[]
* Checks daemon health first to avoid 5s timeout when daemon is not running.
*/
export async function getAvailableModels(port: number): Promise<CursorModel[]> {
if (!(await isDaemonRunning(port))) {
if (!(await isDaemonRunning(port, getCursorDaemonToken()))) {
return DEFAULT_CURSOR_MODELS;
}
return fetchModelsFromDaemon(port);
+8 -3
View File
@@ -15,6 +15,7 @@ import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../u
import { stripClaudeCodeEnv } from '../utils/shell-executor';
import { checkAuthStatus } from './cursor-auth';
import { isDaemonRunning, startDaemon } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { getGlobalEnvConfig } from '../config/config-loader-facade';
interface CursorImageAnalysisResolution {
@@ -31,6 +32,7 @@ interface CursorImageAnalysisDeps {
export function generateCursorEnv(
config: CursorConfig,
daemonToken: string,
claudeConfigDir?: string
): Record<string, string> {
const opusModel = config.opus_model || config.model;
@@ -39,7 +41,7 @@ export function generateCursorEnv(
return {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
ANTHROPIC_AUTH_TOKEN: daemonToken,
ANTHROPIC_MODEL: config.model,
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
@@ -130,13 +132,16 @@ export async function executeCursorProfile(
return 1;
}
let daemonRunning = await isDaemonRunning(config.port);
const daemonToken = getCursorDaemonToken();
let daemonRunning = await isDaemonRunning(config.port, daemonToken);
if (!daemonRunning) {
if (config.auto_start) {
console.log(info('Starting cursor daemon...'));
const result = await startDaemon({
port: config.port,
ghost_mode: config.ghost_mode,
daemon_token: daemonToken,
});
if (!result.success) {
console.error(fail(`Failed to start cursor daemon: ${result.error}`));
@@ -154,7 +159,7 @@ export async function executeCursorProfile(
}
}
const cursorEnv = generateCursorEnv(config, claudeConfigDir);
const cursorEnv = generateCursorEnv(config, daemonToken, claudeConfigDir);
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
const webSearchEnv = getWebSearchHookEnv();
+4 -2
View File
@@ -1,6 +1,7 @@
import type { CursorConfig } from '../config/unified-config-types';
import { checkAuthStatus } from './cursor-auth';
import { isDaemonRunning, startDaemon } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models';
export interface CursorProbeResult {
@@ -136,7 +137,8 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
};
}
let daemonRunning = await isDaemonRunning(config.port);
const daemonToken = getCursorDaemonToken();
let daemonRunning = await isDaemonRunning(config.port, daemonToken);
if (!daemonRunning && config.auto_start) {
const startResult = await startDaemon({
port: config.port,
@@ -144,7 +146,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
});
if (!startResult.success) {
daemonRunning = await isDaemonRunning(config.port);
daemonRunning = await isDaemonRunning(config.port, daemonToken);
} else {
daemonRunning = true;
}
+1
View File
@@ -10,6 +10,7 @@
export interface CursorDaemonConfig {
port: number;
ghost_mode?: boolean;
daemon_token?: string;
}
/**
+43 -2
View File
@@ -29,6 +29,7 @@ import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver';
import { DroidReasoningFlagError } from '../targets/droid-reasoning-runtime';
import { DroidCommandRouterError, routeDroidCommandArgs } from '../targets/droid-command-router';
import { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge';
import { getClaudeSubcommandName } from '../utils/claude-subcommand-detector';
import { resolveCodexRuntimeConfigOverrides } from './environment-builder';
import {
detectProfile,
@@ -83,6 +84,31 @@ function usesImplicitDefaultProfile(cleanArgs: string[]): boolean {
return cleanArgs.length === 0 || cleanArgs[0]?.startsWith('-') === true;
}
/**
* Decide whether a first token that has no matching profile is actually a bare
* Claude subcommand that should be forwarded through the default profile.
*
* Claude Code exposes subcommands like `claude agents`, `claude mcp`,
* `claude plugin`, `claude setup-token`. Invoked through CCS as `ccs agents`,
* `ccs mcp`, ... the first token is treated as a profile name, so profile
* resolution throws "profile not found". Instead, forward such tokens to
* `claude <subcommand>` under the default profile (matching `ccs default
* agents`), where the launcher already strips interactive-session args.
*
* Gated to the claude target codex/droid run their own subcommand routing
* and only reached on the profile-not-found path, so a real configured profile
* of the same name always wins.
*/
export function isBareClaudeSubcommandPassthrough(profile: string, args: string[]): boolean {
if (profile === 'default') return false;
if (getClaudeSubcommandName([profile]) === null) return false;
try {
return resolveTargetType(args) === 'claude';
} catch {
return false;
}
}
function buildNativeCodexDefaultProfile(): ProfileDetectionResult {
return {
type: 'default',
@@ -123,8 +149,23 @@ export async function resolveProfileAndTarget(
// Detect profile (strip --target flags before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
let profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
const detected = detectProfile(cleanArgs);
let profile = detected.profile;
let remainingArgs = detected.remainingArgs;
let profileInfo: ProfileDetectionResult;
try {
profileInfo = detector.detectProfileType(profile);
} catch (profileError) {
// Bare Claude subcommand passthrough: forward `ccs agents`, `ccs mcp`, ...
// through the default profile instead of failing as an unknown profile.
if (isBareClaudeSubcommandPassthrough(profile, args)) {
remainingArgs = [profile, ...remainingArgs];
profile = 'default';
profileInfo = detector.detectProfileType(profile);
} else {
throw profileError;
}
}
let resolvedTarget: ReturnType<typeof resolveTargetType>;
try {
+9
View File
@@ -7,6 +7,7 @@ export const DOCKER_LEGACY_API_KEY = 'ccs-internal-managed';
export const DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS = 14;
export const DOCKER_LEGACY_KEY_GRACE_ENV = 'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS';
export const DOCKER_RESTORE_LEGACY_KEY_ENV = 'CCS_DOCKER_RESTORE_LEGACY_API_KEY';
export const DOCKER_ENABLE_LEGACY_KEY_AUTH_ENV = 'CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH';
const DAY_MS = 24 * 60 * 60 * 1000;
const STATE_VERSION = 1;
@@ -67,6 +68,10 @@ export function shouldRestoreDockerLegacyApiKey(env = process.env): boolean {
return env[DOCKER_RESTORE_LEGACY_KEY_ENV] === '1';
}
export function shouldEnableDockerLegacyKeyAuth(env = process.env): boolean {
return env[DOCKER_ENABLE_LEGACY_KEY_AUTH_ENV] === '1';
}
export function readDockerBootstrapState(): DockerBootstrapStateReadResult {
const statePath = getDockerBootstrapStatePath();
if (!fs.existsSync(statePath)) {
@@ -135,6 +140,10 @@ export function isDockerLegacyKeyGraceActive(
}
export function getActiveDockerLegacyApiKeys(now = new Date()): string[] {
if (!shouldEnableDockerLegacyKeyAuth()) {
return [];
}
const { state } = readDockerBootstrapState();
if (!isDockerLegacyKeyGraceActive(state, now)) {
return [];
+16 -1
View File
@@ -35,12 +35,14 @@ export class SSEParser {
private eventCount: number;
private maxBufferSize: number;
private throwOnMalformedJson: boolean;
private pendingCR: boolean;
constructor(options: SSEParserOptions = {}) {
this.buffer = '';
this.eventCount = 0;
this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default
this.throwOnMalformedJson = options.throwOnMalformedJson === true;
this.pendingCR = false;
}
/**
@@ -49,7 +51,19 @@ export class SSEParser {
* @returns Array of parsed events
*/
parse(chunk: Buffer | string): SSEEvent[] {
this.buffer += chunk.toString().replace(/\r\n?/g, '\n');
let normalizedChunk = chunk.toString();
if (this.pendingCR) {
if (normalizedChunk.startsWith('\n')) {
normalizedChunk = normalizedChunk.slice(1);
}
this.pendingCR = false;
}
const endsWithCR = normalizedChunk.endsWith('\r');
normalizedChunk = normalizedChunk.replace(/\r\n?/g, '\n');
this.pendingCR = endsWithCR;
this.buffer += normalizedChunk;
// C-01 Fix: Prevent unbounded buffer growth (DoS protection)
if (this.buffer.length > this.maxBufferSize) {
@@ -127,5 +141,6 @@ export class SSEParser {
reset(): void {
this.buffer = '';
this.eventCount = 0;
this.pendingCR = false;
}
}
+13 -1
View File
@@ -354,7 +354,19 @@ class SharedManager {
}
const entryPath = path.join(sharedPluginsPath, entry.name);
const stats = fs.statSync(entryPath);
let stats: fs.Stats;
try {
stats = fs.statSync(entryPath);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
console.log(
warn(
`Skipping plugins/${entry.name}: unable to inspect shared plugin entry${code ? ` (${code})` : ''}`
)
);
continue;
}
items.set(entry.name, {
name: entry.name,
type: stats.isDirectory() ? 'directory' : 'file',
+9 -3
View File
@@ -11,6 +11,7 @@ export interface OpenAICompatProfileConfig {
apiKey: string;
provider: DroidProvider;
insecure?: boolean;
forceOpenAIReasoningModel?: boolean;
model?: string;
opusModel?: string;
sonnetModel?: string;
@@ -28,12 +29,18 @@ export interface OpenAICompatProfileEnv {
ANTHROPIC_SMALL_FAST_MODEL?: string;
CCS_DROID_PROVIDER?: string;
CCS_OPENAI_PROXY_INSECURE?: string;
CCS_OPENAI_REASONING_MODEL?: string;
}
export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider {
return provider === 'openai' || provider === 'generic-chat-completion-api';
}
function isTruthyEnv(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
}
export function resolveOpenAICompatProfileConfig(
profileName: string,
settingsPath: string,
@@ -63,9 +70,8 @@ export function resolveOpenAICompatProfileConfig(
baseUrl,
apiKey,
provider,
insecure:
env.CCS_OPENAI_PROXY_INSECURE === '1' ||
env.CCS_OPENAI_PROXY_INSECURE?.toLowerCase() === 'true',
insecure: isTruthyEnv(env.CCS_OPENAI_PROXY_INSECURE),
forceOpenAIReasoningModel: isTruthyEnv(env.CCS_OPENAI_REASONING_MODEL),
model: env.ANTHROPIC_MODEL?.trim() || undefined,
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined,
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined,
+1 -1
View File
@@ -285,7 +285,7 @@ function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatP
};
}
return { pid: null, session: null, source: 'profile' };
return { pid: getOpenAICompatProxyPid(profileName), session: null, source: 'profile' };
}
export async function listOpenAICompatProxyStatuses(): Promise<OpenAICompatProxyStatus[]> {
+93 -9
View File
@@ -31,26 +31,110 @@ function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record<string
};
}
function isDirectOpenAIReasoningChatModel(
function isKnownOpenAIReasoningChatModel(model: string | undefined): boolean {
if (typeof model !== 'string') {
return false;
}
const normalized = model.trim().toLowerCase();
const modelName = normalized.split('/').pop() || normalized;
return DIRECT_OPENAI_REASONING_CHAT_MODEL.test(modelName);
}
function shouldShapeOpenAIReasoningChatPayload(
profile: OpenAICompatProfileConfig,
model: string | undefined
): boolean {
return (
profile.provider === 'openai' &&
typeof model === 'string' &&
DIRECT_OPENAI_REASONING_CHAT_MODEL.test(model.trim().toLowerCase())
);
return profile.forceOpenAIReasoningModel === true || isKnownOpenAIReasoningChatModel(model);
}
function isMiniMaxOpenAICompatProfile(profile: OpenAICompatProfileConfig): boolean {
try {
return new URL(profile.baseUrl).hostname.toLowerCase().includes('minimax');
} catch {
return false;
}
}
function prependTextToContent(
content: ProxyOpenAIRequest['messages'][number]['content'],
text: string
): ProxyOpenAIRequest['messages'][number]['content'] {
if (Array.isArray(content)) {
return [{ type: 'text', text }, ...content];
}
if (typeof content === 'string') {
return `${text}\n\n${content}`;
}
return text;
}
function extractTextContent(content: ProxyOpenAIRequest['messages'][number]['content']): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return '';
}
return content
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
.map((part) => part.text)
.join('\n\n');
}
function shapeMiniMaxChatPayload(payload: ProxyOpenAIRequest): ProxyOpenAIRequest {
const systemMessages: string[] = [];
let removedSystemMessage = false;
const messages = payload.messages.filter((message) => {
if (message.role !== 'system') {
return true;
}
removedSystemMessage = true;
const systemText = extractTextContent(message.content).trim();
if (systemText.length > 0) {
systemMessages.push(systemText);
}
return false;
});
if (!removedSystemMessage) {
return payload;
}
if (systemMessages.length === 0) {
return { ...payload, messages };
}
const systemPrefix = systemMessages.join('\n\n');
const firstUserIndex = messages.findIndex((message) => message.role === 'user');
if (firstUserIndex >= 0) {
messages[firstUserIndex] = {
...messages[firstUserIndex],
content: prependTextToContent(messages[firstUserIndex].content, systemPrefix),
};
} else {
messages.unshift({ role: 'user', content: systemPrefix });
}
return { ...payload, messages };
}
function shapeUpstreamChatPayload(
payload: ProxyOpenAIRequest,
profile: OpenAICompatProfileConfig
): ProxyOpenAIRequest {
if (!isDirectOpenAIReasoningChatModel(profile, payload.model)) {
return payload;
let shaped = payload;
if (isMiniMaxOpenAICompatProfile(profile)) {
shaped = shapeMiniMaxChatPayload(shaped);
}
const shaped = { ...payload };
if (!shouldShapeOpenAIReasoningChatPayload(profile, shaped.model)) {
return shaped;
}
shaped = { ...shaped };
if (shaped.max_tokens !== undefined) {
shaped.max_completion_tokens = shaped.max_tokens;
+11 -3
View File
@@ -40,7 +40,7 @@ type AnthropicContentBlock =
| { type: string; [key: string]: unknown };
interface AnthropicMessage {
role?: 'user' | 'assistant' | string;
role?: 'system' | 'user' | 'assistant' | string;
content?: string | AnthropicContentBlock[];
}
@@ -435,8 +435,8 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
messagesValue.forEach((message, messageIndex) => {
const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage;
const role = parsedMessage.role;
if (role !== 'user' && role !== 'assistant') {
throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`);
if (role !== 'system' && role !== 'user' && role !== 'assistant') {
throw new Error(`messages[${messageIndex}].role must be "system", "user", or "assistant"`);
}
if (pendingToolUseIds && pendingToolUseIds.size > 0 && role !== 'user') {
@@ -446,6 +446,14 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
}
const content = parsedMessage.content;
if (role === 'system') {
translatedMessages.push({
role: 'system',
content: flattenTextContent(content, `messages[${messageIndex}].content`),
});
return;
}
if (typeof content === 'string') {
if (pendingToolUseIds && pendingToolUseIds.size > 0) {
throw new Error(
+6 -1
View File
@@ -13,6 +13,7 @@ import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
import { generateCopilotEnv } from '../copilot/copilot-executor';
import { generateCursorEnv } from '../cursor';
import { getCursorDaemonToken } from '../cursor/cursor-daemon-auth';
import InstanceManager from '../management/instance-manager';
import SharedManager from '../management/shared-manager';
import { expandPath } from '../utils/helpers';
@@ -245,7 +246,11 @@ async function resolveExtensionEnv(
if (!result.cursorConfig) {
throw new Error(`Profile "${requestedProfile}" is missing cursor configuration.`);
}
return generateCursorEnv(result.cursorConfig, continuity.claudeConfigDir);
return generateCursorEnv(
result.cursorConfig,
getCursorDaemonToken(),
continuity.claudeConfigDir
);
})()
: (() => {
if (!result.provider) {
+1
View File
@@ -57,6 +57,7 @@ const PROVIDER_OWNER_HINTS: Record<string, string[]> = {
kimi: ['kimi', 'moonshot'],
kiro: ['kiro', 'aws'],
ghcp: ['github', 'copilot'],
qoder: ['qoder'],
};
function normalize(value: string | null | undefined): string {
+57 -15
View File
@@ -1,5 +1,8 @@
export const CODEX_TRANSLATOR_URL_MARKER = '/api/provider/codex';
const MAX_CODEX_TRANSLATOR_SCAN_DEPTH = 100;
const MAX_CODEX_TRANSLATOR_SCAN_NODES = 10000;
function formatSettingsPathSegment(basePath: string, segment: string | number): string {
if (typeof segment === 'number') {
return `${basePath}[${segment}]`;
@@ -13,23 +16,62 @@ function formatSettingsPathSegment(basePath: string, segment: string | number):
}
export function findCodexTranslatorUrlPaths(value: unknown, path = ''): string[] {
if (typeof value === 'string') {
return value.includes(CODEX_TRANSLATOR_URL_MARKER) ? [path || '(root)'] : [];
const matches: string[] = [];
const stack: Array<{ value: unknown; path: string; depth: number }> = [{ value, path, depth: 0 }];
const seen = new WeakSet<object>();
let visitedNodes = 0;
while (stack.length > 0 && visitedNodes < MAX_CODEX_TRANSLATOR_SCAN_NODES) {
const item = stack.pop();
if (!item) {
break;
}
visitedNodes += 1;
if (typeof item.value === 'string') {
if (item.value.includes(CODEX_TRANSLATOR_URL_MARKER)) {
matches.push(item.path || '(root)');
}
continue;
}
if (typeof item.value !== 'object' || item.value === null) {
continue;
}
if (seen.has(item.value)) {
continue;
}
seen.add(item.value);
if (item.depth >= MAX_CODEX_TRANSLATOR_SCAN_DEPTH) {
continue;
}
if (Array.isArray(item.value)) {
for (let index = item.value.length - 1; index >= 0; index -= 1) {
stack.push({
value: item.value[index],
path: formatSettingsPathSegment(item.path, index),
depth: item.depth + 1,
});
}
continue;
}
const entries = Object.entries(item.value);
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, child] = entries[index];
stack.push({
value: child,
path: formatSettingsPathSegment(item.path, key),
depth: item.depth + 1,
});
}
}
if (Array.isArray(value)) {
return value.flatMap((item, index) =>
findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, index))
);
}
if (typeof value === 'object' && value !== null) {
return Object.entries(value).flatMap(([key, item]) =>
findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, key))
);
}
return [];
return matches;
}
export function formatSettingsPathList(paths: string[]): string {
+11 -8
View File
@@ -143,6 +143,10 @@ function normalizeCcsxpCodexModelFlagAliases(args: string[]): {
for (let index = 0; index < normalizedArgs.length; index += 1) {
const arg = normalizedArgs[index];
if (arg === '--') {
break;
}
if (arg === '-m' || arg === '--model') {
const nextValue = normalizedArgs[index + 1];
if (typeof nextValue === 'string') {
@@ -222,7 +226,7 @@ function prepareExplicitCodexHome(
}
try {
fs.mkdirSync(codexHome, { recursive: true });
fs.mkdirSync(codexHome, { mode: 0o700, recursive: true });
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code !== 'EEXIST') {
@@ -289,20 +293,19 @@ export class CodexAdapter implements TargetAdapter {
const runtimeConfigOverrides = creds?.runtimeConfigOverrides ?? [];
if (profileType === 'default') {
const modelFlagNormalization = isCcsxpCliproxyShortcut()
const isCcsxpShortcut = isCcsxpCliproxyShortcut();
const modelFlagNormalization = isCcsxpShortcut
? normalizeCcsxpCodexModelFlagAliases(userArgs)
: { args: userArgs, overrides: [] };
const overrides = [...runtimeConfigOverrides, ...modelFlagNormalization.overrides];
if (reasoningOverride) {
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
}
if (overrides.length === 0) {
return modelFlagNormalization.args;
const needsConfigOverrideSupport = isCcsxpShortcut || overrides.length > 0;
if (needsConfigOverrideSupport && !codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo));
}
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
if (reasoningOverride || modelFlagNormalization.overrides.length > 0) {
throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo));
}
if (overrides.length === 0) {
return modelFlagNormalization.args;
}
return [...buildConfigOverrideArgs(overrides), ...modelFlagNormalization.args];
+1 -1
View File
@@ -23,7 +23,7 @@ export const TARGET_METADATA: Record<TargetType, TargetMetadata> = {
displayName: 'Codex CLI',
runtimeAliases: ['ccs-codex', 'ccsx', 'ccsxp'],
legacyAliasEnvVar: 'CCS_CODEX_ALIASES',
persistedTarget: false,
persistedTarget: true,
},
} satisfies Record<TargetType, TargetMetadata>;
@@ -25,6 +25,26 @@ describe('withRetry', () => {
expect(fn).toHaveBeenCalledTimes(3);
});
it('uses the default retryability check when retryableCheck is null at runtime', async () => {
let attempt = 0;
const fn = mock(() => {
attempt++;
if (attempt < 2) {
return Promise.reject(new RetryableError('transient failure'));
}
return Promise.resolve('ok');
});
const result = await withRetry(fn, {
maxRetries: 3,
baseDelayMs: 1,
retryableCheck: null,
} as unknown as RetryOptions);
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});
it('throws after max retries exhausted', async () => {
const fn = mock(() => Promise.reject(new RetryableError('always fails')));
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 })).rejects.toThrow('always fails');
+15 -3
View File
@@ -230,8 +230,11 @@ export function getEffectiveClaudeBrowserAttachConfig(
const override = getBrowserAttachOverride(env);
const configUserDataDir =
resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir();
const configHasExplicitPort = config.claude.devtools_port !== undefined;
const configPort = normalizeDevtoolsPort(config.claude.devtools_port);
const configEvalMode = config.claude.eval_mode ?? 'readonly';
const envEvalMode = parseBrowserEvalMode(env.CCS_BROWSER_EVAL_MODE);
const effectiveEvalMode = envEvalMode ?? configEvalMode;
if (override.userDataDir) {
return {
@@ -241,7 +244,7 @@ export function getEffectiveClaudeBrowserAttachConfig(
userDataDir: override.userDataDir,
devtoolsPort: override.devtoolsPort ?? configPort,
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
evalMode: configEvalMode,
evalMode: effectiveEvalMode,
};
}
@@ -251,8 +254,8 @@ export function getEffectiveClaudeBrowserAttachConfig(
overrideActive: false,
userDataDir: configUserDataDir,
devtoolsPort: configPort,
hasExplicitDevtoolsPort: true,
evalMode: configEvalMode,
hasExplicitDevtoolsPort: configHasExplicitPort,
evalMode: effectiveEvalMode,
};
}
@@ -304,6 +307,15 @@ function parseDevtoolsPort(value?: string): number | undefined {
return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10));
}
function parseBrowserEvalMode(value?: string): BrowserEvalMode | undefined {
const trimmed = value?.trim();
if (trimmed === 'disabled' || trimmed === 'readonly' || trimmed === 'readwrite') {
return trimmed;
}
return undefined;
}
function normalizeDevtoolsPort(value: number | undefined): number {
if (!Number.isFinite(value)) {
return 9222;
+1 -5
View File
@@ -25,11 +25,7 @@ export function isLegacyGlmtBaseUrl(baseUrl: string | null | undefined): boolean
return false;
}
return (
normalized === LEGACY_GLMT_BASE_URL ||
normalized.includes('/api/coding/paas/v4') ||
normalized.endsWith('/chat/completions')
);
return normalized === LEGACY_GLMT_BASE_URL || normalized.includes('/api/coding/paas/v4');
}
export function normalizeDeprecatedGlmtEnv(env: Record<string, string>): GlmtNormalizationResult {
+1 -1
View File
@@ -99,7 +99,7 @@ export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions):
throw new Error('withRetry: baseDelayMs must be >= 0');
}
const isRetryable = retryableCheck;
const isRetryable = retryableCheck ?? defaultRetryableCheck;
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
-10
View File
@@ -19,8 +19,6 @@ import {
isDashboardWebSocketUpgradeAllowed,
} from './middleware/auth-middleware';
import { requestLoggingMiddleware } from './middleware/request-logging-middleware';
import { ensureManagedModelPrefixes } from '../cliproxy/ai-providers/managed-model-prefixes';
import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
import { shutdownUsageAggregator } from './usage/aggregator';
import { createLogger } from '../services/logging';
@@ -172,14 +170,6 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Start auto-sync watcher (if enabled in config)
startAutoSyncWatcher();
if (!getProxyTarget().isRemote) {
void ensureManagedModelPrefixes().catch((error) => {
logger.warn('cliproxy.prefix_sync_failed', 'Managed model prefix repair failed', {
error: error instanceof Error ? error.message : String(error),
});
});
}
// Combined cleanup function
const cleanup = () => {
wsCleanup();
+30 -4
View File
@@ -246,10 +246,36 @@ export function requireLocalAccessWhenAuthDisabled(
return true;
}
if (isLoopbackRemoteAddress(req.socket.remoteAddress)) {
return true;
if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
res.status(403).json({ error });
return false;
}
res.status(403).json({ error });
return false;
const host = parseHostHeader(getSingleHeader(req.headers.host));
if (!host || !isLoopbackHostname(host.hostname)) {
res.status(403).json({ error });
return false;
}
const originHeader = getSingleHeader(req.headers.origin);
if (originHeader) {
let origin: URL;
try {
origin = new URL(originHeader);
} catch {
res.status(403).json({ error });
return false;
}
const isSameHost = origin.host.toLowerCase() === host.host.toLowerCase();
const isLoopbackAlias =
isHttpOrigin(origin) && isLoopbackHostname(origin.hostname) && origin.port === host.port;
if (!isSameHost && !isLoopbackAlias) {
res.status(403).json({ error });
return false;
}
}
return true;
}
+100 -4
View File
@@ -17,13 +17,23 @@ import {
// TYPE DEFINITIONS
// ============================================================================
export interface ModelPricing {
export interface PricingRates {
inputPerMillion: number;
outputPerMillion: number;
cacheCreationPerMillion: number;
cacheReadPerMillion: number;
}
export interface ModelPricing extends PricingRates {
/**
* Optional per-service-tier rate overrides keyed by Anthropic's
* `service_tier` request parameter (e.g. `'fast'`). When the lookup is
* called with a known tier, these rates replace the base ones; otherwise
* the base rates apply.
*/
serviceTiers?: Record<string, PricingRates>;
}
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
@@ -31,7 +41,41 @@ export interface TokenUsage {
cacheReadTokens: number;
}
export type PricingLookupOptions = ModelsDevPricingLookupOptions;
export interface PricingLookupOptions extends ModelsDevPricingLookupOptions {
/**
* Anthropic `service_tier` (e.g. `'fast'`). When set and the resolved model
* has matching `serviceTiers` rates, those are returned instead of the
* base rates. Unknown tiers transparently fall through to base.
*/
serviceTier?: string;
}
// Anthropic prompt-caching multipliers, expressed relative to the base input
// rate (see https://platform.claude.com/docs/en/about-claude/pricing#prompt-caching).
// Keeping them as named constants avoids drift when deriving per-tier cache rates.
const CACHE_5M_WRITE_MULTIPLIER = 1.25;
const CACHE_READ_MULTIPLIER = 0.1;
/**
* Build a full rate set from input/output rates, deriving cache rates from the
* documented Anthropic multipliers so fast-tier entries stay in sync with the
* base-rate math instead of carrying hand-computed cache numbers.
*/
function buildRates(inputPerMillion: number, outputPerMillion: number): PricingRates {
return {
inputPerMillion,
outputPerMillion,
cacheCreationPerMillion: inputPerMillion * CACHE_5M_WRITE_MULTIPLIER,
cacheReadPerMillion: inputPerMillion * CACHE_READ_MULTIPLIER,
};
}
// Anthropic fast-mode premiums (per
// https://platform.claude.com/docs/en/about-claude/pricing#fast-mode-pricing).
// Opus 4.6 and 4.7 share the same 6x premium; 4.8 is 2x. Shared constants keep
// the registry entries in sync rather than repeating the literal rates.
const OPUS_46_47_FAST_RATES = buildRates(30.0, 150.0);
const OPUS_48_FAST_RATES = buildRates(10.0, 50.0);
// ============================================================================
// USER-EDITABLE PRICING TABLE
@@ -223,25 +267,54 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5,
},
// Claude 4.6 Opus ($5/$25)
// Claude 4.6 Opus ($5/$25) — fast mode ($30/$150, 6x premium per Anthropic docs)
'claude-opus-4-6': {
inputPerMillion: 5.0,
outputPerMillion: 25.0,
cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5,
serviceTiers: {
fast: OPUS_46_47_FAST_RATES,
},
},
'claude-opus-4-6-thinking': {
inputPerMillion: 5.0,
outputPerMillion: 25.0,
cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5,
serviceTiers: {
fast: OPUS_46_47_FAST_RATES,
},
},
// Claude 4.7 Opus ($5/$25)
// Claude 4.7 Opus ($5/$25) — fast mode ($30/$150, 6x premium per Anthropic docs)
'claude-opus-4-7': {
inputPerMillion: 5.0,
outputPerMillion: 25.0,
cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5,
serviceTiers: {
fast: OPUS_46_47_FAST_RATES,
},
},
// Legacy pricing-only entry for historical analytics data; this id has no
// catalog model (Opus 4.7 moved to adaptive thinking levels in 84dc4e24, so
// there is no separate -thinking variant). Kept so older usage records still
// resolve to the correct rate. No fast tier: the id is never requested live.
'claude-opus-4-7-thinking': {
inputPerMillion: 5.0,
outputPerMillion: 25.0,
cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5,
},
// Claude 4.8 Opus ($5/$25) — fast mode ($10/$50, 2x premium per Anthropic docs)
'claude-opus-4-8': {
inputPerMillion: 5.0,
outputPerMillion: 25.0,
cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5,
serviceTiers: {
fast: OPUS_48_FAST_RATES,
},
},
// ---------------------------------------------------------------------------
@@ -886,12 +959,35 @@ function hasProviderContext(model: string, options: PricingLookupOptions): boole
return Boolean(options.provider || /^[^/]+\//.test(model.trim()));
}
/**
* Apply per-service-tier rates if the resolved model declares them and the
* caller requested a matching tier. Unknown tiers transparently fall through
* to the base rates so existing callers stay unaffected.
*
* TODO(opus-fast-mode): No production caller currently passes `serviceTier`.
* Anthropic's `service_tier` is not yet captured on CliproxyRequestDetail, so
* usage transformers (cliproxy-usage-transformer.ts, data-aggregator.ts) bill
* fast-mode requests at the standard rate i.e. fast Opus is under-reported by
* the tier premium ($10/$50 vs $5/$25). Wire `serviceTier` through once the
* usage pipeline records it. The schema below is ready for that integration.
*/
function applyServiceTier(pricing: ModelPricing, tier: string | undefined): ModelPricing {
if (!tier) return pricing;
const tierRates = pricing.serviceTiers?.[tier];
if (!tierRates) return pricing;
return { ...tierRates, serviceTiers: pricing.serviceTiers };
}
/**
* Get pricing for a model with narrow fuzzy matching fallback.
* Unknown future model families should fall back instead of inheriting the
* first known family tier that happens to share a prefix.
*/
export function getModelPricing(model: string, options: PricingLookupOptions = {}): ModelPricing {
return applyServiceTier(resolveBasePricing(model, options), options.serviceTier);
}
function resolveBasePricing(model: string, options: PricingLookupOptions): ModelPricing {
if (hasProviderContext(model, options)) {
const ccsOverridePricing = getCcsPolicyOverridePricing(model);
if (ccsOverridePricing !== undefined) {
+14 -14
View File
@@ -18,19 +18,19 @@ export interface ModelsDevPricingLookupOptions {
provider?: string;
}
const PROVIDER_ALIASES: Record<string, string> = {
agy: 'google',
antigravity: 'google',
claude: 'anthropic',
codex: 'openai',
copilot: 'github-copilot',
gemini: 'google',
ghcp: 'github-copilot',
github: 'github-copilot',
kimi: 'moonshotai',
moonshot: 'moonshotai',
qwen: 'alibaba',
};
const PROVIDER_ALIASES = new Map<string, string>([
['agy', 'google'],
['antigravity', 'google'],
['claude', 'anthropic'],
['codex', 'openai'],
['copilot', 'github-copilot'],
['gemini', 'google'],
['ghcp', 'github-copilot'],
['github', 'github-copilot'],
['kimi', 'moonshotai'],
['moonshot', 'moonshotai'],
['qwen', 'alibaba'],
]);
function normalizeId(value: string): string {
return value.trim().toLowerCase();
@@ -45,7 +45,7 @@ export function normalizeModelsDevProviderId(
): string | undefined {
if (!provider) return undefined;
const normalized = normalizeId(provider);
return PROVIDER_ALIASES[normalized] ?? normalized;
return PROVIDER_ALIASES.get(normalized) ?? normalized;
}
function splitProviderPrefix(model: string): { provider?: string; model: string } {

Some files were not shown because too many files have changed in this diff Show More