mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 18:16:29 +00:00
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:
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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" | \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)}`));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const RESERVED_PROFILE_NAMES = [
|
||||
'gitlab',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
'qoder',
|
||||
// Copilot API (GitHub Copilot proxy)
|
||||
'copilot',
|
||||
// Cursor IDE (Cursor proxy daemon)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
export interface CursorDaemonConfig {
|
||||
port: number;
|
||||
ghost_mode?: boolean;
|
||||
daemon_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user