Merge remote-tracking branch 'origin/dev' into codex/fix-logs-filtering-to-show-dashboard-audits

This commit is contained in:
Tam Nhu Tran
2026-05-30 15:57:03 -04:00
156 changed files with 4169 additions and 625 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

+4
View File
@@ -48,6 +48,7 @@ jobs:
with:
app-id: ${{ secrets.CCS_REVIEWER_APP_ID }}
private-key: ${{ secrets.CCS_REVIEWER_PRIVATE_KEY }}
permission-issues: write
- name: Post /review command to the PR
uses: actions/github-script@v7
@@ -111,6 +112,9 @@ jobs:
with:
app-id: ${{ secrets.CCS_REVIEWER_APP_ID }}
private-key: ${{ secrets.CCS_REVIEWER_PRIVATE_KEY }}
permission-issues: write
permission-pull-requests: write
permission-contents: read
- name: Run PR-Agent
uses: qodo-ai/pr-agent@v0.34
+36 -4
View File
@@ -72,7 +72,7 @@ jobs:
if: steps.tag.outputs.publish == 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.target.outputs.tag }}
ref: ${{ format('refs/tags/{0}', steps.target.outputs.tag) }}
fetch-depth: 0
persist-credentials: false
@@ -207,7 +207,7 @@ jobs:
if: steps.tag.outputs.publish == 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.target.outputs.tag }}
ref: ${{ format('refs/tags/{0}', steps.target.outputs.tag) }}
persist-credentials: false
- name: Set up QEMU
@@ -260,7 +260,7 @@ jobs:
id: build
uses: docker/build-push-action@v6
with:
context: .
context: docker
file: docker/Dockerfile.integrated
platforms: linux/amd64,linux/arm64
push: true
@@ -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
@@ -332,6 +332,17 @@ jobs:
- name: Pull image
run: docker pull "${{ steps.image.outputs.ref }}"
- name: Verify anonymous pull access
run: |
CLEAN_DOCKER_CONFIG="$(mktemp -d)"
trap 'rm -rf "${CLEAN_DOCKER_CONFIG}"' EXIT
if ! DOCKER_CONFIG="${CLEAN_DOCKER_CONFIG}" docker pull "${{ steps.image.outputs.ref }}"; then
echo "[X] ghcr.io/kaitranntt/ccs is not anonymously pullable." >&2
echo "[i] Make the GHCR package public, then rerun the Docker publish workflow." >&2
exit 1
fi
echo "[OK] Anonymous pull succeeded for ${{ steps.image.outputs.ref }}"
- name: Assert image size budget (amd64)
run: |
chmod +x tests/docker/image-size.sh
@@ -471,3 +482,24 @@ jobs:
"${IMAGE_REF}"
echo "[OK] Promoted: :latest :${MINOR} :${MAJOR} → ${IMAGE_REF}"
- name: Verify promoted tags are anonymously pullable
env:
VERSION: ${{ needs.publish-integrated.outputs.version }}
run: |
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
IMAGE="ghcr.io/${OWNER_LOWER}/ccs"
MINOR="${VERSION%.*}"
MAJOR="${VERSION%%.*}"
CLEAN_DOCKER_CONFIG="$(mktemp -d)"
trap 'rm -rf "${CLEAN_DOCKER_CONFIG}"' EXIT
for TAG in latest "${MINOR}" "${MAJOR}"; do
REF="${IMAGE}:${TAG}"
if ! DOCKER_CONFIG="${CLEAN_DOCKER_CONFIG}" docker pull "${REF}"; then
echo "[X] ${REF} is not anonymously pullable." >&2
echo "[i] Confirm the GHCR package is public, then rerun promotion." >&2
exit 1
fi
echo "[OK] Anonymous pull succeeded for ${REF}"
done
+1 -71
View File
@@ -102,74 +102,4 @@ jobs:
if: success() && steps.release.outputs.released == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(jq -r '.version' package.json)
RELEASE_BODY=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null || echo "")
PREVIOUS_STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --sort=-v:refname | \
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
grep -vx "v${VERSION}" | head -1 || echo "")
RANGE="${PREVIOUS_STABLE_TAG:+${PREVIOUS_STABLE_TAG}..HEAD~1}"
if [[ -z "$RANGE" ]]; then
RANGE="HEAD~50..HEAD~1"
fi
COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true)
PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \
grep -oE "Merge pull request #[0-9]+|\\(#[0-9]+\\)" | \
grep -oE "[0-9]+" | sort -u || true)
PR_TEXT=""
for PR_NUM in $PR_CANDIDATES; do
DETAILS=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json title,body --jq '.title + "\n" + (.body // "")' 2>/dev/null || true)
if [[ -n "$DETAILS" ]]; then
PR_TEXT="${PR_TEXT}"$'\n'"${DETAILS}"
fi
done
RELEASE_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RELEASE_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RELEASE_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RELEASE_ISSUES=$(printf '%s\n%s\n%s\n' \
"$RELEASE_ISSUES_FROM_BODY" \
"$RELEASE_ISSUES_FROM_COMMITS" \
"$RELEASE_ISSUES_FROM_PRS" | sort -u || true)
RESOLVED_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RESOLVED_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RESOLVED_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RESOLVED_ISSUES=$(printf '%s\n%s\n%s\n' \
"$RESOLVED_ISSUES_FROM_BODY" \
"$RESOLVED_ISSUES_FROM_COMMITS" \
"$RESOLVED_ISSUES_FROM_PRS" | sort -u || true)
if [[ -z "$RELEASE_ISSUES" ]]; then
echo "No release-scoped issues found for v${VERSION}"
exit 0
fi
for NUM in $RELEASE_ISSUES; do
echo "Cleaning release state on issue #$NUM"
gh issue edit "$NUM" \
--remove-label "released-dev" \
--remove-label "pending-release" \
--repo "${{ github.repository }}" 2>/dev/null || true
HAS_RELEASED=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name | select(. == "released")] | length' 2>/dev/null || echo "0")
if [[ "$HAS_RELEASED" -gt 0 ]] && printf '%s\n' "$RESOLVED_ISSUES" | grep -qx "$NUM"; then
gh issue close "$NUM" \
--comment "[bot] Closing issue because this fix/feature is now in stable release (@latest)." \
--repo "${{ github.repository }}" || true
fi
done
run: node scripts/github/stable-release-issue-cleanup.mjs
+24
View File
@@ -1,3 +1,27 @@
## [8.1.4](https://github.com/kaitranntt/ccs/compare/v8.1.3...v8.1.4) (2026-05-29)
### Hotfixes
* avoid Docker smoke port collisions ([#1412](https://github.com/kaitranntt/ccs/issues/1412)) ([c3d8dcb](https://github.com/kaitranntt/ccs/commit/c3d8dcbdd929e29b529c40afc965fea14af15ea1))
## [8.1.3](https://github.com/kaitranntt/ccs/compare/v8.1.2...v8.1.3) (2026-05-29)
### Hotfixes
* add integrated Docker healthcheck ([0e3383d](https://github.com/kaitranntt/ccs/commit/0e3383d31da6c8c882760a86890c0dd915af50db)), closes [#1400](https://github.com/kaitranntt/ccs/issues/1400)
## [8.1.2](https://github.com/kaitranntt/ccs/compare/v8.1.1...v8.1.2) (2026-05-29)
### Hotfixes
* repair Docker image size inspection ([731d43e](https://github.com/kaitranntt/ccs/commit/731d43ec9a331fa34b77c9abc18d4608dde27252)), closes [#1400](https://github.com/kaitranntt/ccs/issues/1400)
## [8.1.1](https://github.com/kaitranntt/ccs/compare/v8.1.0...v8.1.1) (2026-05-29)
### Hotfixes
* repair Docker release publishing ([d49bbda](https://github.com/kaitranntt/ccs/commit/d49bbdaec3a1b391a819653f15985b3b48fdd5d2)), closes [#1400](https://github.com/kaitranntt/ccs/issues/1400)
## [8.1.0](https://github.com/kaitranntt/ccs/compare/v8.0.0...v8.1.0) (2026-05-23)
### Features
+4 -4
View File
@@ -2,9 +2,9 @@
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kiro",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "kiro",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kiro",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kiro",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kiro"
"ANTHROPIC_MODEL": "kiro-claude-sonnet-4-6",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kiro-claude-opus-4-7",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kiro-claude-sonnet-4-6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kiro-claude-haiku-4-5"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/qoder",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "qoder/auto",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "qoder/ultimate",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "qoder/performance",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "qoder/efficient"
}
}
+8 -15
View File
@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1
# syntax=docker/dockerfile:1.7
# =============================================================================
# Build stage: compile TypeScript and build UI
@@ -36,13 +36,10 @@ RUN bun run build:all
# Validate build artifacts exist
RUN test -d dist && test -d lib && echo "[OK] Build artifacts validated"
# Generate a fresh npm lockfile from package.json immediately after build.
# This lockfile is ephemeral — never committed; bun.lock remains the dev lockfile.
# --ignore-scripts: only lockfile generation, no install side-effects needed here.
# postinstall (scripts/postinstall.js) is a user-facing setup script; it must NOT
# run during the Docker build (it writes to ~/.ccs/ which does not exist here).
RUN --mount=type=cache,target=/root/.npm \
npm install --package-lock-only --ignore-scripts
# Build runtime deps from the committed bun.lock to keep Docker installs pinned.
# --ignore-scripts avoids user-home side effects from postinstall during image build.
RUN --mount=type=cache,target=/root/.bun \
bun install --frozen-lockfile --production --ignore-scripts
# =============================================================================
# Runtime stage: Node-only, no Bun
@@ -60,13 +57,9 @@ RUN apt-get update \
WORKDIR /app
# Copy the ephemeral lockfile generated in the build stage (never committed).
# --ignore-scripts: postinstall writes ~/.ccs/ config which is not needed inside
# the Docker image; runtime deps (bcrypt v6+, express, etc.) have no native
# compilation steps requiring postinstall.
COPY --from=build /app/package.json /app/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
# Copy pinned production dependencies resolved from the committed bun.lock.
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY docker/entrypoint.sh /usr/local/bin/ccs-entrypoint
RUN chmod +x /usr/local/bin/ccs-entrypoint
+3
View File
@@ -45,4 +45,7 @@ RUN chmod +x /entrypoint-integrated.sh \
EXPOSE 3000 8085 8317
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "const http=require('http');const probe=(url)=>new Promise((resolve)=>{const req=http.get(url,(res)=>{res.resume();resolve(res.statusCode<400);});req.on('error',()=>resolve(false));req.setTimeout(4500,()=>{req.destroy();resolve(false);});});Promise.all([probe('http://127.0.0.1:3000/'),probe('http://127.0.0.1:8317/')]).then((results)=>process.exit(results.every(Boolean)?0:1));"
ENTRYPOINT ["/entrypoint-integrated.sh"]
+18 -10
View File
@@ -113,10 +113,11 @@ environment:
Running `ccs config auth setup` on the outer host shell updates that machine's own `~/.ccs`, not the Docker volume mounted into `ccs-cliproxy`. For the integrated stack, configure auth inside the container or provide the auth env vars in Compose.
Generate a bcrypt hash:
Generate a bcrypt hash without putting the password in shell history or process arguments:
```bash
docker exec ccs-cliproxy node -e "console.log(require('bcrypt').hashSync('your-password', 10))"
docker exec -i ccs-cliproxy node -e "const fs=require('fs'); const bcrypt=require('bcrypt'); const password=fs.readFileSync(0,'utf8').trimEnd(); console.log(bcrypt.hashSync(password, 10));"
# then type/paste the password followed by Enter (stdin is not exposed via argv)
```
> **Note:** Do not commit the password hash in `docker-compose.yml`. Use Docker secrets or a `.env` file (not tracked in git) for sensitive values like `CCS_DASHBOARD_PASSWORD_HASH`.
@@ -160,15 +161,20 @@ docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy
For remote deployments via `ccs docker up --host`:
```bash
# Copy tokens into the running container (no root/sudo needed)
scp /path/to/auth/*.json my-server:/tmp/ccs-auth/
ssh my-server 'for f in /tmp/ccs-auth/*.json; do docker cp "$f" ccs-cliproxy:/root/.ccs/cliproxy/auth/; done'
# Create a private staging directory (0700) and print its path
STAGE_DIR=$(ssh my-server 'umask 077 && mktemp -d "${HOME}/.ccs-auth.XXXXXX"')
# Copy only JSON token files into the private staging directory
scp /path/to/auth/*.json "my-server:${STAGE_DIR}/"
# Restrict file permissions and import each staged token into the container
ssh my-server "chmod 600 \"${STAGE_DIR}\"/*.json && for f in \"${STAGE_DIR}\"/*.json; do docker cp \"\$f\" ccs-cliproxy:/root/.ccs/cliproxy/auth/; done"
# Restart CLIProxy to load new tokens
ssh my-server "docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy"
# Clean up temp files
ssh my-server "rm -rf /tmp/ccs-auth"
# Clean up private staging files
ssh my-server "rm -rf \"${STAGE_DIR}\""
```
> **Tip:** `docker cp` is preferred over writing directly to Docker volume mountpoints, which require root access.
@@ -191,10 +197,12 @@ docker exec ccs-cliproxy curl -fsS http://127.0.0.1:3000/api/health \
# 4. Verify auth tokens loaded (check client count)
docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log
# 5. Test dashboard API (from remote -- requires auth)
curl -fsS -X POST http://<host>:3000/api/auth/login \
# 5. Test dashboard API (from remote -- requires auth + HTTPS)
read -r -s CCS_DASHBOARD_PASSWORD && echo
curl -fsS -X POST https://<host>:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"your-password"}'
-d "{\"username\":\"admin\",\"password\":\"${CCS_DASHBOARD_PASSWORD}\"}"
unset CCS_DASHBOARD_PASSWORD
```
Expected healthy output:
+2 -2
View File
@@ -25,8 +25,8 @@ services:
CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}"
CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}"
ports:
- "3000:3000"
- "8317:8317"
- "${CCS_DASHBOARD_PORT:-3000}:3000"
- "${CCS_CLIPROXY_PORT:-8317}:8317"
volumes:
# /root/.ccs matches the HOME used inside the integrated image.
# entrypoint-integrated.sh runs as root (supervisord user=root) and
+24 -13
View File
@@ -159,6 +159,8 @@ const DEFAULT_WAIT_TIMEOUT_MS = 2000;
const DEFAULT_WAIT_POLL_INTERVAL_MS = 100;
const DEFAULT_DRAG_STEPS = 5;
const MAX_POINTER_ACTIONS = 25;
const MAX_CLICK_COUNT = 25;
const MAX_KEY_REPEAT = 25;
const SESSION_START_SETTLE_WINDOW_MS = 250;
const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024;
const MAX_LOCAL_TRANSFER_FILE_BYTES = 10 * 1024 * 1024;
@@ -454,6 +456,7 @@ function getTools() {
clickCount: {
type: 'integer',
minimum: 1,
maximum: MAX_CLICK_COUNT,
description: 'Optional click count. Defaults to 1.',
},
},
@@ -519,6 +522,7 @@ function getTools() {
repeat: {
type: 'integer',
minimum: 1,
maximum: MAX_KEY_REPEAT,
description: 'Optional repeat count. Defaults to 1.',
},
},
@@ -2305,10 +2309,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 +3562,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 +3812,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 +5106,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(() => {})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "8.1.0",
"version": "8.1.4-dev.4",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
+5 -3
View File
@@ -97,9 +97,11 @@ function evaluateDashboardSunset({ targetTag, baselineVersion, releaseWindow, st
const versions = parseStableTags(stableTags);
const hasBaseline = versions.some((version) => compareVersions(version, baseline) === 0);
if (compareVersions(target, baseline) > 0 && !hasBaseline) {
throw new Error(
`Cannot count dashboard sunset releases: baseline tag ${baseline.raw} is missing from git tags`,
);
return {
publish: false,
elapsed: releaseWindow,
reason: `legacy dashboard sunset baseline ${baseline.raw} is missing from git tags; skipping deprecated image publish`,
};
}
if (!versions.some((version) => compareVersions(version, target) === 0)) {
@@ -0,0 +1,113 @@
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(.*)/gi;
const RESOLVE_VERB_PATTERN = /\b(fixes|closes|resolves)\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[2] || '';
let issueMatch;
ISSUE_REF_PATTERN.lastIndex = 0;
while ((issueMatch = ISSUE_REF_PATTERN.exec(tail)) !== null) {
issues.add(Number(issueMatch[1]));
}
}
return [...issues].sort((a, b) => a - b);
}
export function extractPrNumbers(text) {
const prs = new Set();
let match;
PR_REF_PATTERN.lastIndex = 0;
while ((match = PR_REF_PATTERN.exec(text || '')) !== null) {
prs.add(Number(match[1]));
}
return [...prs].sort((a, b) => a - b);
}
export function planIssueCleanup({ releaseIssues, resolvedIssues, issueStates }) {
const resolved = new Set(resolvedIssues);
return releaseIssues.map((number) => {
const state = issueStates.get(number) || { labels: [], state: 'UNKNOWN' };
const labels = new Set(state.labels);
const wasReleasedDev = labels.has('released-dev');
const shouldClose = state.state === 'OPEN' && (wasReleasedDev || resolved.has(number));
return {
number,
removeLabels: ['released-dev', 'pending-release'],
addReleasedLabel: shouldClose,
close: shouldClose,
reason: wasReleasedDev ? 'promoted from dev to stable' : 'resolved by stable release',
};
});
}
export function getStableReleaseContext({ env = process.env, exec = runCommand } = {}) {
const repo = env.GITHUB_REPOSITORY;
if (!repo) throw new Error('GITHUB_REPOSITORY is required');
const version = JSON.parse(readFileSync('package.json', 'utf8')).version;
const currentTag = `v${version}`;
const releaseBody = exec('gh', [
'release',
'view',
currentTag,
'--repo',
repo,
'--json',
'body',
'--jq',
'.body',
]);
const tags = exec('git', ['tag', '-l', 'v[0-9]*.[0-9]*.[0-9]*', '--sort=-v:refname'])
.split('\n')
.map((tag) => tag.trim())
.filter((tag) => STABLE_TAG_PATTERN.test(tag) && tag !== currentTag);
const previousStableTag = tags[0] || '';
const range = previousStableTag ? `${previousStableTag}..HEAD~1` : 'HEAD~50..HEAD~1';
const commitText = exec('git', ['log', range, '--pretty=format:%s%n%b'], { optional: true });
return { repo, version, currentTag, releaseBody, range, commitText };
}
export function buildReleaseIssueSet({ releaseBody, commitText, prText }) {
const releaseIssues = new Set([
...extractIssueNumbers(releaseBody, { includeRefs: true }),
...extractIssueNumbers(commitText, { includeRefs: true }),
...extractIssueNumbers(prText, { includeRefs: true }),
]);
const resolvedIssues = new Set([
...extractIssueNumbers(releaseBody, { includeRefs: false }),
...extractIssueNumbers(commitText, { includeRefs: false }),
...extractIssueNumbers(prText, { includeRefs: false }),
]);
return {
releaseIssues: [...releaseIssues].sort((a, b) => a - b),
resolvedIssues: [...resolvedIssues].sort((a, b) => a - b),
};
}
export function runCommand(command, args, { optional = false } = {}) {
const result = spawnSync(command, args, { encoding: 'utf8' });
if (result.status !== 0) {
if (optional) return '';
throw new Error(
`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`
);
}
return result.stdout.trim();
}
@@ -0,0 +1,134 @@
import {
buildReleaseIssueSet,
extractPrNumbers,
getStableReleaseContext,
planIssueCleanup,
runCommand,
} from './stable-release-issue-cleanup-lib.mjs';
function gh(args, options) {
return runCommand('gh', args, options);
}
function fetchPrText(repo, commitText) {
let prText = '';
for (const prNumber of extractPrNumbers(commitText)) {
const details = gh(
[
'pr',
'view',
String(prNumber),
'--repo',
repo,
'--json',
'title,body',
'--jq',
'.title + "\n" + (.body // "")',
],
{ optional: true }
);
if (details) prText += `\n${details}`;
}
return prText;
}
function fetchIssueStates(repo, issueNumbers) {
const states = new Map();
for (const number of issueNumbers) {
const raw = gh(
[
'issue',
'view',
String(number),
'--repo',
repo,
'--json',
'state,labels',
'--jq',
'{state:.state,labels:[.labels[].name]}',
],
{ optional: true }
);
if (!raw) continue;
states.set(number, JSON.parse(raw));
}
return states;
}
function ensureReleasedLabel(repo) {
gh(
[
'label',
'create',
'released',
'--color',
'ededed',
'--description',
'Fix available in stable npm channel',
'--repo',
repo,
],
{ optional: true }
);
}
function applyAction(repo, action) {
console.log(`Cleaning release state on issue #${action.number}`);
for (const label of action.removeLabels) {
gh(['issue', 'edit', String(action.number), '--remove-label', label, '--repo', repo], {
optional: true,
});
}
if (!action.close) return;
gh(['issue', 'edit', String(action.number), '--add-label', 'released', '--repo', repo], {
optional: true,
});
gh(
[
'issue',
'close',
String(action.number),
'--comment',
'[bot] Closing issue because this fix/feature is now in stable release (@latest).',
'--repo',
repo,
],
{ optional: true }
);
console.log(`Closed issue #${action.number}: ${action.reason}`);
}
export function main() {
const context = getStableReleaseContext();
console.log(`Checking stable release issue lifecycle for ${context.currentTag}`);
console.log(`Checking commits in range: ${context.range}`);
const prText = fetchPrText(context.repo, context.commitText);
const { releaseIssues, resolvedIssues } = buildReleaseIssueSet({
releaseBody: context.releaseBody,
commitText: context.commitText,
prText,
});
if (releaseIssues.length === 0) {
console.log(`No release-scoped issues found for ${context.currentTag}`);
return;
}
ensureReleasedLabel(context.repo);
const issueStates = fetchIssueStates(context.repo, releaseIssues);
for (const action of planIssueCleanup({ releaseIssues, resolvedIssues, issueStates })) {
applyAction(context.repo, action);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
try {
main();
} catch (error) {
console.error(error);
process.exit(1);
}
}
+1 -1
View File
@@ -809,7 +809,7 @@ export function getOfficialChannelsLegacyEnableHelp(): string {
}
export function getOfficialChannelTokenHelp(): string {
return 'Use --set-token <channel>=<token>. If no channel is provided, Discord is assumed for backward compatibility.';
return 'Use --set-token <channel> and pass the token via that channel env var (for example TELEGRAM_BOT_TOKEN=... ccs config channels --set-token telegram).';
}
export function getOfficialChannelClearTokenHelp(): string {
@@ -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',
]);
});
@@ -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: {
+4
View File
@@ -660,6 +660,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;
}
@@ -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;
@@ -7,6 +7,10 @@ import {
resolveRuntimeCodexFallbackModel,
} from './codex-plan-compatibility';
import { getModelMaxLevel } from '../model-catalog';
import {
attachUpstreamResponseTimeout,
writeForwardResponseHead,
} from '../proxy/upstream-response-timeout';
export type CodexReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type CodexServiceTier = 'fast';
@@ -301,7 +305,7 @@ export class CodexReasoningProxy {
headers: http.IncomingHttpHeaders,
responseBody: string
): void {
clientRes.writeHead(statusCode, headers);
writeForwardResponseHead(clientRes, statusCode, headers);
clientRes.end(responseBody);
}
@@ -635,10 +639,38 @@ export class CodexReasoningProxy {
),
(upstreamRes) => {
clearResponseTimeout();
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
upstreamRes.pipe(clientRes);
upstreamRes.on('end', () => resolve());
upstreamRes.on('error', reject);
const statusCode = upstreamRes.statusCode || 200;
let responseStarted = false;
const writeResponseHead = () => {
if (responseStarted) return;
responseStarted = true;
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
};
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
upstreamReq,
upstreamRes,
clientRes,
timeoutMs: this.config.timeoutMs,
onTimeout: () => resolve(),
});
upstreamRes.on('data', (chunk: Buffer) => {
writeResponseHead();
const canContinue = clientRes.write(chunk);
if (!canContinue) {
upstreamRes.pause();
clientRes.once('drain', () => upstreamRes.resume());
}
});
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
writeResponseHead();
clientRes.end();
resolve();
});
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
}
);
@@ -672,17 +704,60 @@ export class CodexReasoningProxy {
(upstreamRes) => {
clearResponseTimeout();
const statusCode = upstreamRes.statusCode || 200;
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
upstreamReq,
upstreamRes,
clientRes,
timeoutMs: this.config.timeoutMs,
onTimeout: () => resolve(504),
});
if (statusCode >= 200 && statusCode < 300) {
clientRes.writeHead(statusCode, upstreamRes.headers);
upstreamRes.pipe(clientRes);
upstreamRes.on('end', () => resolve(statusCode));
upstreamRes.on('error', reject);
let responseStarted = false;
const writeResponseHead = () => {
if (responseStarted) return;
responseStarted = true;
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
};
upstreamRes.on('data', (chunk: Buffer) => {
writeResponseHead();
const canContinue = clientRes.write(chunk);
if (!canContinue) {
upstreamRes.pause();
clientRes.once('drain', () => upstreamRes.resume());
}
});
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
writeResponseHead();
clientRes.end();
resolve(statusCode);
});
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
return;
}
const maxErrorResponseSize = 10 * 1024 * 1024; // 10MB
let totalResponseBytes = 0;
let responseTooLarge = false;
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
upstreamRes.on('data', (chunk: Buffer) => {
totalResponseBytes += chunk.length;
if (totalResponseBytes > maxErrorResponseSize) {
responseTooLarge = true;
upstreamRes.destroy(new Error('Upstream error response exceeded 10MB limit'));
return;
}
chunks.push(chunk);
});
upstreamRes.on('end', async () => {
clearUpstreamResponseTimeout();
if (responseTooLarge) {
reject(new Error('Upstream error response exceeded 10MB limit'));
return;
}
try {
const responseBody = Buffer.concat(chunks).toString('utf8');
const unsupportedError =
@@ -741,7 +816,10 @@ export class CodexReasoningProxy {
reject(error);
}
});
upstreamRes.on('error', reject);
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
}
);
@@ -306,7 +306,11 @@ describe('handlePasteCallbackMode traceability', () => {
},
{
url: /\/v0\/management\/oauth-callback$/,
response: { status: 'error', error: 'invalid_grant: expired code' },
response: {
status: 'error',
error:
'invalid_grant: bad redirect http://localhost:1455/callback?code=oauth-code-secret&state=upstream-state-secret',
},
status: 400,
},
]);
@@ -120,9 +120,29 @@ describe('createOAuthTraceRecorder', () => {
expect(snap[0].error).toEqual({ code: 'E1', message: 'boom' });
});
test('Error instance accepted', () => {
test('redacts OAuth secrets from error messages before they reach sinks', () => {
const lines: string[] = [];
const { rec } = makeRecorder(true, lines);
rec.record(OAuthTracePhase.Error, undefined, {
code: 'CALLBACK_REJECTED',
message:
'bad redirect http://localhost:1455/callback?code=AUTHCODE_SECRET&state=STATE_SECRET',
});
const blob = JSON.stringify(rec.snapshot()) + '\n' + lines.join('\n');
expect(blob).not.toContain('AUTHCODE_SECRET');
expect(blob).not.toContain('STATE_SECRET');
expect(blob).toContain(REDACTED_PLACEHOLDER);
});
test('Error instance accepted and redacted', () => {
const { rec } = makeRecorder();
rec.record(OAuthTracePhase.Error, undefined, new Error('plain'));
expect(rec.snapshot()[0].error?.message).toBe('plain');
rec.record(
OAuthTracePhase.Error,
undefined,
new Error('bad redirect http://localhost:1455/callback?code=AUTHCODE_SECRET')
);
expect(rec.snapshot()[0].error?.message).toContain(REDACTED_PLACEHOLDER);
expect(rec.snapshot()[0].error?.message).not.toContain('AUTHCODE_SECRET');
});
});
@@ -85,6 +85,13 @@ describe('redactString', () => {
expect(out).not.toContain('AT_SECRET');
expect(out).toBe(`access_token=${REDACTED_PLACEHOLDER}&keep=1`);
});
test('redacts ampersand-delimited generic token query params', () => {
const out = redactString('access_token=AT_SECRET&token=TOKEN_SECRET');
expect(out).not.toContain('AT_SECRET');
expect(out).not.toContain('TOKEN_SECRET');
expect(out).toBe(`access_token=${REDACTED_PLACEHOLDER}&token=${REDACTED_PLACEHOLDER}`);
});
});
describe('redactUrl', () => {
+9
View File
@@ -306,6 +306,13 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
scopes: [],
authFlag: '--kilo-login',
},
qoder: {
provider: 'qoder',
displayName: 'Qoder',
authUrl: 'https://qoder.com/device/selectAccounts',
scopes: [],
authFlag: '--qoder-login',
},
};
/**
@@ -389,6 +396,8 @@ export interface OAuthOptions {
account?: string;
add?: boolean;
nickname?: string;
/** Existing account id to update during reauthentication. */
expectedAccountId?: string;
/** If true, caller explicitly accepts Antigravity OAuth risk for this command/session. */
acceptAgyRisk?: boolean;
/** Kiro auth method override (CLI + Dashboard parity). */
+20 -11
View File
@@ -82,6 +82,7 @@ import { generateSessionId } from './project-selection-handler';
import { createFileSink } from './oauth-trace/sink-file';
import { createOAuthTraceRecorder, OAuthTracePhase, type OAuthTraceRecorder } from './oauth-trace';
import { diagnoseFailure, formatErrorMessage } from './oauth-trace/diagnose-failure';
import { redactString } from './oauth-trace/redactor';
interface PasteCallbackStartData {
url?: string;
@@ -889,8 +890,9 @@ export async function handlePasteCallbackMode(
if (!callbackResponse.ok || callbackData.status === 'error') {
const callbackError =
callbackData.error || `OAuth callback failed with status ${callbackResponse.status}`;
console.log(fail(callbackError));
warnPossible403Ban(provider, callbackError);
const redactedCallbackError = redactString(callbackError);
console.log(fail(redactedCallbackError));
warnPossible403Ban(provider, redactedCallbackError);
trace.record(
OAuthTracePhase.Error,
{ status: callbackResponse.status },
@@ -915,8 +917,9 @@ export async function handlePasteCallbackMode(
);
if (tokenWaitError) {
console.log(fail(tokenWaitError));
warnPossible403Ban(provider, tokenWaitError);
const redactedTokenWaitError = redactString(tokenWaitError);
console.log(fail(redactedTokenWaitError));
warnPossible403Ban(provider, redactedTokenWaitError);
trace.record(
OAuthTracePhase.Error,
{},
@@ -999,8 +1002,8 @@ async function handleGitLabPatLogin(
const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl);
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
const suppliedToken = options?.gitlabPersonalAccessToken?.trim();
const personalAccessToken =
suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
const envPersonalAccessToken = process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
const personalAccessToken = suppliedToken || envPersonalAccessToken;
let token = personalAccessToken;
if (!token) {
@@ -1015,6 +1018,11 @@ async function handleGitLabPatLogin(
return null;
}
// PAT provided via process env should never be forwarded to downstream runtime.
if (!suppliedToken && envPersonalAccessToken) {
delete process.env['GITLAB_PERSONAL_ACCESS_TOKEN'];
}
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
headers: {
@@ -1126,8 +1134,9 @@ export async function triggerOAuth(
// Check for existing accounts
const existingAccounts = getProviderAccounts(provider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const targetAccountId = options.expectedAccountId || existingNameMatch?.id;
const nicknameError = !fromUI
? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id)
? getCliAuthNicknameError(provider, nickname, existingAccounts, targetAccountId)
: null;
if (nicknameError) {
console.log(fail(nicknameError));
@@ -1139,7 +1148,7 @@ export async function triggerOAuth(
const tokenDir = getProviderTokenDir(provider);
const success = await importKiroToken(verbose);
if (success) {
return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id);
return registerAccountFromToken(provider, tokenDir, nickname, verbose, targetAccountId);
}
return null;
}
@@ -1230,7 +1239,7 @@ export async function triggerOAuth(
verbose,
tokenDir,
nickname,
existingNameMatch?.id,
targetAccountId,
{
gitlabBaseUrl: resolvedGitLabBaseUrl,
gitlabPersonalAccessToken: options.gitlabPersonalAccessToken,
@@ -1246,7 +1255,7 @@ export async function triggerOAuth(
verbose,
tokenDir,
nickname,
existingNameMatch?.id,
targetAccountId,
{
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
@@ -1331,7 +1340,7 @@ export async function triggerOAuth(
verbose,
isCLI,
nickname,
expectedAccountId: existingNameMatch?.id,
expectedAccountId: targetAccountId,
authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code',
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
manualCallback: useSelectedKiroLocalPasteCallback,
@@ -10,6 +10,7 @@
const SENSITIVE_QUERY_KEYS = [
'code',
'state',
'token',
'access_token',
'refresh_token',
'id_token',
@@ -1,7 +1,7 @@
import { OAuthTraceEvent, OAuthTracePhase, OAuthTraceSink } from './trace-events';
import { createMemorySink } from './sink-memory';
import { createVerboseStdoutSink } from './sink-verbose-stdout';
import { redactJsonShallow } from './redactor';
import { redactJsonShallow, redactString } from './redactor';
export interface OAuthTraceRecorder {
record(
@@ -53,9 +53,9 @@ export function createOAuthTraceRecorder(options: OAuthTraceRecorderOptions): OA
): { code?: string; message: string } | undefined {
if (!err) return undefined;
if (err instanceof Error) {
return { message: err.message };
return { message: redactString(err.message) };
}
return { code: err.code, message: err.message };
return { code: err.code, message: redactString(err.message) };
}
return {
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
import { compareCliproxyVersions, isNewerVersion } from '../version-checker';
describe('cliproxy version comparison', () => {
it('treats missing fork release suffix as zero', () => {
expect(compareCliproxyVersions('6.6.81', '6.6.81-0')).toBe(0);
expect(isNewerVersion('6.6.81-0', '6.6.81')).toBe(false);
});
it('orders patched fork release suffixes after core version equality', () => {
expect(compareCliproxyVersions('7.1.31-1', '7.1.31-0')).toBe(1);
expect(compareCliproxyVersions('7.1.31-0', '7.1.31-1')).toBe(-1);
expect(isNewerVersion('7.1.31-1', '7.1.31-0')).toBe(true);
});
it('lets core version precedence win before fork release suffixes', () => {
expect(compareCliproxyVersions('7.1.32-0', '7.1.31-99')).toBe(1);
expect(isNewerVersion('7.1.31-99', '7.1.32-0')).toBe(false);
});
});
+40 -19
View File
@@ -33,28 +33,49 @@ interface FetchAllVersionsDeps {
fetchJsonFn?: typeof fetchJson;
}
interface ParsedCliproxyVersion {
major: number;
minor: number;
patch: number;
forkRelease: number;
}
function parseCliproxyVersion(version: string): ParsedCliproxyVersion {
const normalized = version.trim().replace(/^v/, '');
const [coreVersion, forkReleaseValue = '0'] = normalized.split('-', 2);
const [major = 0, minor = 0, patch = 0] = coreVersion
.split('.')
.map((part) => parseInt(part, 10) || 0);
const forkRelease = /^\d+$/.test(forkReleaseValue) ? parseInt(forkReleaseValue, 10) || 0 : 0;
return { major, minor, patch, forkRelease };
}
function compareVersionPart(a: number, b: number): number {
if (a > b) return 1;
if (a < b) return -1;
return 0;
}
export function compareCliproxyVersions(a: string, b: string): number {
const left = parseCliproxyVersion(a);
const right = parseCliproxyVersion(b);
return (
compareVersionPart(left.major, right.major) ||
compareVersionPart(left.minor, right.minor) ||
compareVersionPart(left.patch, right.patch) ||
compareVersionPart(left.forkRelease, right.forkRelease)
);
}
/**
* Compare semver versions (true if latest > current)
* Handles CLIProxyAPIPlus versioning: strips -0 suffix before comparison
* Compare CLIProxy release versions (true if latest > current).
* Missing fork suffixes are treated as -0, while patched fork suffixes such as
* 7.1.31-1 still sort newer than 7.1.31-0.
*/
export function isNewerVersion(latest: string, current: string): boolean {
// Strip -0 suffix from CLIProxyAPIPlus versions (e.g., "6.6.40-0" -> "6.6.40")
const cleanLatest = latest.replace(/-\d+$/, '');
const cleanCurrent = current.replace(/-\d+$/, '');
const latestParts = cleanLatest.split('.').map((p) => parseInt(p, 10) || 0);
const currentParts = cleanCurrent.split('.').map((p) => parseInt(p, 10) || 0);
// Pad arrays to same length
while (latestParts.length < 3) latestParts.push(0);
while (currentParts.length < 3) currentParts.push(0);
for (let i = 0; i < 3; i++) {
if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false;
}
return false; // Equal versions
return compareCliproxyVersions(latest, current) > 0;
}
/**
@@ -8,6 +8,7 @@ describe('base-config-loader new providers', () => {
['gitlab', '/api/provider/gitlab', 'gitlab-duo'],
['codebuddy', '/api/provider/codebuddy', 'auto'],
['kilo', '/api/provider/kilo', 'kilo/auto'],
['qoder', '/api/provider/qoder', 'qoder/auto'],
] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => {
const config = loadBaseConfig(provider);
+62
View File
@@ -265,6 +265,68 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
},
],
},
qoder: {
provider: 'qoder',
displayName: 'Qoder',
defaultModel: 'qoder/auto',
models: [
{
id: 'qoder/auto',
name: 'Qoder Auto',
description: 'Auto selects the best Qoder model for your prompt',
},
{
id: 'qoder/ultimate',
name: 'Qoder Ultimate',
description: 'Highest quality Qoder tier',
},
{
id: 'qoder/performance',
name: 'Qoder Performance',
description: 'Balanced quality and speed',
},
{
id: 'qoder/efficient',
name: 'Qoder Efficient',
description: 'Cost-efficient Qoder tier',
},
{
id: 'qoder/lite',
name: 'Qoder Lite',
description: 'Fastest and most affordable Qoder tier',
},
{
id: 'qoder/qmodel',
name: 'Qwen 3.6 Plus (via Qoder)',
description: 'Qwen 3.6 Plus frontier model',
},
{
id: 'qoder/dmodel',
name: 'DeepSeek V4 Pro (via Qoder)',
description: 'DeepSeek V4 Pro frontier model',
},
{
id: 'qoder/dfmodel',
name: 'DeepSeek V4 Flash (via Qoder)',
description: 'DeepSeek V4 Flash frontier model',
},
{
id: 'qoder/gm51model',
name: 'GLM 5.1 (via Qoder)',
description: 'GLM 5.1 frontier model',
},
{
id: 'qoder/kmodel',
name: 'Kimi K2.6 (via Qoder)',
description: 'Kimi K2.6 frontier model',
},
{
id: 'qoder/mmodel',
name: 'MiniMax M2.7 (via Qoder)',
description: 'MiniMax M2.7 frontier model',
},
],
},
kimi: {
provider: 'kimi',
displayName: 'Kimi (Moonshot)',
+12
View File
@@ -182,6 +182,18 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
tokenTypeValues: ['kilo'],
aliases: [],
},
qoder: {
displayName: 'Qoder',
description: 'Qoder AI coding assistant',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'qoder',
authUrlProviderName: 'qoder',
refreshOwnership: 'unsupported',
authFilePrefixes: ['qoder-'],
tokenTypeValues: ['qoder'],
aliases: [],
},
};
export const CLIPROXY_PROVIDER_IDS = Object.freeze(
@@ -294,6 +294,33 @@ describe('proxy-config-resolver', () => {
expect(config.managementKey).toBe('remote-management-key');
});
it('should clear YAML management key when CLI overrides remote host', () => {
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host.example.com'], {
remote: {
host: 'yaml-host.example.com',
auth_token: 'remote-auth-token',
management_key: 'remote-management-key',
},
});
expect(config.mode).toBe('remote');
expect(config.host).toBe('cli-host.example.com');
expect(config.managementKey).toBeUndefined();
});
it('should clear YAML management key when ENV overrides auth token', () => {
process.env.CCS_PROXY_AUTH_TOKEN = 'env-auth-token';
const { config } = resolveProxyConfig([], {
remote: {
host: 'yaml-host.example.com',
auth_token: 'remote-auth-token',
management_key: 'remote-management-key',
},
});
expect(config.mode).toBe('remote');
expect(config.authToken).toBe('env-auth-token');
expect(config.managementKey).toBeUndefined();
});
it('should allow CLI --proxy-host to override YAML enabled:false', () => {
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], {
remote: { enabled: false, host: 'yaml-host' },
@@ -436,6 +436,155 @@ describe('ToolSanitizationProxy Integration', () => {
}
});
it('translates Codex fast aliases before forwarding to provider routes', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages?beta=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.5-fast',
messages: [],
}),
});
const sentBody = lastRequest!.body as Record<string, unknown>;
expect(sentBody.model).toBe('gpt-5.5');
expect(sentBody.service_tier).toBe('priority');
} finally {
proxy.stop();
}
});
it('translates Codex effort and fast aliases before forwarding to provider routes', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.5-fast-high',
messages: [],
reasoning: { summary: 'auto' },
}),
});
const sentBody = lastRequest!.body as Record<string, unknown>;
expect(sentBody.model).toBe('gpt-5.5');
expect((sentBody.reasoning as Record<string, unknown>).summary).toBe('auto');
expect((sentBody.reasoning as Record<string, unknown>).effort).toBe('high');
expect(sentBody.service_tier).toBe('priority');
} finally {
proxy.stop();
}
});
it('folds Codex system messages into the first user message before forwarding', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages?beta=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.4',
system: [{ type: 'text', text: 'Always be concise.' }],
messages: [
{ role: 'system', content: 'Use JSON.' },
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
],
}),
});
const sentBody = lastRequest!.body as Record<string, unknown>;
const sentMessages = sentBody.messages as Array<Record<string, unknown>>;
expect(sentBody.system).toBeUndefined();
expect(sentMessages).toHaveLength(1);
expect(sentMessages[0].role).toBe('user');
expect(sentMessages[0].content).toEqual([
{ type: 'text', text: 'Always be concise.\n\nUse JSON.' },
{ type: 'text', text: 'hello' },
]);
} finally {
proxy.stop();
}
});
it('strips blank Codex system messages before forwarding', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.4',
system: ' ',
messages: [
{ role: 'system', content: [{ type: 'text', text: ' ' }] },
{ role: 'user', content: 'hello' },
],
}),
});
const sentBody = lastRequest!.body as Record<string, unknown>;
const sentMessages = sentBody.messages as Array<Record<string, unknown>>;
expect(sentBody.system).toBeUndefined();
expect(sentMessages).toEqual([{ role: 'user', content: 'hello' }]);
} finally {
proxy.stop();
}
});
it('does not apply Codex alias or system rewrites on explicit non-Codex provider routes', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
await fetch(`http://127.0.0.1:${port}/api/provider/gemini/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5.5-fast',
system: 'Keep this as a provider-level system field.',
messages: [
{ role: 'system', content: 'Keep this as a message.' },
{ role: 'user', content: 'hello' },
],
}),
});
const sentBody = lastRequest!.body as Record<string, unknown>;
expect(sentBody.model).toBe('gpt-5.5-fast');
expect(sentBody.service_tier).toBeUndefined();
expect(sentBody.system).toBe('Keep this as a provider-level system field.');
expect(sentBody.messages).toEqual([
{ role: 'system', content: 'Keep this as a message.' },
{ role: 'user', content: 'hello' },
]);
} finally {
proxy.stop();
}
});
for (const model of [
'gpt-5.3-codex-xhigh',
'gpt-5.1-codex-mini',
@@ -825,6 +974,49 @@ describe('ToolSanitizationProxy Integration', () => {
proxy.stop();
}
});
it('returns 504 when raw upstream passthrough stalls after headers', async () => {
const upstream = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.flushHeaders();
});
});
const upstreamPort = await new Promise<number>((resolve, reject) => {
upstream.once('error', reject);
upstream.listen(0, '127.0.0.1', () => {
const address = upstream.address();
if (typeof address !== 'object' || !address) {
reject(new Error('Failed to resolve upstream port'));
return;
}
resolve(address.port);
});
});
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
timeoutMs: 100,
});
const port = await proxy.start();
try {
const response = await fetch(`http://127.0.0.1:${port}/health`);
const text = await Promise.race([
response.text(),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error('Timed out waiting for proxy response')), 2_000)
),
]);
expect(response.status).toBe(504);
expect(text).toContain('Upstream response timed out');
} finally {
proxy.stop();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
});
describe('Multiple Tools Sanitization', () => {
@@ -1086,5 +1278,65 @@ describe('ToolSanitizationProxy Integration', () => {
proxy.stop();
}
});
it('ends stalled streaming responses after upstream headers', async () => {
const upstream = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write(
'event: message_start\n' +
'data: {"type":"message_start","message":{"id":"msg_stall","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n'
);
});
});
const upstreamPort = await new Promise<number>((resolve, reject) => {
upstream.once('error', reject);
upstream.listen(0, '127.0.0.1', () => {
const address = upstream.address();
if (typeof address !== 'object' || !address) {
reject(new Error('Failed to resolve upstream port'));
return;
}
resolve(address.port);
});
});
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
timeoutMs: 100,
});
const port = await proxy.start();
try {
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stream: true,
tools: [{ name: 'valid_tool' }],
}),
});
const text = await Promise.race([
response.text(),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error('Timed out waiting for proxy response')), 2_000)
),
]);
expect(response.status).toBe(200);
expect(text).toContain('message_start');
expect(text).toContain('timeout_error');
expect(text).toContain('Upstream response timed out');
} finally {
proxy.stop();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
});
});
+13 -1
View File
@@ -291,7 +291,19 @@ export function resolveProxyConfig(
// Merge auth token: CLI > ENV > config.yaml
resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token;
resolved.managementKey = yamlConfig.remote?.management_key;
// Keep YAML management key only when remote target/auth are not overridden via CLI/ENV.
// Prevents leaking a saved management key to a different runtime target.
const hasRuntimeRemoteOverride =
cliFlags.host !== undefined ||
envConfig.host !== undefined ||
cliFlags.port !== undefined ||
envConfig.port !== undefined ||
cliFlags.protocol !== undefined ||
envConfig.protocol !== undefined ||
cliFlags.authToken !== undefined ||
envConfig.authToken !== undefined;
resolved.managementKey = hasRuntimeRemoteOverride ? undefined : yamlConfig.remote?.management_key;
// Merge timeout: CLI > ENV > config.yaml > default (2000ms in executor)
resolved.timeout = cliFlags.timeout ?? envConfig.timeout ?? yamlConfig.remote?.timeout;
+185 -8
View File
@@ -22,12 +22,17 @@ import {
extractProviderFromPathname,
getDeniedModelIdReasonForProvider,
normalizeModelIdForRouting,
parseCodexModelTuningAlias,
stripCodexEffortSuffix,
} from '../ai-providers/model-id-normalizer';
import { getModelMaxLevel } from '../model-catalog';
import { createLogger } from '../../services/logging';
import { getCcsDir } from '../../config/config-loader-facade';
import {
attachUpstreamResponseTimeout,
writeForwardResponseHead,
} from './upstream-response-timeout';
export interface ToolSanitizationProxyConfig {
/** Upstream CLIProxy URL */
@@ -59,6 +64,7 @@ const GEMINI_UNSUPPORTED_TOOL_FIELDS = new Set([
]);
const CODEX_UNSUPPORTED_TOOL_FIELDS = new Set(['cache_control']);
const CODEX_FAST_SERVICE_TIER = 'priority';
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
const LEGACY_CODEX_MODEL_ID_REGEX = /^gpt-5(?:\.\d+)?-codex(?:-(?:mini|max))?$/i;
@@ -86,6 +92,118 @@ function isKnownCodexModelId(model: string | undefined): boolean {
);
}
function isCodexRequest(providerFromPath: string | null, model: unknown): boolean {
return (
providerFromPath === 'codex' ||
(providerFromPath === null && typeof model === 'string' && isKnownCodexModelId(model))
);
}
function applyCodexModelTuningAlias(body: Record<string, unknown>): Record<string, unknown> {
if (typeof body.model !== 'string') {
return body;
}
const parsed = parseCodexModelTuningAlias(body.model);
if (!parsed || !isKnownCodexModelId(parsed.baseModel)) {
return body;
}
const tunedBody: Record<string, unknown> = { ...body, model: parsed.baseModel };
if (parsed.effort) {
const existingReasoning = isRecord(body.reasoning) ? body.reasoning : {};
tunedBody.reasoning = {
...existingReasoning,
effort: parsed.effort,
};
}
if (parsed.serviceTier) {
tunedBody.service_tier = CODEX_FAST_SERVICE_TIER;
}
return tunedBody;
}
function extractSystemText(content: unknown): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return '';
}
return content
.filter((block): block is { type: unknown; text?: unknown } => isRecord(block))
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text as string)
.join('\n\n');
}
function prependSystemTextToContent(content: unknown, systemText: string): unknown {
if (Array.isArray(content)) {
return [{ type: 'text', text: systemText }, ...content];
}
if (typeof content === 'string') {
return `${systemText}\n\n${content}`;
}
return systemText;
}
function foldCodexSystemMessages(body: Record<string, unknown>): Record<string, unknown> {
const systemTexts: string[] = [];
let removedSystem = false;
const nextBody = { ...body };
if (body.system !== undefined) {
removedSystem = true;
delete nextBody.system;
const systemText = extractSystemText(body.system).trim();
if (systemText) {
systemTexts.push(systemText);
}
}
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
const messages = rawMessages.filter((message) => {
if (!isRecord(message) || message.role !== 'system') {
return true;
}
removedSystem = true;
const systemText = extractSystemText(message.content).trim();
if (systemText) {
systemTexts.push(systemText);
}
return false;
});
if (!removedSystem) {
return body;
}
if (systemTexts.length > 0) {
const systemPrefix = systemTexts.join('\n\n');
const firstUserIndex = messages.findIndex(
(message) => isRecord(message) && message.role === 'user'
);
if (firstUserIndex >= 0 && isRecord(messages[firstUserIndex])) {
const firstUserMessage = messages[firstUserIndex];
messages[firstUserIndex] = {
...firstUserMessage,
content: prependSystemTextToContent(firstUserMessage.content, systemPrefix),
};
} else {
messages.unshift({ role: 'user', content: systemPrefix });
}
}
nextBody.messages = messages;
return nextBody;
}
function getUnsupportedToolFields(
providerFromPath: string | null,
model: string | undefined
@@ -348,6 +466,11 @@ export class ToolSanitizationProxy {
}
}
if (isRecord(modifiedBody) && isCodexRequest(providerFromPath, modifiedBody.model)) {
const tunedBody = applyCodexModelTuningAlias(modifiedBody);
modifiedBody = foldCodexSystemMessages(tunedBody);
}
// Sanitize tools if present
if (isRecord(modifiedBody) && Array.isArray(modifiedBody.tools)) {
// Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties)
@@ -521,10 +644,38 @@ export class ToolSanitizationProxy {
),
(upstreamRes) => {
clearResponseTimeout();
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
upstreamRes.pipe(clientRes);
upstreamRes.on('end', () => resolve());
upstreamRes.on('error', reject);
const statusCode = upstreamRes.statusCode || 200;
let responseStarted = false;
const writeResponseHead = () => {
if (responseStarted) return;
responseStarted = true;
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
};
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
upstreamReq,
upstreamRes,
clientRes,
timeoutMs: this.config.timeoutMs,
onTimeout: () => resolve(),
});
upstreamRes.on('data', (chunk: Buffer) => {
writeResponseHead();
const canContinue = clientRes.write(chunk);
if (!canContinue) {
upstreamRes.pause();
clientRes.once('drain', () => upstreamRes.resume());
}
});
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
writeResponseHead();
clientRes.end();
resolve();
});
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
}
);
@@ -560,10 +711,18 @@ export class ToolSanitizationProxy {
),
(upstreamRes) => {
clearResponseTimeout();
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
upstreamReq,
upstreamRes,
clientRes,
timeoutMs: this.config.timeoutMs,
onTimeout: () => resolve(),
});
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
try {
const responseBody = Buffer.concat(chunks).toString('utf8');
const contentType = upstreamRes.headers['content-type'] || '';
@@ -598,7 +757,10 @@ export class ToolSanitizationProxy {
reject(err);
}
});
upstreamRes.on('error', reject);
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
}
);
@@ -636,7 +798,14 @@ export class ToolSanitizationProxy {
),
(upstreamRes) => {
clearResponseTimeout();
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
upstreamReq,
upstreamRes,
clientRes,
timeoutMs: this.config.timeoutMs,
onTimeout: () => resolve(),
});
writeForwardResponseHead(clientRes, upstreamRes.statusCode || 200, upstreamRes.headers);
// Track upstream SSE lifecycle events (guards against empty proxy responses)
const lifecycle = {
@@ -673,6 +842,7 @@ export class ToolSanitizationProxy {
}
});
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
try {
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
this.writeLog(
@@ -693,7 +863,10 @@ export class ToolSanitizationProxy {
}
resolve();
});
upstreamRes.on('error', reject);
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
return;
}
@@ -719,6 +892,7 @@ export class ToolSanitizationProxy {
});
upstreamRes.on('end', () => {
clearUpstreamResponseTimeout();
try {
// Process any remaining buffer
if (buffer.trim()) {
@@ -749,7 +923,10 @@ export class ToolSanitizationProxy {
resolve();
});
upstreamRes.on('error', reject);
upstreamRes.on('error', (error) => {
clearUpstreamResponseTimeout();
reject(error);
});
}
);
@@ -0,0 +1,87 @@
import * as http from 'http';
export const UPSTREAM_RESPONSE_TIMEOUT_MESSAGE =
'Upstream response timed out while streaming response body';
export function buildTimeoutSafeResponseHeaders(
headers: http.IncomingHttpHeaders
): http.OutgoingHttpHeaders {
const safeHeaders: http.OutgoingHttpHeaders = {};
for (const [name, value] of Object.entries(headers)) {
const normalized = name.toLowerCase();
if (normalized === 'content-length' || normalized === 'transfer-encoding') {
continue;
}
safeHeaders[name] = value;
}
return safeHeaders;
}
export function writeForwardResponseHead(
clientRes: http.ServerResponse,
statusCode: number,
headers: http.IncomingHttpHeaders
): void {
if (clientRes.headersSent) return;
clientRes.writeHead(statusCode, buildTimeoutSafeResponseHeaders(headers));
}
export function writeTimeoutResponse(
clientRes: http.ServerResponse,
headers: http.IncomingHttpHeaders,
message = UPSTREAM_RESPONSE_TIMEOUT_MESSAGE
): void {
if (clientRes.destroyed || clientRes.writableEnded) return;
const contentType = String(headers['content-type'] ?? '').toLowerCase();
try {
if (contentType.includes('text/event-stream')) {
const payload = {
type: 'error',
error: {
type: 'timeout_error',
message,
},
};
clientRes.write(`event: error\ndata: ${JSON.stringify(payload)}\n\n`);
} else if (!clientRes.headersSent) {
clientRes.writeHead(504, { 'Content-Type': 'application/json' });
clientRes.write(JSON.stringify({ error: message }));
} else {
clientRes.write(`\n${JSON.stringify({ error: message })}`);
}
clientRes.end();
} catch {
// Client may have disconnected while the upstream response was stalled.
}
}
export function attachUpstreamResponseTimeout(options: {
upstreamReq: http.ClientRequest;
upstreamRes: http.IncomingMessage;
clientRes: http.ServerResponse;
timeoutMs: number;
onTimeout?: (error: Error) => void;
}): () => void {
const { upstreamReq, upstreamRes, clientRes, timeoutMs, onTimeout } = options;
let settled = false;
const clear = () => {
settled = true;
upstreamRes.setTimeout(0);
};
upstreamRes.setTimeout(timeoutMs, () => {
if (settled) return;
settled = true;
const error = new Error(UPSTREAM_RESPONSE_TIMEOUT_MESSAGE);
writeTimeoutResponse(clientRes, upstreamRes.headers, error.message);
onTimeout?.(error);
upstreamRes.destroy(error);
upstreamReq.destroy(error);
});
return clear;
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'bun:test';
import * as https from 'https';
import { describe, expect, it, mock, spyOn } from 'bun:test';
import type { ProxyTarget } from '../../proxy/proxy-target-resolver';
async function loadRoutingHttpModule() {
@@ -22,6 +23,73 @@ describe('routing-strategy-http', () => {
);
});
it('rejects malformed self-signed HTTPS URLs before arming the request timeout', async () => {
const { fetchCliproxyRoutingResponse } = await loadRoutingHttpModule();
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => {
void handler;
void timeout;
return 1 as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout);
const target: ProxyTarget = {
host: 'bad host',
port: 443,
protocol: 'https',
allowSelfSigned: true,
isRemote: true,
};
try {
await expect(fetchCliproxyRoutingResponse(target, 'GET')).rejects.toThrow();
expect(globalThis.setTimeout).not.toHaveBeenCalled();
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
it('clears the request timeout when https.request throws synchronously', async () => {
const { fetchCliproxyRoutingResponse } = await loadRoutingHttpModule();
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const activeTimers = new Set<number>();
globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => {
void handler;
void timeout;
const timerId = activeTimers.size + 1;
activeTimers.add(timerId);
return timerId as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout);
globalThis.clearTimeout = mock(((timerId?: ReturnType<typeof setTimeout>) => {
activeTimers.delete(timerId as unknown as number);
}) as typeof clearTimeout);
const requestSpy = spyOn(https, 'request').mockImplementation(() => {
throw new Error('sync request failure');
});
const target: ProxyTarget = {
host: 'proxy.example.com',
port: 443,
protocol: 'https',
allowSelfSigned: true,
isRemote: true,
};
try {
await expect(fetchCliproxyRoutingResponse(target, 'GET')).rejects.toThrow(
'sync request failure'
);
expect(globalThis.setTimeout).toHaveBeenCalledTimes(1);
expect(globalThis.clearTimeout).toHaveBeenCalledTimes(1);
expect(activeTimers.size).toBe(0);
} finally {
requestSpy.mockRestore();
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
it('builds the remote management URL for routing strategy writes', async () => {
const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule();
const target: ProxyTarget = {
+46 -36
View File
@@ -40,6 +40,8 @@ export async function fetchCliproxyRoutingResponse(
}
}
const requestUrl = new URL(url);
return new Promise<Response>((resolve, reject) => {
const agent = new https.Agent({ rejectUnauthorized: false });
let settled = false;
@@ -51,57 +53,65 @@ export async function fetchCliproxyRoutingResponse(
callback();
};
let req: ReturnType<typeof https.request> | undefined;
const timeoutId = setTimeout(() => {
const error = new Error('Request timeout');
req.destroy(error);
req?.destroy(error);
settle(() => reject(error));
}, ROUTING_TIMEOUT_MS);
const req = https.request(
url,
{
method,
headers,
agent,
timeout: ROUTING_TIMEOUT_MS,
},
(res) => {
let payload = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
payload += chunk;
});
res.on('end', () => {
settle(() =>
resolve(
new Response(payload, {
status: res.statusCode || 500,
statusText: res.statusMessage ?? '',
headers:
typeof res.headers['content-type'] === 'string'
? { 'Content-Type': res.headers['content-type'] }
: undefined,
})
)
);
});
}
);
try {
req = https.request(
requestUrl,
{
method,
headers,
agent,
timeout: ROUTING_TIMEOUT_MS,
},
(res) => {
let payload = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
payload += chunk;
});
res.on('end', () => {
settle(() =>
resolve(
new Response(payload, {
status: res.statusCode || 500,
statusText: res.statusMessage ?? '',
headers:
typeof res.headers['content-type'] === 'string'
? { 'Content-Type': res.headers['content-type'] }
: undefined,
})
)
);
});
}
);
} catch (error) {
settle(() => reject(error));
return;
}
req.on('error', (error) => {
const request = req;
request.on('error', (error) => {
settle(() => reject(error));
});
req.on('timeout', () => {
request.on('timeout', () => {
const error = new Error('Request timeout');
req.destroy(error);
request.destroy(error);
settle(() => reject(error));
});
if (body) {
req.write(JSON.stringify(body));
request.write(JSON.stringify(body));
}
req.end();
request.end();
});
}
+1
View File
@@ -38,6 +38,7 @@ const CHANNEL_TO_PROVIDER: Record<string, CLIProxyProvider> = {
kimi: 'kimi',
kiro: 'kiro',
'github-copilot': 'ghcp',
qoder: 'qoder',
};
/** CCS provider → channel name mapping (reverse) */
+3 -1
View File
@@ -16,7 +16,8 @@ export type CLIProxyProvider =
| 'cursor'
| 'gitlab'
| 'codebuddy'
| 'kilo';
| 'kilo'
| 'qoder';
/** CLIProxy backend selection */
export type CLIProxyBackend = 'original' | 'plus';
@@ -32,6 +33,7 @@ export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = [
'gitlab',
'codebuddy',
'kilo',
'qoder',
];
/** Model mapping for each provider */
+25 -4
View File
@@ -94,7 +94,7 @@ export function ensureSharedConfigSymlink(
target: targetPath,
});
} catch (err) {
copySharedConfigFallback(targetPath, linkPath, err);
handleSymlinkFailure(targetPath, linkPath, err);
}
}
@@ -106,9 +106,30 @@ function isRegularConfigCopyUnmodified(linkPath: string, targetPath: string): bo
}
}
function copySharedConfigFallback(targetPath: string, linkPath: string, err: unknown): void {
fs.copyFileSync(targetPath, linkPath);
fs.chmodSync(linkPath, 0o600);
function handleSymlinkFailure(targetPath: string, linkPath: string, err: unknown): void {
const code = (err as NodeJS.ErrnoException | null)?.code;
if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOSYS') {
throw err;
}
const tmpPath = path.join(
path.dirname(linkPath),
`.config.toml.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
);
try {
fs.copyFileSync(targetPath, tmpPath, fs.constants.COPYFILE_EXCL);
fs.chmodSync(tmpPath, 0o600);
fs.renameSync(tmpPath, linkPath);
} catch (copyErr) {
try {
fs.rmSync(tmpPath, { force: true });
} catch {
// best effort cleanup
}
throw copyErr;
}
process.stderr.write(
`[!] codex-auth: symlink unavailable; copied shared config.toml to ${linkPath}. ` +
`Config edits won't propagate automatically.\n`
@@ -3,7 +3,7 @@
*
* Migrates legacy ~/.codex/auth.json into a named profile (non-destructive).
* Implements C3 torn-write protection: read-with-retry + JWT-shape validation
* + pgrep Codex-running detection + atomic write.
* + current-user Codex-running detection + atomic write.
*
* Usage: ccsx auth import-default <name> [--with-history] [--force] [--force-while-running]
*/
@@ -40,51 +40,37 @@ function sleep(ms: number): Promise<void> {
}
/**
* Detect a running `codex` process via pgrep + ps validation (best-effort, never throws).
* Returns the PID string if found, null otherwise.
* Detect a running Codex CLI process from the current user's process table
* (best-effort, never throws). Returns the PID string if found, null otherwise.
*/
function detectCodexRunning(): string | null {
try {
const result = childProcess.spawnSync('pgrep', ['-f', 'codex'], {
encoding: 'utf8',
timeout: 2000,
});
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
const pids = parsePgrepPids(result.stdout);
if (pids.length === 0) return null;
const psResult = childProcess.spawnSync(
const result = childProcess.spawnSync(
'ps',
['-p', pids.join(','), '-o', 'pid=', '-o', 'command='],
['-A', '-o', 'pid=', '-o', 'uid=', '-o', 'command='],
{
encoding: 'utf8',
timeout: 2000,
}
);
if (psResult.status !== 0 || !psResult.stdout) return null;
return selectCodexPidFromPsOutput(psResult.stdout, pids);
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
return selectCodexPidFromPsOutput(result.stdout);
} catch {
return null;
}
}
function parsePgrepPids(stdout: string): string[] {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((pid) => /^\d+$/.test(pid) && pid !== String(process.pid));
}
function selectCodexPidFromPsOutput(stdout: string, candidatePids: string[]): string | null {
const candidates = new Set(candidatePids);
function selectCodexPidFromPsOutput(stdout: string): string | null {
const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
for (const line of stdout.split(/\r?\n/)) {
const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/);
if (!match) continue;
const [, pid, command] = match;
if (!pid || !command || !candidates.has(pid)) continue;
const [, pid, uid, command] = match;
if (!pid || !uid || !command || pid === String(process.pid)) continue;
if (currentUid !== null && uid !== String(currentUid)) continue;
if (isLikelyCodexProcessCommand(command)) return pid;
}
return null;
+6 -1
View File
@@ -1056,8 +1056,13 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
}
if (account.paused) {
const refreshed = pauseAccount(provider, account.id);
const refreshedAccount = refreshed ? findAccountByQuery(provider, account.id) : account;
console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`));
console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`));
if (refreshed) {
console.log(info('Manual pause refreshed; account will stay out of quota rotation'));
}
console.log(info(`Paused at: ${refreshedAccount?.pausedAt || account.pausedAt || 'unknown'}`));
return;
}
+1
View File
@@ -205,6 +205,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
gitlab: 'GitLab Duo via CLIProxy OAuth',
codebuddy: 'CodeBuddy via CLIProxy OAuth',
kilo: 'Kilo AI via CLIProxy OAuth',
qoder: 'Qoder AI via CLIProxy OAuth',
}[name] || 'CLIProxy OAuth provider',
})
);
+24 -31
View File
@@ -54,31 +54,13 @@ interface ChannelsCommandOptions {
setSelectionMissing: boolean;
clearTokenAll: boolean;
clearTokenChannel?: OfficialChannelId;
setToken?: { channelId: OfficialChannelId; token: string };
setTokenChannel?: OfficialChannelId;
setTokenMissing: boolean;
clearTokenInvalid?: string;
setTokenInvalid?: string;
help: boolean;
}
function parseTokenAssignment(value: string): {
channelId: OfficialChannelId;
token: string;
} | null {
const separatorIndex = value.indexOf('=');
if (separatorIndex === -1) {
return value.trim() ? { channelId: 'discord', token: value.trim() } : null;
}
const channelId = value.slice(0, separatorIndex).trim().toLowerCase();
const token = value.slice(separatorIndex + 1).trim();
if (!isOfficialChannelId(channelId) || !token) {
return null;
}
return { channelId, token };
}
export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions {
const setSelection = extractOption(args, ['--set']);
const setToken = extractOption(args, ['--set-token']);
@@ -100,11 +82,13 @@ export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions
}
}
let parsedSetToken: { channelId: OfficialChannelId; token: string } | undefined;
let parsedSetTokenChannel: OfficialChannelId | undefined;
let setTokenInvalid: string | undefined;
if (setToken.found && !setToken.missingValue && setToken.value) {
parsedSetToken = parseTokenAssignment(setToken.value) ?? undefined;
if (!parsedSetToken) {
const channelId = setToken.value.trim().toLowerCase();
if (isOfficialChannelId(channelId)) {
parsedSetTokenChannel = channelId;
} else {
setTokenInvalid = setToken.value;
}
}
@@ -120,7 +104,7 @@ export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions
clearTokenAll,
clearTokenChannel,
clearTokenInvalid,
setToken: parsedSetToken,
setTokenChannel: parsedSetTokenChannel,
setTokenMissing: setToken.found && setToken.missingValue,
setTokenInvalid,
help: hasAnyFlag(args, ['--help', '-h']),
@@ -153,7 +137,7 @@ function showHelp(): void {
` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions`
);
console.log(` ${color('--no-unattended', 'command')} Disable unattended runtime flag`);
console.log(` ${color('--set-token <spec>', 'command')} ${getOfficialChannelTokenHelp()}`);
console.log(` ${color('--set-token <channel>', 'command')} ${getOfficialChannelTokenHelp()}`);
console.log(
` ${color('--clear-token [channel]', 'command')} ${getOfficialChannelClearTokenHelp()}`
);
@@ -173,7 +157,7 @@ function showHelp(): void {
` $ ${color('ccs config channels --set all', 'command')} ${dim('# Enable all official channels')}`
);
console.log(
` $ ${color('ccs config channels --set-token telegram=123:abc', 'command')} ${dim('# Save TELEGRAM_BOT_TOKEN')}`
` $ ${color('TELEGRAM_BOT_TOKEN=123:abc ccs config channels --set-token telegram', 'command')} ${dim('# Save TELEGRAM_BOT_TOKEN')}`
);
console.log(
` $ ${color('ccs config channels --clear-token discord', 'command')} ${dim('# Clear one token')}`
@@ -395,7 +379,9 @@ export async function handleConfigChannelsCommand(args: string[]): Promise<void>
}
if (options.setTokenInvalid) {
console.error(
fail(`Invalid --set-token value: ${options.setTokenInvalid} (use <channel>=<token>)`)
fail(
`Invalid --set-token value: ${options.setTokenInvalid} (use ${getOfficialChannelChoices()})`
)
);
process.exitCode = 1;
return;
@@ -444,12 +430,19 @@ export async function handleConfigChannelsCommand(args: string[]): Promise<void>
updateConfig({ channels: nextConfig });
}
if (options.setToken) {
if (!getOfficialChannelTokenIds().includes(options.setToken.channelId)) {
throw new Error(`${options.setToken.channelId} does not use a bot token.`);
if (options.setTokenChannel) {
if (!getOfficialChannelTokenIds().includes(options.setTokenChannel)) {
throw new Error(`${options.setTokenChannel} does not use a bot token.`);
}
setConfiguredOfficialChannelToken(options.setToken.channelId, options.setToken.token);
console.log(ok(`${getOfficialChannelDisplayName(options.setToken.channelId)} token saved`));
const envKey = getOfficialChannelEnvKey(options.setTokenChannel);
const token = envKey ? process.env[envKey]?.trim() : '';
if (!token) {
throw new Error(
`${getOfficialChannelDisplayName(options.setTokenChannel)} token missing. Set ${envKey} in your environment and rerun.`
);
}
setConfiguredOfficialChannelToken(options.setTokenChannel, token);
console.log(ok(`${getOfficialChannelDisplayName(options.setTokenChannel)} token saved`));
console.log('');
}
+1
View File
@@ -16,6 +16,7 @@ export const RESERVED_PROFILE_NAMES = [
'gitlab',
'codebuddy',
'kilo',
'qoder',
// Copilot API (GitHub Copilot proxy)
'copilot',
// Cursor IDE (Cursor proxy daemon)
+24
View File
@@ -0,0 +1,24 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
const DAEMON_TOKEN_FILE = 'cursor-daemon-token';
export function getCursorDaemonToken(): string {
const tokenPath = path.join(getCcsDir(), DAEMON_TOKEN_FILE);
try {
const token = fs.readFileSync(tokenPath, 'utf8').trim();
if (token.length > 0) {
return token;
}
} catch {
// Token file missing or unreadable; regenerate below.
}
const token = crypto.randomBytes(32).toString('hex');
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
fs.writeFileSync(tokenPath, token, { mode: 0o600 });
return token;
}
+59
View File
@@ -55,6 +55,23 @@ interface OpenAIChatRequest {
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
function getAnthropicRequestToken(headers: http.IncomingHttpHeaders): string {
const xApiKey = headers['x-api-key'];
if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) {
return xApiKey.trim();
}
const authorization = headers.authorization;
if (typeof authorization === 'string') {
const match = authorization.match(/^Bearer\s+(.+)$/i);
if (match && match[1].trim().length > 0) {
return match[1].trim();
}
}
return '';
}
function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
@@ -108,6 +125,23 @@ function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
});
}
function hasValidDaemonToken(req: http.IncomingMessage): boolean {
const expectedToken = process.env.CCS_CURSOR_DAEMON_TOKEN;
if (!expectedToken) {
return false;
}
const provided = req.headers['x-ccs-cursor-token'];
if (typeof provided === 'string') {
return provided === expectedToken;
}
if (Array.isArray(provided)) {
return provided.includes(expectedToken);
}
return false;
}
function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] {
if (!Array.isArray(raw)) {
throw new Error('messages must be an array');
@@ -202,6 +236,10 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
try {
if (method === 'GET' && requestUrl === '/health') {
if (!hasValidDaemonToken(req)) {
writeJson(res, 401, { error: 'Unauthorized' });
return;
}
writeJson(res, 200, { ok: true, service: 'cursor-daemon' });
return;
}
@@ -234,6 +272,11 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
return;
}
if (!hasValidDaemonToken(req)) {
writeJson(res, 401, { error: 'Unauthorized' });
return;
}
const rawBody = await readJsonBody(req);
const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined;
const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {});
@@ -283,6 +326,22 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
return;
}
if (isAnthropicRoute) {
const expectedToken = (process.env.ANTHROPIC_AUTH_TOKEN || 'cursor-managed').trim();
const requestToken = getAnthropicRequestToken(req.headers);
if (!expectedToken || requestToken !== expectedToken) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(
401,
'authentication_error',
'Invalid Anthropic auth token. Set ANTHROPIC_AUTH_TOKEN and send it via x-api-key or Authorization Bearer.'
),
res
);
return;
}
}
const daemonCredentials = {
accessToken: authStatus.credentials.accessToken,
machineId: authStatus.credentials.machineId,
+30 -7
View File
@@ -6,6 +6,7 @@
*/
import { spawn, ChildProcess } from 'child_process';
import { randomBytes } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
@@ -56,7 +57,7 @@ async function resolveDaemonEntrypoint(): Promise<string | null> {
* Check if cursor daemon is running on the specified port.
* Uses 127.0.0.1 instead of localhost for more reliable local connections.
*/
export async function isDaemonRunning(port: number): Promise<boolean> {
export async function isDaemonRunning(port: number, daemonToken?: string): Promise<boolean> {
return new Promise((resolve) => {
const req = http.request(
{
@@ -65,6 +66,7 @@ export async function isDaemonRunning(port: number): Promise<boolean> {
path: '/health',
method: 'GET',
timeout: 3000,
headers: daemonToken ? { 'x-ccs-cursor-token': daemonToken } : undefined,
},
(res) => {
let body = '';
@@ -125,14 +127,26 @@ export async function getDaemonStatus(port: number): Promise<CursorDaemonStatus>
*/
export async function startDaemon(
config: CursorDaemonConfig
): Promise<{ success: boolean; pid?: number; error?: string }> {
): Promise<{ success: boolean; pid?: number; error?: string; daemonToken?: string }> {
// Auto-generate daemon token if not provided so /health and protected routes
// can be probed by the caller during startup polling.
const daemonToken =
config.daemon_token && config.daemon_token.trim()
? config.daemon_token
: randomBytes(32).toString('hex');
const effectiveConfig: CursorDaemonConfig = { ...config, daemon_token: daemonToken };
// Check if already running
if (await isDaemonRunning(config.port)) {
if (await isDaemonRunning(effectiveConfig.port, effectiveConfig.daemon_token)) {
logger.stage('dispatch', 'cursor.daemon.already_running', 'Cursor daemon already running', {
provider: 'cursor',
port: config.port,
});
return { success: true, pid: getPidFromFile() ?? undefined };
return {
success: true,
pid: getPidFromFile() ?? undefined,
daemonToken: effectiveConfig.daemon_token,
};
}
// Validate port before interpolation (prevents injection)
@@ -159,7 +173,12 @@ export async function startDaemon(
let proc: ChildProcess;
let resolved = false;
const safeResolve = (result: { success: boolean; pid?: number; error?: string }) => {
const safeResolve = (result: {
success: boolean;
pid?: number;
error?: string;
daemonToken?: string;
}) => {
if (resolved) return;
resolved = true;
if (checkTimeout) clearTimeout(checkTimeout);
@@ -205,6 +224,10 @@ export async function startDaemon(
proc = spawn(process.execPath, args, {
stdio: 'ignore',
detached: true,
env: {
...process.env,
CCS_CURSOR_DAEMON_TOKEN: effectiveConfig.daemon_token || '',
},
});
// Unref so parent can exit
@@ -220,8 +243,8 @@ export async function startDaemon(
const pollHealth = async () => {
attempts++;
if (await isDaemonRunning(config.port)) {
safeResolve({ success: true, pid: proc.pid });
if (await isDaemonRunning(effectiveConfig.port, effectiveConfig.daemon_token)) {
safeResolve({ success: true, pid: proc.pid, daemonToken: effectiveConfig.daemon_token });
} else if (attempts >= maxAttempts) {
// Kill orphaned process
if (proc.pid) {
+2 -1
View File
@@ -8,6 +8,7 @@ import * as http from 'http';
import type { CursorModel } from './types';
import type { CursorApiCredentials } from './cursor-protobuf-schema';
import { isDaemonRunning } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { buildCursorModelsHeaders } from './cursor-client-policy';
import {
DEFAULT_CURSOR_MODEL,
@@ -285,7 +286,7 @@ export async function fetchModelsFromDaemon(port: number): Promise<CursorModel[]
* Checks daemon health first to avoid 5s timeout when daemon is not running.
*/
export async function getAvailableModels(port: number): Promise<CursorModel[]> {
if (!(await isDaemonRunning(port))) {
if (!(await isDaemonRunning(port, getCursorDaemonToken()))) {
return DEFAULT_CURSOR_MODELS;
}
return fetchModelsFromDaemon(port);
+8 -3
View File
@@ -15,6 +15,7 @@ import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../u
import { stripClaudeCodeEnv } from '../utils/shell-executor';
import { checkAuthStatus } from './cursor-auth';
import { isDaemonRunning, startDaemon } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { getGlobalEnvConfig } from '../config/config-loader-facade';
interface CursorImageAnalysisResolution {
@@ -31,6 +32,7 @@ interface CursorImageAnalysisDeps {
export function generateCursorEnv(
config: CursorConfig,
daemonToken: string,
claudeConfigDir?: string
): Record<string, string> {
const opusModel = config.opus_model || config.model;
@@ -39,7 +41,7 @@ export function generateCursorEnv(
return {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
ANTHROPIC_AUTH_TOKEN: daemonToken,
ANTHROPIC_MODEL: config.model,
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
@@ -130,13 +132,16 @@ export async function executeCursorProfile(
return 1;
}
let daemonRunning = await isDaemonRunning(config.port);
const daemonToken = getCursorDaemonToken();
let daemonRunning = await isDaemonRunning(config.port, daemonToken);
if (!daemonRunning) {
if (config.auto_start) {
console.log(info('Starting cursor daemon...'));
const result = await startDaemon({
port: config.port,
ghost_mode: config.ghost_mode,
daemon_token: daemonToken,
});
if (!result.success) {
console.error(fail(`Failed to start cursor daemon: ${result.error}`));
@@ -154,7 +159,7 @@ export async function executeCursorProfile(
}
}
const cursorEnv = generateCursorEnv(config, claudeConfigDir);
const cursorEnv = generateCursorEnv(config, daemonToken, claudeConfigDir);
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
const webSearchEnv = getWebSearchHookEnv();
+4 -2
View File
@@ -1,6 +1,7 @@
import type { CursorConfig } from '../config/unified-config-types';
import { checkAuthStatus } from './cursor-auth';
import { isDaemonRunning, startDaemon } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models';
export interface CursorProbeResult {
@@ -136,7 +137,8 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
};
}
let daemonRunning = await isDaemonRunning(config.port);
const daemonToken = getCursorDaemonToken();
let daemonRunning = await isDaemonRunning(config.port, daemonToken);
if (!daemonRunning && config.auto_start) {
const startResult = await startDaemon({
port: config.port,
@@ -144,7 +146,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
});
if (!startResult.success) {
daemonRunning = await isDaemonRunning(config.port);
daemonRunning = await isDaemonRunning(config.port, daemonToken);
} else {
daemonRunning = true;
}
+1
View File
@@ -10,6 +10,7 @@
export interface CursorDaemonConfig {
port: number;
ghost_mode?: boolean;
daemon_token?: string;
}
/**
+43 -2
View File
@@ -29,6 +29,7 @@ import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver';
import { DroidReasoningFlagError } from '../targets/droid-reasoning-runtime';
import { DroidCommandRouterError, routeDroidCommandArgs } from '../targets/droid-command-router';
import { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge';
import { getClaudeSubcommandName } from '../utils/claude-subcommand-detector';
import { resolveCodexRuntimeConfigOverrides } from './environment-builder';
import {
detectProfile,
@@ -83,6 +84,31 @@ function usesImplicitDefaultProfile(cleanArgs: string[]): boolean {
return cleanArgs.length === 0 || cleanArgs[0]?.startsWith('-') === true;
}
/**
* Decide whether a first token that has no matching profile is actually a bare
* Claude subcommand that should be forwarded through the default profile.
*
* Claude Code exposes subcommands like `claude agents`, `claude mcp`,
* `claude plugin`, `claude setup-token`. Invoked through CCS as `ccs agents`,
* `ccs mcp`, ... the first token is treated as a profile name, so profile
* resolution throws "profile not found". Instead, forward such tokens to
* `claude <subcommand>` under the default profile (matching `ccs default
* agents`), where the launcher already strips interactive-session args.
*
* Gated to the claude target codex/droid run their own subcommand routing
* and only reached on the profile-not-found path, so a real configured profile
* of the same name always wins.
*/
export function isBareClaudeSubcommandPassthrough(profile: string, args: string[]): boolean {
if (profile === 'default') return false;
if (getClaudeSubcommandName([profile]) === null) return false;
try {
return resolveTargetType(args) === 'claude';
} catch {
return false;
}
}
function buildNativeCodexDefaultProfile(): ProfileDetectionResult {
return {
type: 'default',
@@ -123,8 +149,23 @@ export async function resolveProfileAndTarget(
// Detect profile (strip --target flags before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
let profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
const detected = detectProfile(cleanArgs);
let profile = detected.profile;
let remainingArgs = detected.remainingArgs;
let profileInfo: ProfileDetectionResult;
try {
profileInfo = detector.detectProfileType(profile);
} catch (profileError) {
// Bare Claude subcommand passthrough: forward `ccs agents`, `ccs mcp`, ...
// through the default profile instead of failing as an unknown profile.
if (isBareClaudeSubcommandPassthrough(profile, args)) {
remainingArgs = [profile, ...remainingArgs];
profile = 'default';
profileInfo = detector.detectProfileType(profile);
} else {
throw profileError;
}
}
let resolvedTarget: ReturnType<typeof resolveTargetType>;
try {
+9
View File
@@ -7,6 +7,7 @@ export const DOCKER_LEGACY_API_KEY = 'ccs-internal-managed';
export const DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS = 14;
export const DOCKER_LEGACY_KEY_GRACE_ENV = 'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS';
export const DOCKER_RESTORE_LEGACY_KEY_ENV = 'CCS_DOCKER_RESTORE_LEGACY_API_KEY';
export const DOCKER_ENABLE_LEGACY_KEY_AUTH_ENV = 'CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH';
const DAY_MS = 24 * 60 * 60 * 1000;
const STATE_VERSION = 1;
@@ -67,6 +68,10 @@ export function shouldRestoreDockerLegacyApiKey(env = process.env): boolean {
return env[DOCKER_RESTORE_LEGACY_KEY_ENV] === '1';
}
export function shouldEnableDockerLegacyKeyAuth(env = process.env): boolean {
return env[DOCKER_ENABLE_LEGACY_KEY_AUTH_ENV] === '1';
}
export function readDockerBootstrapState(): DockerBootstrapStateReadResult {
const statePath = getDockerBootstrapStatePath();
if (!fs.existsSync(statePath)) {
@@ -135,6 +140,10 @@ export function isDockerLegacyKeyGraceActive(
}
export function getActiveDockerLegacyApiKeys(now = new Date()): string[] {
if (!shouldEnableDockerLegacyKeyAuth()) {
return [];
}
const { state } = readDockerBootstrapState();
if (!isDockerLegacyKeyGraceActive(state, now)) {
return [];
+16 -1
View File
@@ -35,12 +35,14 @@ export class SSEParser {
private eventCount: number;
private maxBufferSize: number;
private throwOnMalformedJson: boolean;
private pendingCR: boolean;
constructor(options: SSEParserOptions = {}) {
this.buffer = '';
this.eventCount = 0;
this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default
this.throwOnMalformedJson = options.throwOnMalformedJson === true;
this.pendingCR = false;
}
/**
@@ -49,7 +51,19 @@ export class SSEParser {
* @returns Array of parsed events
*/
parse(chunk: Buffer | string): SSEEvent[] {
this.buffer += chunk.toString().replace(/\r\n?/g, '\n');
let normalizedChunk = chunk.toString();
if (this.pendingCR) {
if (normalizedChunk.startsWith('\n')) {
normalizedChunk = normalizedChunk.slice(1);
}
this.pendingCR = false;
}
const endsWithCR = normalizedChunk.endsWith('\r');
normalizedChunk = normalizedChunk.replace(/\r\n?/g, '\n');
this.pendingCR = endsWithCR;
this.buffer += normalizedChunk;
// C-01 Fix: Prevent unbounded buffer growth (DoS protection)
if (this.buffer.length > this.maxBufferSize) {
@@ -127,5 +141,6 @@ export class SSEParser {
reset(): void {
this.buffer = '';
this.eventCount = 0;
this.pendingCR = false;
}
}
+13 -1
View File
@@ -354,7 +354,19 @@ class SharedManager {
}
const entryPath = path.join(sharedPluginsPath, entry.name);
const stats = fs.statSync(entryPath);
let stats: fs.Stats;
try {
stats = fs.statSync(entryPath);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
console.log(
warn(
`Skipping plugins/${entry.name}: unable to inspect shared plugin entry${code ? ` (${code})` : ''}`
)
);
continue;
}
items.set(entry.name, {
name: entry.name,
type: stats.isDirectory() ? 'directory' : 'file',
+1 -1
View File
@@ -285,7 +285,7 @@ function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatP
};
}
return { pid: null, session: null, source: 'profile' };
return { pid: getOpenAICompatProxyPid(profileName), session: null, source: 'profile' };
}
export async function listOpenAICompatProxyStatuses(): Promise<OpenAICompatProxyStatus[]> {
+81 -3
View File
@@ -42,15 +42,93 @@ function isDirectOpenAIReasoningChatModel(
);
}
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 (!isDirectOpenAIReasoningChatModel(profile, shaped.model)) {
return shaped;
}
shaped = { ...shaped };
if (shaped.max_tokens !== undefined) {
shaped.max_completion_tokens = shaped.max_tokens;
+11 -3
View File
@@ -40,7 +40,7 @@ type AnthropicContentBlock =
| { type: string; [key: string]: unknown };
interface AnthropicMessage {
role?: 'user' | 'assistant' | string;
role?: 'system' | 'user' | 'assistant' | string;
content?: string | AnthropicContentBlock[];
}
@@ -435,8 +435,8 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
messagesValue.forEach((message, messageIndex) => {
const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage;
const role = parsedMessage.role;
if (role !== 'user' && role !== 'assistant') {
throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`);
if (role !== 'system' && role !== 'user' && role !== 'assistant') {
throw new Error(`messages[${messageIndex}].role must be "system", "user", or "assistant"`);
}
if (pendingToolUseIds && pendingToolUseIds.size > 0 && role !== 'user') {
@@ -446,6 +446,14 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
}
const content = parsedMessage.content;
if (role === 'system') {
translatedMessages.push({
role: 'system',
content: flattenTextContent(content, `messages[${messageIndex}].content`),
});
return;
}
if (typeof content === 'string') {
if (pendingToolUseIds && pendingToolUseIds.size > 0) {
throw new Error(
+6 -1
View File
@@ -13,6 +13,7 @@ import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
import { generateCopilotEnv } from '../copilot/copilot-executor';
import { generateCursorEnv } from '../cursor';
import { getCursorDaemonToken } from '../cursor/cursor-daemon-auth';
import InstanceManager from '../management/instance-manager';
import SharedManager from '../management/shared-manager';
import { expandPath } from '../utils/helpers';
@@ -245,7 +246,11 @@ async function resolveExtensionEnv(
if (!result.cursorConfig) {
throw new Error(`Profile "${requestedProfile}" is missing cursor configuration.`);
}
return generateCursorEnv(result.cursorConfig, continuity.claudeConfigDir);
return generateCursorEnv(
result.cursorConfig,
getCursorDaemonToken(),
continuity.claudeConfigDir
);
})()
: (() => {
if (!result.provider) {
+1
View File
@@ -57,6 +57,7 @@ const PROVIDER_OWNER_HINTS: Record<string, string[]> = {
kimi: ['kimi', 'moonshot'],
kiro: ['kiro', 'aws'],
ghcp: ['github', 'copilot'],
qoder: ['qoder'],
};
function normalize(value: string | null | undefined): string {
+57 -15
View File
@@ -1,5 +1,8 @@
export const CODEX_TRANSLATOR_URL_MARKER = '/api/provider/codex';
const MAX_CODEX_TRANSLATOR_SCAN_DEPTH = 100;
const MAX_CODEX_TRANSLATOR_SCAN_NODES = 10000;
function formatSettingsPathSegment(basePath: string, segment: string | number): string {
if (typeof segment === 'number') {
return `${basePath}[${segment}]`;
@@ -13,23 +16,62 @@ function formatSettingsPathSegment(basePath: string, segment: string | number):
}
export function findCodexTranslatorUrlPaths(value: unknown, path = ''): string[] {
if (typeof value === 'string') {
return value.includes(CODEX_TRANSLATOR_URL_MARKER) ? [path || '(root)'] : [];
const matches: string[] = [];
const stack: Array<{ value: unknown; path: string; depth: number }> = [{ value, path, depth: 0 }];
const seen = new WeakSet<object>();
let visitedNodes = 0;
while (stack.length > 0 && visitedNodes < MAX_CODEX_TRANSLATOR_SCAN_NODES) {
const item = stack.pop();
if (!item) {
break;
}
visitedNodes += 1;
if (typeof item.value === 'string') {
if (item.value.includes(CODEX_TRANSLATOR_URL_MARKER)) {
matches.push(item.path || '(root)');
}
continue;
}
if (typeof item.value !== 'object' || item.value === null) {
continue;
}
if (seen.has(item.value)) {
continue;
}
seen.add(item.value);
if (item.depth >= MAX_CODEX_TRANSLATOR_SCAN_DEPTH) {
continue;
}
if (Array.isArray(item.value)) {
for (let index = item.value.length - 1; index >= 0; index -= 1) {
stack.push({
value: item.value[index],
path: formatSettingsPathSegment(item.path, index),
depth: item.depth + 1,
});
}
continue;
}
const entries = Object.entries(item.value);
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, child] = entries[index];
stack.push({
value: child,
path: formatSettingsPathSegment(item.path, key),
depth: item.depth + 1,
});
}
}
if (Array.isArray(value)) {
return value.flatMap((item, index) =>
findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, index))
);
}
if (typeof value === 'object' && value !== null) {
return Object.entries(value).flatMap(([key, item]) =>
findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, key))
);
}
return [];
return matches;
}
export function formatSettingsPathList(paths: string[]): string {
+1 -1
View File
@@ -222,7 +222,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') {
@@ -25,6 +25,26 @@ describe('withRetry', () => {
expect(fn).toHaveBeenCalledTimes(3);
});
it('uses the default retryability check when retryableCheck is null at runtime', async () => {
let attempt = 0;
const fn = mock(() => {
attempt++;
if (attempt < 2) {
return Promise.reject(new RetryableError('transient failure'));
}
return Promise.resolve('ok');
});
const result = await withRetry(fn, {
maxRetries: 3,
baseDelayMs: 1,
retryableCheck: null,
} as unknown as RetryOptions);
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});
it('throws after max retries exhausted', async () => {
const fn = mock(() => Promise.reject(new RetryableError('always fails')));
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 })).rejects.toThrow('always fails');
+15 -3
View File
@@ -230,8 +230,11 @@ export function getEffectiveClaudeBrowserAttachConfig(
const override = getBrowserAttachOverride(env);
const configUserDataDir =
resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir();
const configHasExplicitPort = config.claude.devtools_port !== undefined;
const configPort = normalizeDevtoolsPort(config.claude.devtools_port);
const configEvalMode = config.claude.eval_mode ?? 'readonly';
const envEvalMode = parseBrowserEvalMode(env.CCS_BROWSER_EVAL_MODE);
const effectiveEvalMode = envEvalMode ?? configEvalMode;
if (override.userDataDir) {
return {
@@ -241,7 +244,7 @@ export function getEffectiveClaudeBrowserAttachConfig(
userDataDir: override.userDataDir,
devtoolsPort: override.devtoolsPort ?? configPort,
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
evalMode: configEvalMode,
evalMode: effectiveEvalMode,
};
}
@@ -251,8 +254,8 @@ export function getEffectiveClaudeBrowserAttachConfig(
overrideActive: false,
userDataDir: configUserDataDir,
devtoolsPort: configPort,
hasExplicitDevtoolsPort: true,
evalMode: configEvalMode,
hasExplicitDevtoolsPort: configHasExplicitPort,
evalMode: effectiveEvalMode,
};
}
@@ -304,6 +307,15 @@ function parseDevtoolsPort(value?: string): number | undefined {
return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10));
}
function parseBrowserEvalMode(value?: string): BrowserEvalMode | undefined {
const trimmed = value?.trim();
if (trimmed === 'disabled' || trimmed === 'readonly' || trimmed === 'readwrite') {
return trimmed;
}
return undefined;
}
function normalizeDevtoolsPort(value: number | undefined): number {
if (!Number.isFinite(value)) {
return 9222;
+1 -5
View File
@@ -25,11 +25,7 @@ export function isLegacyGlmtBaseUrl(baseUrl: string | null | undefined): boolean
return false;
}
return (
normalized === LEGACY_GLMT_BASE_URL ||
normalized.includes('/api/coding/paas/v4') ||
normalized.endsWith('/chat/completions')
);
return normalized === LEGACY_GLMT_BASE_URL || normalized.includes('/api/coding/paas/v4');
}
export function normalizeDeprecatedGlmtEnv(env: Record<string, string>): GlmtNormalizationResult {
+1 -1
View File
@@ -99,7 +99,7 @@ export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions):
throw new Error('withRetry: baseDelayMs must be >= 0');
}
const isRetryable = retryableCheck;
const isRetryable = retryableCheck ?? defaultRetryableCheck;
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
-10
View File
@@ -19,8 +19,6 @@ import {
isDashboardWebSocketUpgradeAllowed,
} from './middleware/auth-middleware';
import { requestLoggingMiddleware } from './middleware/request-logging-middleware';
import { ensureManagedModelPrefixes } from '../cliproxy/ai-providers/managed-model-prefixes';
import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
import { shutdownUsageAggregator } from './usage/aggregator';
import { createLogger } from '../services/logging';
@@ -172,14 +170,6 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Start auto-sync watcher (if enabled in config)
startAutoSyncWatcher();
if (!getProxyTarget().isRemote) {
void ensureManagedModelPrefixes().catch((error) => {
logger.warn('cliproxy.prefix_sync_failed', 'Managed model prefix repair failed', {
error: error instanceof Error ? error.message : String(error),
});
});
}
// Combined cleanup function
const cleanup = () => {
wsCleanup();
+30 -4
View File
@@ -246,10 +246,36 @@ export function requireLocalAccessWhenAuthDisabled(
return true;
}
if (isLoopbackRemoteAddress(req.socket.remoteAddress)) {
return true;
if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
res.status(403).json({ error });
return false;
}
res.status(403).json({ error });
return false;
const host = parseHostHeader(getSingleHeader(req.headers.host));
if (!host || !isLoopbackHostname(host.hostname)) {
res.status(403).json({ error });
return false;
}
const originHeader = getSingleHeader(req.headers.origin);
if (originHeader) {
let origin: URL;
try {
origin = new URL(originHeader);
} catch {
res.status(403).json({ error });
return false;
}
const isSameHost = origin.host.toLowerCase() === host.host.toLowerCase();
const isLoopbackAlias =
isHttpOrigin(origin) && isLoopbackHostname(origin.hostname) && origin.port === host.port;
if (!isSameHost && !isLoopbackAlias) {
res.status(403).json({ error });
return false;
}
}
return true;
}
+6
View File
@@ -243,6 +243,12 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5,
},
'claude-opus-4-7-thinking': {
inputPerMillion: 5.0,
outputPerMillion: 25.0,
cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5,
},
// ---------------------------------------------------------------------------
// OpenAI Models - Source: better-ccusage
+60 -17
View File
@@ -348,6 +348,24 @@ export function getStartAuthNicknameError(
return null;
}
export function getReauthAccountTarget(
accountId: string | undefined,
existingAccounts: Array<{ id: string; nickname?: string }>
): { account?: { id: string; nickname?: string }; error?: string } {
if (!accountId) {
return {};
}
const account = existingAccounts.find((candidate) => candidate.id === accountId);
if (!account) {
return {
error: `Account '${accountId}' not found for this provider`,
};
}
return { account };
}
/**
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
@@ -623,6 +641,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const requestBody =
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const accountId =
typeof requestBody.accountId === 'string' ? requestBody.accountId.trim() : undefined;
const noIncognitoBody =
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
@@ -659,6 +679,16 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
return;
}
const localProvider = provider as CLIProxyProvider;
const existingAccounts = getProviderAccounts(localProvider);
const reauthTarget = getReauthAccountTarget(accountId, existingAccounts);
if (reauthTarget.error) {
res.status(404).json({ error: reauthTarget.error });
return;
}
const targetAccountId = reauthTarget.account?.id;
const effectiveNickname = nickname || reauthTarget.account?.nickname;
if (provider === 'kiro' && invalidKiroMethod) {
res.status(400).json({
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github, idc',
@@ -708,7 +738,6 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
}
try {
const localProvider = provider as CLIProxyProvider;
const knownTokenFiles = listProviderTokenSnapshots(localProvider);
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
@@ -738,7 +767,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const tokenSnapshot = findNewTokenSnapshot(
listProviderTokenSnapshots(localProvider),
knownTokenFiles
knownTokenFiles,
targetAccountId
);
if (!tokenSnapshot) {
res.status(409).json({
@@ -750,9 +780,9 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const account = registerAccountFromToken(
localProvider,
getProviderTokenDir(localProvider),
nickname,
effectiveNickname,
false,
tokenSnapshot.file
targetAccountId || tokenSnapshot.file
);
if (!account) {
res.status(409).json({
@@ -784,11 +814,11 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
}
}
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
nickname,
existingAccounts
localProvider,
effectiveNickname,
existingAccounts,
targetAccountId
);
if (nicknameError) {
res.status(400).json(nicknameError);
@@ -808,7 +838,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const account = await triggerOAuth(provider as CLIProxyProvider, {
add: true, // Always add mode from UI
headless: false, // Force interactive mode
nickname: nickname || undefined,
nickname: effectiveNickname || undefined,
expectedAccountId: targetAccountId,
acceptAgyRisk: provider === 'agy',
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
@@ -971,6 +1002,8 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
const requestBody =
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const accountId =
typeof requestBody.accountId === 'string' ? requestBody.accountId.trim() : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
const gitlabBaseUrl =
@@ -1037,11 +1070,20 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return;
}
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const localProvider = provider as CLIProxyProvider;
const existingAccounts = getProviderAccounts(localProvider);
const reauthTarget = getReauthAccountTarget(accountId, existingAccounts);
if (reauthTarget.error) {
res.status(404).json({ error: reauthTarget.error });
return;
}
const targetAccountId = reauthTarget.account?.id;
const effectiveNickname = nickname || reauthTarget.account?.nickname;
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
nickname,
existingAccounts
localProvider,
effectiveNickname,
existingAccounts,
targetAccountId
);
if (nicknameError) {
res.status(400).json(nicknameError);
@@ -1136,8 +1178,9 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
if (oauthState) {
rememberManualAuthState(oauthState, {
nickname: nickname || undefined,
knownTokenFiles: listProviderTokenSnapshots(provider as CLIProxyProvider),
nickname: effectiveNickname || undefined,
expectedAccountId: targetAccountId,
knownTokenFiles: listProviderTokenSnapshots(localProvider),
});
}
@@ -1237,7 +1280,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
getProviderTokenDir(localProvider),
pendingAuth.nickname,
false,
tokenSnapshot.file
pendingAuth.expectedAccountId || tokenSnapshot.file
);
if (!account) {
@@ -1386,7 +1429,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
getProviderTokenDir(localProvider),
pendingAuth.nickname,
false,
tokenSnapshot.file
pendingAuth.expectedAccountId || tokenSnapshot.file
);
if (!account) {
+29 -8
View File
@@ -116,9 +116,22 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}
}
res.writeHead(proxyStatus, proxyRes.headers);
// Manual streaming instead of pipe() for Bun runtime compatibility
proxyRes.on('data', (chunk: Buffer) => res.write(chunk));
proxyRes.on('end', () => res.end());
// Manual streaming instead of pipe() for Bun runtime compatibility.
// Explicitly honor downstream backpressure to avoid unbounded buffering.
const onDrain = () => proxyRes.resume();
proxyRes.on('data', (chunk: Buffer) => {
const canContinue = res.write(chunk);
if (!canContinue) {
proxyRes.pause();
res.once('drain', onDrain);
}
});
proxyRes.on('end', () => {
res.off('drain', onDrain);
res.end();
});
}
);
@@ -134,13 +147,21 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}
}
});
// Clean up proxy connection only when the client aborts the request.
// Avoid res.on('close') here because Bun may emit it during local error
// responses before the JSON body is flushed, which can truncate 502 payloads.
req.on('aborted', () => {
if (!res.writableEnded) {
const cleanupProxyRequest = () => {
if (!proxyReq.destroyed) {
proxyReq.destroy();
}
};
// Request-abort cleanup covers disconnects before the response starts.
req.on('aborted', cleanupProxyRequest);
// Response close cleanup covers disconnects while streaming the proxied response.
// Guard on writableEnded/finished so successful proxy completions are untouched.
res.on('close', () => {
if (!res.writableEnded || !res.finished) {
cleanupProxyRequest();
}
});
if (bodyBuffer) {
+7 -1
View File
@@ -18,6 +18,7 @@ import {
import cursorSettingsRoutes from './cursor-settings-routes';
import { getCursorConfig } from '../../config/config-loader-facade';
import { isDashboardWebSocketOriginAllowed } from '../middleware/auth-middleware';
const router = Router();
@@ -192,7 +193,12 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
/**
* POST /api/cursor/probe - Run a live authenticated runtime probe
*/
router.post('/probe', async (_req: Request, res: Response): Promise<void> => {
router.post('/probe', async (req: Request, res: Response): Promise<void> => {
if (!isDashboardWebSocketOriginAllowed(req)) {
res.status(403).json({ error: 'Cross-origin probe requests are not allowed.' });
return;
}
try {
const cursorConfig = getCursorConfig();
const result = await probeCursorRuntime(cursorConfig);
@@ -404,6 +404,9 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
if (!normalizedBackend || normalizedModel.length === 0) {
return acc;
}
if (!knownBackends.has(normalizedBackend)) {
throw new Error(`Unsupported provider backend "${backendId}".`);
}
acc[normalizedBackend] = normalizedModel;
return acc;
},
@@ -470,6 +473,10 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
res.json(await buildDashboardPayload());
} catch (error) {
if (error instanceof Error && error.message.startsWith('Unsupported provider backend')) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: (error as Error).message });
}
});
+9 -1
View File
@@ -6,6 +6,7 @@
*/
import { Router, Request, Response } from 'express';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import {
createApiProfile,
@@ -30,6 +31,9 @@ import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './rou
const router = Router();
const LOCAL_RUNTIME_READINESS_LOCAL_ACCESS_ERROR =
'Local runtime readiness requires localhost access when dashboard auth is disabled.';
function isDenylistError(message: string | undefined): boolean {
return typeof message === 'string' && message.toLowerCase().includes('denylist');
}
@@ -87,7 +91,11 @@ router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void =>
}
});
router.get('/local-runtime-readiness', async (_req: Request, res: Response): Promise<void> => {
router.get('/local-runtime-readiness', async (req: Request, res: Response): Promise<void> => {
if (!requireLocalAccessWhenAuthDisabled(req, res, LOCAL_RUNTIME_READINESS_LOCAL_ACCESS_ERROR)) {
return;
}
try {
res.json({ runtimes: await getLocalRuntimeReadiness() });
} catch (error) {
+18 -6
View File
@@ -22,7 +22,10 @@ import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
import { removeCcsImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-hook-utils';
import { resolveCliproxyBridgeMetadata } from '../../api/services';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import {
isLoopbackRemoteAddress,
requireLocalAccessWhenAuthDisabled,
} from '../middleware/auth-middleware';
import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
@@ -43,6 +46,7 @@ import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks';
import {
getCcsDir,
getImageAnalysisConfig,
isDashboardAuthEnabled,
loadConfigSafe,
loadOrCreateUnifiedConfig,
loadSettings,
@@ -155,6 +159,14 @@ function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
);
}
function canResolveSensitiveRuntimeStatus(req: Request): boolean {
if (isDashboardAuthEnabled()) {
return true;
}
return isLoopbackRemoteAddress(req.socket.remoteAddress);
}
function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } {
const message = error instanceof Error ? error.message.toLowerCase() : '';
@@ -498,17 +510,17 @@ router.get('/:profile', async (req: Request, res: Response): Promise<void> => {
const stat = fs.statSync(settingsPath);
const masked = maskApiKeys(settings);
const imageAnalysisStatus = canResolveSensitiveRuntimeStatus(req)
? await resolveImageAnalysisStatusForProfile(profile, settings, settingsPath)
: null;
res.json({
profile,
settings: masked,
mtime: stat.mtime.getTime(),
path: settingsPath,
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
imageAnalysisStatus: await resolveImageAnalysisStatusForProfile(
profile,
settings,
settingsPath
),
imageAnalysisStatus,
});
} catch (error) {
respondInternalError(res, error, 'Internal server error.');
@@ -231,8 +231,12 @@ function writeJsonDocument(
const tempPath = `${filePath}.tmp.${uniqueFileNonce()}`;
try {
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', {
encoding: 'utf8',
mode: 0o600,
});
fs.renameSync(tempPath, filePath);
fs.chmodSync(filePath, 0o600);
} catch (error) {
if (fs.existsSync(tempPath)) {
fs.rmSync(tempPath, { force: true });
+7 -2
View File
@@ -9,8 +9,13 @@ export interface ProfileScopedUsageData {
const PROFILE_NAME_REGEX = /^[A-Za-z0-9._-]+$/;
export function normalizeProfileQuery(profile?: string): string | undefined {
const value = profile?.trim();
export function normalizeProfileQuery(profile?: unknown): string | undefined {
if (profile === undefined) return undefined;
if (typeof profile !== 'string') {
throw new Error('Invalid profile filter');
}
const value = profile.trim();
if (!value || value === 'all') return undefined;
if (!PROFILE_NAME_REGEX.test(value)) {
throw new Error('Invalid profile filter');
+3 -2
View File
@@ -106,8 +106,9 @@ assert_case "skips at the larger configured window boundary" 0 false 3 \
assert_failure "rejects prerelease target tags" \
--target "v7.80.0-rc.1" --baseline "7.80.0" --window "2"
assert_failure "fails loudly when baseline tag is unavailable after baseline" \
--target "v7.81.2" --baseline "7.81.0" --window "2"
assert_case "skips deprecated publish when baseline tag is unavailable after baseline" 0 false 2 \
"v7.81.2" "7.81.0" "2" \
$'v7.80.0'
echo ""
echo "Dashboard sunset guard tests complete: ${PASS} passed, ${FAIL} failed."
+52
View File
@@ -159,6 +159,54 @@ MOCK_EOF
chmod +x "${MOCK_DIR}/docker"
}
make_mock_docker_platform_raw_index() {
# First inspect returns a multi-arch index; digest inspect returns the
# platform manifest with real layer sizes. This mirrors GHCR OCI output.
cat > "${MOCK_DIR}/docker" <<'MOCK_EOF'
#!/usr/bin/env bash
if [[ "$1" == "buildx" && "$2" == "imagetools" && "$3" == "inspect" ]]; then
ref="$4"
if [[ "$ref" == "mock-image:tag" ]]; then
cat <<'JSON'
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:amd64digest",
"platform": { "os": "linux", "architecture": "amd64" }
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:arm64digest",
"platform": { "os": "linux", "architecture": "arm64" }
}
]
}
JSON
exit 0
fi
if [[ "$ref" == "mock-image:tag@sha256:amd64digest" ]]; then
cat <<'JSON'
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"layers": [
{ "size": 100000000 },
{ "size": 57671680 }
]
}
JSON
exit 0
fi
exit 1
fi
exit 0
MOCK_EOF
chmod +x "${MOCK_DIR}/docker"
}
run_platform_test() {
local name="$1"
local expected_exit="$2"
@@ -190,6 +238,10 @@ run_platform_test "--platform: pass when platform-scoped size < budget" 0 "20971
make_mock_docker_platform "150000000 112000000"
run_platform_test "--platform: fail when platform-scoped size > budget" 1 "209715200"
# --platform raw OCI index: resolve the platform digest then sum manifest layers
make_mock_docker_platform_raw_index
run_platform_test "--platform: resolves raw OCI index before summing layers" 0 "209715200"
# --platform inspect failure → must exit 1 (REV5 regression guard)
make_mock_docker_platform_fail
run_platform_test "--platform: fail loudly when imagetools inspect fails (REV5 guard)" 1 "209715200"
+72 -12
View File
@@ -50,27 +50,87 @@ fi
MAX_MB=$(( MAX_BYTES / 1048576 ))
sum_manifest_layer_sizes() {
local sum
sum="$(jq -sr '
.[0] as $manifest
| if ($manifest | type) == "object" and ($manifest.layers | type) == "array" then
([$manifest.layers[]?.size | numbers] | add) // 0
else
0
end
' 2>/dev/null)" || {
echo "0"
return
}
echo "${sum:-0}"
}
select_platform_digest() {
local os="$1"
local arch="$2"
local variant="$3"
jq -r \
--arg os "$os" \
--arg arch "$arch" \
--arg variant "$variant" \
'
.manifests[]?
| select((.platform.os // "") == $os)
| select((.platform.architecture // "") == $arch)
| select($variant == "" or (.platform.variant // "") == $variant)
| .digest
' 2>/dev/null | head -1 || true
}
if [[ -n "$PLATFORM" ]]; then
# Multi-arch path: sum compressed layer sizes from the registry manifest.
# `docker buildx imagetools inspect --format` returns the manifest JSON for
# the given platform. We sum the `size` field of each layer entry.
# Read raw OCI/Docker manifests directly. Multi-arch tags first resolve the
# requested platform digest from the index, then sum that manifest's layer
# sizes. A single-platform tag can be summed immediately from its raw manifest.
if ! command -v jq >/dev/null 2>&1; then
echo "[X] jq is required for platform-scoped image size inspection" >&2
exit 1
fi
if [[ "$PLATFORM" != */* ]]; then
echo "[X] --platform must use os/arch format, got: ${PLATFORM}" >&2
exit 1
fi
PLATFORM_OS="${PLATFORM%%/*}"
PLATFORM_REST="${PLATFORM#*/}"
PLATFORM_ARCH="${PLATFORM_REST%%/*}"
PLATFORM_VARIANT=""
if [[ "$PLATFORM_REST" == */* ]]; then
PLATFORM_VARIANT="${PLATFORM_REST#*/}"
fi
echo "[i] Inspecting ${IMAGE} for platform ${PLATFORM} via registry manifest..." >&2
ACTUAL_BYTES=$(
docker buildx imagetools inspect "${IMAGE}" \
--format "{{ range .Manifest.Layers }}{{ .Size }} {{ end }}" \
--raw 2>/dev/null \
| tr ' ' '\n' \
| awk 'NF && /^[0-9]+$/ { sum += $1 } END { print sum+0 }' \
2>/dev/null || echo ""
)
RAW_MANIFEST="$(docker buildx imagetools inspect "${IMAGE}" --raw 2>/dev/null || true)"
ACTUAL_BYTES="$(printf '%s' "$RAW_MANIFEST" | sum_manifest_layer_sizes)"
if [[ -z "$ACTUAL_BYTES" || "$ACTUAL_BYTES" == "0" ]]; then
# Fallback: try the platform-specific sub-manifest
PLATFORM_DIGEST="$(printf '%s' "$RAW_MANIFEST" | select_platform_digest "$PLATFORM_OS" "$PLATFORM_ARCH" "$PLATFORM_VARIANT")"
if [[ -n "$PLATFORM_DIGEST" && "$PLATFORM_DIGEST" != "null" ]]; then
# Fallback: inspect the selected platform sub-manifest.
echo "[i] Falling back to platform-scoped manifest ${PLATFORM_DIGEST}..." >&2
RAW_PLATFORM_MANIFEST="$(docker buildx imagetools inspect "${IMAGE}@${PLATFORM_DIGEST}" --raw 2>/dev/null || true)"
ACTUAL_BYTES="$(printf '%s' "$RAW_PLATFORM_MANIFEST" | sum_manifest_layer_sizes)"
else
ACTUAL_BYTES="0"
fi
fi
if [[ -z "$ACTUAL_BYTES" || "$ACTUAL_BYTES" == "0" ]]; then
# Last resort: support older buildx versions that expose layer sizes only
# through the templated manifest object.
echo "[i] Falling back to platform-scoped imagetools inspect..." >&2
ACTUAL_BYTES=$(
docker buildx imagetools inspect "${IMAGE}@$(
docker buildx imagetools inspect "${IMAGE}" \
--format "{{ range .Manifest.Manifests }}{{ if eq .Platform.OS \"$(echo "${PLATFORM}" | cut -d/ -f1)\" }}{{ if eq .Platform.Architecture \"$(echo "${PLATFORM}" | cut -d/ -f2)\" }}{{ .Digest }}{{ end }}{{ end }}{{ end }}" \
--format "{{ range .Manifest.Manifests }}{{ if eq .Platform.OS \"${PLATFORM_OS}\" }}{{ if eq .Platform.Architecture \"${PLATFORM_ARCH}\" }}{{ .Digest }}{{ end }}{{ end }}{{ end }}" \
2>/dev/null | head -1
)" --format "{{ range .Manifest.Layers }}{{ .Size }} {{ end }}" 2>/dev/null \
| tr ' ' '\n' \
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Unit test for network-contract.sh Docker Compose env and args.
# Uses a fake docker binary; no Docker daemon required.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="${SCRIPT_DIR}/network-contract.sh"
PASS=0
FAIL=0
MOCK_DIR="$(mktemp -d)"
LOG_FILE="${MOCK_DIR}/docker.log"
COMPOSE_FILE="${MOCK_DIR}/compose.yaml"
trap 'rm -rf "$MOCK_DIR"' EXIT
cat > "$COMPOSE_FILE" <<'YAML'
services:
ccs:
image: ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}
YAML
cat > "${MOCK_DIR}/docker" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
printf 'cmd=%s env_image=%s env_dashboard=%s env_cliproxy=%s args=%s\n' \
"$1" "${CCS_IMAGE:-}" "${CCS_DASHBOARD_PORT:-}" "${CCS_CLIPROXY_PORT:-}" "$*" \
>> "$DOCKER_MOCK_LOG"
if [[ "$1" == "compose" ]]; then
if printf '%s\n' "$*" | grep -q 'ps --format json'; then
printf '[{"Service":"ccs","Health":"healthy"}]\n'
fi
exit 0
fi
if [[ "$1" == "network" && "$2" == "inspect" && "$3" == "ccs-net" ]]; then
exit 0
fi
if [[ "$1" == "run" ]]; then
exit 0
fi
exit 1
MOCK
chmod +x "${MOCK_DIR}/docker"
run_test() {
local name="$1"
shift
if "$@"; then
echo "[OK] ${name}"
(( PASS++ )) || true
else
echo "[X] ${name}"
(( FAIL++ )) || true
fi
}
run_contract() {
PATH="${MOCK_DIR}:${PATH}" \
DOCKER_MOCK_LOG="$LOG_FILE" \
bash "$SCRIPT" "$COMPOSE_FILE" "ghcr.io/kaitranntt/ccs:test" >/dev/null
}
echo ""
echo "Running network-contract.sh env tests..."
echo ""
run_contract
run_test "compose up receives safe host ports and image override" \
grep -q 'env_image=ghcr.io/kaitranntt/ccs:test env_dashboard=13001 env_cliproxy=18318 args=compose -f .* up -d --remove-orphans' "$LOG_FILE"
run_test "compose ps receives the same safe host ports" \
grep -q 'env_image=ghcr.io/kaitranntt/ccs:test env_dashboard=13001 env_cliproxy=18318 args=compose -f .* ps --format json' "$LOG_FILE"
run_test "compose down removes volumes and orphans through cleanup trap" \
grep -q 'env_image=ghcr.io/kaitranntt/ccs:test env_dashboard=13001 env_cliproxy=18318 args=compose -f .* down -v --remove-orphans' "$LOG_FILE"
run_test "sibling probes keep the ccs-net DNS contract" \
grep -q 'args=run --rm --network ccs-net curlimages/curl:latest -fsS --max-time 10 http://ccs:8317/' "$LOG_FILE"
run_test "dashboard sibling probe keeps internal port 3000" \
grep -q 'args=run --rm --network ccs-net curlimages/curl:latest -fsS --max-time 10 http://ccs:3000/' "$LOG_FILE"
echo ""
echo "Results: ${PASS} passed, ${FAIL} failed"
echo ""
if [[ "$FAIL" -gt 0 ]]; then
exit 1
fi
+25 -10
View File
@@ -19,6 +19,8 @@ set -euo pipefail
COMPOSE_FILE="${1:-docker/compose.yaml}"
IMAGE_OVERRIDE="${2:-}"
DASHBOARD_HOST_PORT="${CCS_NETWORK_CONTRACT_DASHBOARD_PORT:-${CCS_DASHBOARD_PORT:-13001}}"
CLIPROXY_HOST_PORT="${CCS_NETWORK_CONTRACT_CLIPROXY_PORT:-${CCS_CLIPROXY_PORT:-18318}}"
# ---------------------------------------------------------------------------
# Helpers
@@ -27,22 +29,35 @@ log() { printf '[i] %s\n' "$*"; }
ok() { printf '[OK] %s\n' "$*"; }
err() { printf '[X] %s\n' "$*" >&2; }
compose() {
if [[ -n "$IMAGE_OVERRIDE" ]]; then
CCS_IMAGE="$IMAGE_OVERRIDE" \
CCS_DASHBOARD_PORT="$DASHBOARD_HOST_PORT" \
CCS_CLIPROXY_PORT="$CLIPROXY_HOST_PORT" \
docker compose -f "$COMPOSE_FILE" "$@"
return
fi
CCS_DASHBOARD_PORT="$DASHBOARD_HOST_PORT" \
CCS_CLIPROXY_PORT="$CLIPROXY_HOST_PORT" \
docker compose -f "$COMPOSE_FILE" "$@"
}
cleanup() {
log "Tearing down stack..."
compose down -v --remove-orphans 2>/dev/null || true
}
trap cleanup EXIT
# ---------------------------------------------------------------------------
# Bring stack up; register teardown on any exit
# ---------------------------------------------------------------------------
log "Bringing CCS stack up: $COMPOSE_FILE"
log "Using host ports: dashboard=${DASHBOARD_HOST_PORT}, cliproxy=${CLIPROXY_HOST_PORT}"
if [[ -n "$IMAGE_OVERRIDE" ]]; then
log "Overriding image with: $IMAGE_OVERRIDE"
CCS_IMAGE="$IMAGE_OVERRIDE" docker compose -f "$COMPOSE_FILE" up -d
else
docker compose -f "$COMPOSE_FILE" up -d
fi
cleanup() {
log "Tearing down stack..."
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
}
trap cleanup EXIT
compose up -d --remove-orphans
# ---------------------------------------------------------------------------
# Wait for healthcheck (max 90s) — use jq instead of python3 for CI portability
@@ -52,7 +67,7 @@ WAIT_MAX=45 # 45 x 2s = 90s
HEALTHY=0
for _i in $(seq 1 "$WAIT_MAX"); do
STATUS=$(
docker compose -f "$COMPOSE_FILE" ps --format json 2>/dev/null \
compose ps --format json 2>/dev/null \
| jq -r 'if type == "array" then .[] else . end | select(.Service != null and (.Service | contains("ccs"))) | .Health // "unknown"' \
2>/dev/null | head -1 || echo "unknown"
)
@@ -31,13 +31,51 @@ afterEach(async () => {
});
describe('cursor daemon lifecycle smoke', () => {
it('requires Anthropic caller auth token when credentials are present', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
saveCredentials({
accessToken: 'a'.repeat(60),
machineId: '1234567890abcdef1234567890abcdef',
authMethod: 'manual',
importedAt: new Date().toISOString(),
});
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
const daemonToken = result.daemonToken as string;
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'x-ccs-cursor-token': daemonToken,
},
body: JSON.stringify({
model: 'claude-sonnet-4.5',
max_tokens: 64,
messages: [{ role: 'user', content: 'hello' }],
}),
});
expect(response.status).toBe(401);
const body = (await response.json()) as {
type?: string;
error?: { type?: string; message?: string };
};
expect(body.type).toBe('error');
expect(body.error?.type).toBe('authentication_error');
expect(body.error?.message).toContain('Invalid Anthropic auth token');
});
it('starts, serves expected routes, and stops cleanly', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
expect(result.pid).toBeDefined();
const daemonToken = result.daemonToken as string;
expect(await isDaemonRunning(port)).toBe(true);
expect(await isDaemonRunning(port, daemonToken)).toBe(true);
const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`);
expect(modelsResponse.status).toBe(200);
@@ -47,7 +85,7 @@ describe('cursor daemon lifecycle smoke', () => {
const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'x-ccs-cursor-token': daemonToken },
body: JSON.stringify({
model: 'gpt-4.1',
messages: [{ role: 'user', content: 'hello' }],
@@ -60,6 +98,7 @@ describe('cursor daemon lifecycle smoke', () => {
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'x-ccs-cursor-token': daemonToken,
},
body: JSON.stringify({
model: 'claude-sonnet-4.5',
@@ -78,15 +117,18 @@ describe('cursor daemon lifecycle smoke', () => {
const stopResult = await stopDaemon();
expect(stopResult.success).toBe(true);
expect(await isDaemonRunning(port)).toBe(false);
expect(await isDaemonRunning(port, daemonToken)).toBe(false);
}, 35000);
it('returns 404 for unknown routes', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
const daemonToken = result.daemonToken as string;
const response = await fetch(`http://127.0.0.1:${port}/unknown`);
const response = await fetch(`http://127.0.0.1:${port}/unknown`, {
headers: { 'x-ccs-cursor-token': daemonToken },
});
expect(response.status).toBe(404);
});
@@ -103,10 +145,11 @@ describe('cursor daemon lifecycle smoke', () => {
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
const daemonToken = result.daemonToken as string;
const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'x-ccs-cursor-token': daemonToken },
body: JSON.stringify({
model: 'gpt-4.1',
messages: [{ role: 'user', content: 'hello' }],
@@ -122,17 +165,19 @@ describe('cursor daemon lifecycle smoke', () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
const daemonToken = result.daemonToken as string;
const tokenHeader = { 'x-ccs-cursor-token': daemonToken };
const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...tokenHeader },
body: '{invalid-json',
});
expect(invalidJson.status).toBe(400);
const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...tokenHeader },
body: JSON.stringify({
model: 'gpt-4.1',
messages: { role: 'user', content: 'hello' },
@@ -145,6 +190,7 @@ describe('cursor daemon lifecycle smoke', () => {
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
...tokenHeader,
},
body: JSON.stringify({
model: 'claude-sonnet-4.5',
@@ -163,7 +209,7 @@ describe('cursor daemon lifecycle smoke', () => {
const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...tokenHeader },
body: JSON.stringify({
model: 'gpt-4.1',
messages: [
@@ -6,6 +6,7 @@ import * as path from 'path';
import getPort from 'get-port';
import {
getOpenAICompatProxyStatus,
isOpenAICompatProxyRunning,
listOpenAICompatProxyStatuses,
startOpenAICompatProxy,
stopOpenAICompatProxy,
@@ -919,6 +920,62 @@ describe('openai proxy daemon lifecycle', () => {
expect(secondStart.authToken).not.toBe('stale-token-a');
});
it('stops pid-only profile daemons instead of only deleting their state', async () => {
const port = await getPort();
const settingsPath = path.join(tempDir, 'pid-only-stop.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
ANTHROPIC_AUTH_TOKEN: 'sk-pid-only-stop',
ANTHROPIC_MODEL: 'gpt-4.1',
},
}),
'utf8'
);
const profile = resolveOpenAICompatProfileConfig('pid-only-stop', settingsPath, {
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
ANTHROPIC_AUTH_TOKEN: 'sk-pid-only-stop',
ANTHROPIC_MODEL: 'gpt-4.1',
});
if (!profile) {
throw new Error('Expected pid-only-stop OpenAI-compatible profile');
}
const started = await startOpenAICompatProxy(profile, { port });
expect(started.success).toBe(true);
expect(started.pid).toBeDefined();
const pid = started.pid;
if (!pid) {
throw new Error('Expected pid-only-stop daemon pid');
}
try {
fs.unlinkSync(getOpenAICompatProxySessionPath('pid-only-stop'));
const statuses = await listOpenAICompatProxyStatuses();
expect(statuses).toContainEqual({
running: false,
profileName: 'pid-only-stop',
pid,
});
const stopped = await stopOpenAICompatProxy();
expect(stopped.success).toBe(true);
expect(fs.existsSync(getOpenAICompatProxyPidPath('pid-only-stop'))).toBe(false);
expect(await isOpenAICompatProxyRunning(port, 'pid-only-stop')).toBe(false);
} finally {
try {
process.kill(pid, 'SIGKILL');
} catch {
// Already stopped.
}
}
}, 35000);
it('replaces pid-only proxy state before starting a new daemon', async () => {
const firstPort = await getPort();
const replacementPort = await getPort();
@@ -206,9 +206,8 @@ describe('GET /api/codex/profiles', () => {
// 127.0.0.1 and the test client connects to 127.0.0.1, the built-in fetch
// will always be loopback. We test the guard directly via a separate
// Express app that injects a non-loopback remote address.
const { requireLocalAccessWhenAuthDisabled } = await import(
'../../../src/web-server/middleware/auth-middleware'
);
const { requireLocalAccessWhenAuthDisabled } =
await import('../../../src/web-server/middleware/auth-middleware');
const { isDashboardAuthEnabled } = await import('../../../src/config/config-loader-facade');
if (!isDashboardAuthEnabled()) {
@@ -245,6 +244,43 @@ describe('GET /api/codex/profiles', () => {
}
});
it('returns 403 for loopback remote addresses when host/origin indicate non-local origin', async () => {
const { requireLocalAccessWhenAuthDisabled } =
await import('../../../src/web-server/middleware/auth-middleware');
const { isDashboardAuthEnabled } = await import('../../../src/config/config-loader-facade');
if (!isDashboardAuthEnabled()) {
let guardResult: boolean | undefined;
const testApp = express();
testApp.get('/test', (req, res) => {
Object.defineProperty(req, 'socket', {
value: { remoteAddress: '127.0.0.1' },
writable: true,
configurable: true,
});
req.headers.host = 'attacker.example.test';
req.headers.origin = 'http://attacker.example.test';
guardResult = requireLocalAccessWhenAuthDisabled(req, res, 'localhost only');
if (guardResult) {
res.json({ ok: true });
}
});
const testServer = await new Promise<http.Server>((resolve) => {
const s = testApp.listen(0, '127.0.0.1', () => resolve(s));
});
const testPort = (testServer.address() as { port: number }).port;
const res = await fetch(`http://127.0.0.1:${testPort}/test`);
await new Promise<void>((resolve) => testServer.close(() => resolve()));
expect(res.status).toBe(403);
expect(guardResult).toBe(false);
}
});
it('response body contains no token substrings for a valid profile', async () => {
const instancesDir = path.join(ccsDir, 'codex-instances');
const workDir = path.join(instancesDir, 'work');
+1
View File
@@ -97,6 +97,7 @@ describe('ProfileDetector', () => {
expect(detector.detectProfileType('gitlab').provider).toBe('gitlab');
expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy');
expect(detector.detectProfileType('kilo').provider).toBe('kilo');
expect(detector.detectProfileType('qoder').provider).toBe('qoder');
});
it('should detect settings-based profile from unified config', () => {
@@ -143,6 +143,19 @@ describe('ensureSharedConfigSymlink', () => {
expect(stderrChunks.join('')).toContain('symlink unavailable');
});
it('rethrows symlink errors that are not fallback-safe', () => {
fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 });
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => {
throw Object.assign(new Error('simulated race'), { code: 'EEXIST' });
});
try {
expect(() => ensureSharedConfigSymlink(profileDir, sharedConfigPath)).toThrow(/simulated race/);
} finally {
symlinkSpy.mockRestore();
}
});
it('preserves edited fallback copies on later repair attempts', () => {
fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 });
const linkPath = path.join(profileDir, 'config.toml');
@@ -8,7 +8,7 @@
* - cliproxy-format source rejects with clear message
* - torn-write retry: truncated JSON twice then full succeeds on 3rd read
* - persistent torn state clean error, not silent corruption
* - pgrep mock returning PID warns + refuses without --force-while-running
* - process-table Codex PID warns + refuses without --force-while-running
* - --force-while-running bypasses pgrep check
* - --with-history copies history.jsonl + sessions/
* - --with-history default false not copied
@@ -54,9 +54,9 @@ beforeEach(() => {
process.env.CCS_HOME = ccsHome;
process.env.LEGACY_CODEX_HOME = legacyCodexHome;
// Default: pgrep finds nothing (no Codex running). Tests that need a positive
// result override this per-test. Without this default, `pgrep -f codex` on a
// dev machine matches the Claude Code process itself and exits early with code 7.
// Default: process-table lookup finds nothing (no Codex running). Tests that
// need a positive result override this per-test. This keeps local developer
// processes from affecting import-default tests.
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 1,
stdout: '',
@@ -78,9 +78,8 @@ afterEach(() => {
});
async function makeCtx() {
const { CodexProfileRegistry } = await import(
'../../../../src/codex-auth/codex-profile-registry'
);
const { CodexProfileRegistry } =
await import('../../../../src/codex-auth/codex-profile-registry');
return {
registry: new CodexProfileRegistry(),
version: '0.0.0-test',
@@ -128,21 +127,10 @@ function captureOutput(): { stderr: string[]; restore: () => void } {
};
}
function mockProcessTable(pgrepStdout: string, psStdout: string) {
function mockProcessTable(psStdout: string) {
spyOn(childProcess, 'spawnSync').mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(cmd: string, _args: string[]): any => {
if (cmd === 'pgrep') {
return {
status: pgrepStdout.trim().length > 0 ? 0 : 1,
stdout: pgrepStdout,
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
};
}
if (cmd === 'ps') {
return {
status: psStdout.trim().length > 0 ? 0 : 1,
@@ -171,9 +159,8 @@ function mockProcessTable(pgrepStdout: string, psStdout: string) {
describe('import-default — missing legacy auth.json', () => {
it('exits with clear error when ~/.codex/auth.json does not exist', async () => {
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
let exitCalled = false;
@@ -201,9 +188,8 @@ describe('import-default — option validation', () => {
it('rejects unsupported flags before importing legacy auth', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
let exitCount = 0;
@@ -252,9 +238,8 @@ describe('import-default — profile collision without --force', () => {
// Write valid legacy auth
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
// Pre-create the profile
ctx.registry.createProfile('myprofile');
@@ -284,9 +269,8 @@ describe('import-default — --force overwrites and creates backup', () => {
// Write valid legacy auth
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
// First import (no --force needed since profile doesn't exist)
@@ -338,9 +322,8 @@ describe('import-default — cliproxy-format rejection', () => {
});
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), cliproxyAuth);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
let exitCalled = false;
@@ -370,9 +353,8 @@ describe('import-default — torn-write retry', () => {
// Write truncated JSON initially — simulates torn write mid-file
fs.writeFileSync(authPath, '{truncated');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
// After 50ms (before 2nd retry at 100ms) replace with valid JSON
@@ -397,9 +379,8 @@ describe('import-default — torn-write retry', () => {
// Write persistently invalid JSON — all retries will fail
fs.writeFileSync(authPath, '{always-truncated');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
let exitCalled = false;
@@ -426,9 +407,8 @@ describe('import-default — torn-write retry', () => {
const authPath = path.join(legacyCodexHome, 'auth.json');
fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: 'header.not-json.sig' } }));
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
let exitCalled = false;
@@ -455,9 +435,8 @@ describe('import-default — torn-write retry', () => {
const authPath = path.join(legacyCodexHome, 'auth.json');
fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: 'h.e30$.s' } }));
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
let exitCalled = false;
@@ -485,9 +464,8 @@ describe('import-default — torn-write retry', () => {
const [header, payload] = VALID_JWT.split('.');
fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: `${header}.${payload}.a` } }));
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
let exitCalled = false;
@@ -512,14 +490,13 @@ describe('import-default — torn-write retry', () => {
});
describe('import-default — Codex running detection', () => {
it('warns and refuses when pgrep finds a codex PID', async () => {
it('warns and refuses when process table finds a same-user Codex PID', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
mockProcessTable(`12345 ${process.getuid()} /usr/local/bin/codex login\n`);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
let exitCalled = false;
@@ -546,11 +523,10 @@ describe('import-default — Codex running detection', () => {
it('proceeds with --force-while-running even when Codex is running', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
mockProcessTable(`12345 ${process.getuid()} /usr/local/bin/codex login\n`);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
const restore = silenceConsole();
@@ -567,11 +543,10 @@ describe('import-default — Codex running detection', () => {
it('warns and refuses when Codex is running through a node shim', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable('12345\n', '12345 /usr/bin/node /usr/local/bin/codex login\n');
mockProcessTable(`12345 ${process.getuid()} /usr/bin/node /usr/local/bin/codex login\n`);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
let exitCalled = false;
@@ -595,14 +570,13 @@ describe('import-default — Codex running detection', () => {
expect(ctx.registry.hasProfile('nodeshim')).toBe(false);
});
it('ignores pgrep false positives that are not Codex executables', async () => {
it('ignores process-table false positives that are not Codex executables', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable('11111\n', '11111 /usr/bin/node /tmp/codex-auth-helper.js\n');
mockProcessTable(`11111 ${process.getuid()} /usr/bin/node /tmp/codex-auth-helper.js\n`);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
const restore = silenceConsole();
@@ -614,6 +588,47 @@ describe('import-default — Codex running detection', () => {
expect(ctx.registry.hasProfile('falsepositive')).toBe(true);
});
it('ignores same-process codex-runtime invocation paths', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable(
`${process.pid} ${process.getuid()} node /workspace/ccs/dist/bin/codex-runtime.js auth import-default self\n`
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['selfmatch']);
} finally {
restore();
}
expect(ctx.registry.hasProfile('selfmatch')).toBe(true);
});
it('ignores Codex processes owned by another uid', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const otherUid = process.getuid() + 1;
mockProcessTable(`22222 ${otherUid} /usr/local/bin/codex login\n`);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['otheruid']);
} finally {
restore();
}
expect(ctx.registry.hasProfile('otheruid')).toBe(true);
});
});
describe('import-default — --with-history', () => {
@@ -624,9 +639,8 @@ describe('import-default — --with-history', () => {
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(path.join(sessionsDir, 'sess1.json'), '{}');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
const restore = silenceConsole();
@@ -645,9 +659,8 @@ describe('import-default — --with-history', () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
fs.writeFileSync(path.join(legacyCodexHome, 'history.jsonl'), '{"prompt":"hello"}\n');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
const restore = silenceConsole();
@@ -666,9 +679,8 @@ describe('import-default — atomic write', () => {
it('leaves no tmp file after successful import', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
const restore = silenceConsole();
@@ -689,9 +701,8 @@ describe('import-default — happy path end-to-end', () => {
it('registers profile with decoded email in registry', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const { handleImportDefaultCodex } =
await import('../../../../src/codex-auth/commands/import-default-command');
const ctx = await makeCtx();
const restore = silenceConsole();
@@ -85,6 +85,7 @@ describe('completion backend', () => {
expect(values).toContain('gitlab');
expect(values).toContain('codebuddy');
expect(values).toContain('kilo');
expect(values).toContain('qoder');
expect(values).toContain('localglm');
expect(values).toContain('work');
expect(values).toContain('my-codex');
@@ -2,35 +2,32 @@ import { describe, expect, it } from 'bun:test';
import { parseChannelsCommandArgs } from '../../../src/commands/config-channels-command';
describe('config channels command parser', () => {
it('parses selection, unattended mode, and token input', () => {
it('parses selection, unattended mode, and token channel input', () => {
const result = parseChannelsCommandArgs([
'--set',
'telegram,discord',
'--unattended',
'--set-token',
'telegram=telegram-secret',
'telegram',
]);
expect(result.setSelection).toBe('telegram,discord');
expect(result.unattended).toBe(true);
expect(result.setToken).toEqual({
channelId: 'telegram',
token: 'telegram-secret',
});
expect(result.setTokenChannel).toBe('telegram');
});
it('supports inline token assignment, legacy flags, and clear-token variants', () => {
it('supports legacy flags and clear-token variants', () => {
const result = parseChannelsCommandArgs([
'--disable',
'--no-unattended',
'--set-token=abc',
'--set-token=discord',
]);
const clearAll = parseChannelsCommandArgs(['--clear-token']);
const clearOne = parseChannelsCommandArgs(['--clear-token', 'discord']);
expect(result.disable).toBe(true);
expect(result.noUnattended).toBe(true);
expect(result.setToken).toEqual({ channelId: 'discord', token: 'abc' });
expect(result.setTokenChannel).toBe('discord');
expect(clearAll.clearTokenAll).toBe(true);
expect(clearOne.clearTokenChannel).toBe('discord');
});
@@ -61,6 +61,7 @@ describe('help command parity', () => {
expect(rendered.includes('gitlab')).toBe(true);
expect(rendered.includes('codebuddy')).toBe(true);
expect(rendered.includes('kilo')).toBe(true);
expect(rendered.includes('qoder')).toBe(true);
expect(rendered.includes('--gitlab-token-login')).toBe(true);
expect(rendered.includes('--token-login')).toBe(true);
expect(rendered.includes('--gitlab-url <url>')).toBe(true);
@@ -415,6 +415,43 @@ describe('persist command Claude extension parity', () => {
expect(renderedLogs).toContain('Native Codex target: ccsxp or ccs codex --target codex');
});
it('does not fail after writing settings when a cleared env value is deeply nested', async () => {
await writeUnifiedConfig();
const settingsPath = path.join(tempRoot, '.claude', 'settings.json');
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
const deepValue = `${'{"nested":'.repeat(20000)}"leaf"${'}'.repeat(20000)}`;
await fs.promises.writeFile(
settingsPath,
`{"env":{"KEEP_ME":"still-here","ANTHROPIC_AUTH_TOKEN":${deepValue}}}\n`,
'utf8'
);
const originalConsoleLog = console.log;
const capturedLogs: string[] = [];
console.log = (...args: unknown[]) => {
capturedLogs.push(args.map((arg) => String(arg)).join(' '));
};
try {
await withScopedHome(() => handlePersistCommand(['default', '--yes']));
} finally {
console.log = originalConsoleLog;
}
const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as {
env: Record<string, string>;
};
expect(persisted.env.KEEP_ME).toBe('still-here');
expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
const renderedLogs = capturedLogs.join('\n');
expect(renderedLogs).toContain("Profile 'default' written to");
expect(renderedLogs).toContain('Config Receipt');
expect(renderedLogs).toContain('Codex translator URL: not found');
expect(renderedLogs).not.toContain('Failed to write settings');
});
it('warns in the persist receipt when a Codex translator URL remains in settings', async () => {
await writeUnifiedConfig();
+1 -1
View File
@@ -197,7 +197,7 @@ describe('isDaemonRunning', () => {
throw new Error('Unable to resolve test server port');
}
const result = await isDaemonRunning(address.port);
const result = await isDaemonRunning(address.port, "bad-token");
expect(result).toBe(false);
} finally {
await new Promise<void>((resolve) => {
@@ -46,11 +46,12 @@ describe('cursor-profile-executor', () => {
sonnet_model: 'cursor-sonnet',
haiku_model: 'cursor-haiku',
},
'test-token',
'/tmp/claude-config'
);
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:20129');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('cursor-opus');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('cursor-sonnet');

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