diff --git a/.github/pr-assets/1390/after-disclaimer-as-tooltip.png b/.github/pr-assets/1390/after-disclaimer-as-tooltip.png new file mode 100644 index 00000000..2e5c8ce4 Binary files /dev/null and b/.github/pr-assets/1390/after-disclaimer-as-tooltip.png differ diff --git a/.github/pr-assets/1390/before-disclaimer-takes-space.png b/.github/pr-assets/1390/before-disclaimer-takes-space.png new file mode 100644 index 00000000..0c64f529 Binary files /dev/null and b/.github/pr-assets/1390/before-disclaimer-takes-space.png differ diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 28b41d1f..ed6d044d 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -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 diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index c69cf972..a4c95d3a 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57e60922..120a14ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b31c04ef..dca0b900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/config/base-kiro.settings.json b/config/base-kiro.settings.json index e84d0b18..6243c415 100644 --- a/config/base-kiro.settings.json +++ b/config/base-kiro.settings.json @@ -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" } } diff --git a/config/base-qoder.settings.json b/config/base-qoder.settings.json new file mode 100644 index 00000000..d4ade6d3 --- /dev/null +++ b/config/base-qoder.settings.json @@ -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" + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile index ab5a4d9a..96710be9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/Dockerfile.integrated b/docker/Dockerfile.integrated index 7d1bd03c..3ef0a74f 100644 --- a/docker/Dockerfile.integrated +++ b/docker/Dockerfile.integrated @@ -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"] diff --git a/docker/README.md b/docker/README.md index 000d3583..6c3ba275 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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://: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://: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: diff --git a/docker/compose.yaml b/docker/compose.yaml index 9d27448a..54bcd323 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -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 diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index bfb0d2f2..96ea4cb5 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -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(() => {}) diff --git a/package.json b/package.json index d8a507f0..84b1035f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/docker-dashboard-sunset-guard.js b/scripts/docker-dashboard-sunset-guard.js index 0561bd81..e9d4fccd 100755 --- a/scripts/docker-dashboard-sunset-guard.js +++ b/scripts/docker-dashboard-sunset-guard.js @@ -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)) { diff --git a/scripts/github/stable-release-issue-cleanup-lib.mjs b/scripts/github/stable-release-issue-cleanup-lib.mjs new file mode 100644 index 00000000..64d7057d --- /dev/null +++ b/scripts/github/stable-release-issue-cleanup-lib.mjs @@ -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(); +} diff --git a/scripts/github/stable-release-issue-cleanup.mjs b/scripts/github/stable-release-issue-cleanup.mjs new file mode 100644 index 00000000..11d9c597 --- /dev/null +++ b/scripts/github/stable-release-issue-cleanup.mjs @@ -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); + } +} diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts index 9846ef71..fe8de5b6 100644 --- a/src/channels/official-channels-runtime.ts +++ b/src/channels/official-channels-runtime.ts @@ -809,7 +809,7 @@ export function getOfficialChannelsLegacyEnableHelp(): string { } export function getOfficialChannelTokenHelp(): string { - return 'Use --set-token =. If no channel is provided, Discord is assumed for backward compatibility.'; + return 'Use --set-token and pass the token via that channel env var (for example TELEGRAM_BOT_TOKEN=... ccs config channels --set-token telegram).'; } export function getOfficialChannelClearTokenHelp(): string { diff --git a/src/cliproxy/__tests__/provider-capabilities.test.ts b/src/cliproxy/__tests__/provider-capabilities.test.ts index 02fea0b0..16f15b21 100644 --- a/src/cliproxy/__tests__/provider-capabilities.test.ts +++ b/src/cliproxy/__tests__/provider-capabilities.test.ts @@ -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', ]); }); diff --git a/src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts b/src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts index 32b59c5c..ebac4851 100644 --- a/src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts +++ b/src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts @@ -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; + }; + }; + }; + 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: { diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index fb688341..b87d0dd6 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -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; } diff --git a/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts b/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts index 9b156985..0a3abf3b 100644 --- a/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts +++ b/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts @@ -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; + + 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; diff --git a/src/cliproxy/ai-providers/codex-reasoning-proxy.ts b/src/cliproxy/ai-providers/codex-reasoning-proxy.ts index a8502a82..a9d7bd6a 100644 --- a/src/cliproxy/ai-providers/codex-reasoning-proxy.ts +++ b/src/cliproxy/ai-providers/codex-reasoning-proxy.ts @@ -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); + }); } ); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts index 9bf3436b..b8aa6646 100644 --- a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts @@ -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, }, ]); diff --git a/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts b/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts index 86dc7fc8..7b7839b8 100644 --- a/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts @@ -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'); }); }); diff --git a/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts b/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts index 8b48261c..a60324ab 100644 --- a/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts @@ -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', () => { diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 231ab850..41661cce 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -306,6 +306,13 @@ export const OAUTH_CONFIGS: Record = { 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). */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 2622d8ff..dea6cddc 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -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, diff --git a/src/cliproxy/auth/oauth-trace/redactor.ts b/src/cliproxy/auth/oauth-trace/redactor.ts index 62c74902..40ead575 100644 --- a/src/cliproxy/auth/oauth-trace/redactor.ts +++ b/src/cliproxy/auth/oauth-trace/redactor.ts @@ -10,6 +10,7 @@ const SENSITIVE_QUERY_KEYS = [ 'code', 'state', + 'token', 'access_token', 'refresh_token', 'id_token', diff --git a/src/cliproxy/auth/oauth-trace/trace-recorder.ts b/src/cliproxy/auth/oauth-trace/trace-recorder.ts index 5cf14c17..7b8d8a72 100644 --- a/src/cliproxy/auth/oauth-trace/trace-recorder.ts +++ b/src/cliproxy/auth/oauth-trace/trace-recorder.ts @@ -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 { diff --git a/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts b/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts new file mode 100644 index 00000000..186942b0 --- /dev/null +++ b/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts @@ -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); + }); +}); diff --git a/src/cliproxy/binary/version-checker.ts b/src/cliproxy/binary/version-checker.ts index cfade244..f4e7c547 100644 --- a/src/cliproxy/binary/version-checker.ts +++ b/src/cliproxy/binary/version-checker.ts @@ -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; } /** diff --git a/src/cliproxy/config/__tests__/base-config-loader.test.ts b/src/cliproxy/config/__tests__/base-config-loader.test.ts index 88a74338..4a5bd42a 100644 --- a/src/cliproxy/config/__tests__/base-config-loader.test.ts +++ b/src/cliproxy/config/__tests__/base-config-loader.test.ts @@ -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); diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 29f6052e..e4144394 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -265,6 +265,68 @@ export const MODEL_CATALOG: Partial> = }, ], }, + 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)', diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index 1d020d77..eb942cd7 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -182,6 +182,18 @@ export const PROVIDER_CAPABILITIES: Record { 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' }, diff --git a/src/cliproxy/proxy/__tests__/tool-sanitization-proxy-integration.test.ts b/src/cliproxy/proxy/__tests__/tool-sanitization-proxy-integration.test.ts index 6da226e2..3bb65862 100644 --- a/src/cliproxy/proxy/__tests__/tool-sanitization-proxy-integration.test.ts +++ b/src/cliproxy/proxy/__tests__/tool-sanitization-proxy-integration.test.ts @@ -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; + 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; + expect(sentBody.model).toBe('gpt-5.5'); + expect((sentBody.reasoning as Record).summary).toBe('auto'); + expect((sentBody.reasoning as Record).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; + const sentMessages = sentBody.messages as Array>; + + 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; + const sentMessages = sentBody.messages as Array>; + + 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; + 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((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((_, 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((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((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((_, 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((resolve) => upstream.close(() => resolve())); + } + }); }); }); diff --git a/src/cliproxy/proxy/proxy-config-resolver.ts b/src/cliproxy/proxy/proxy-config-resolver.ts index e4e229b2..a9cdded3 100644 --- a/src/cliproxy/proxy/proxy-config-resolver.ts +++ b/src/cliproxy/proxy/proxy-config-resolver.ts @@ -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; diff --git a/src/cliproxy/proxy/tool-sanitization-proxy.ts b/src/cliproxy/proxy/tool-sanitization-proxy.ts index 3fed56e4..a86cc7ad 100644 --- a/src/cliproxy/proxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/proxy/tool-sanitization-proxy.ts @@ -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): Record { + if (typeof body.model !== 'string') { + return body; + } + + const parsed = parseCodexModelTuningAlias(body.model); + if (!parsed || !isKnownCodexModelId(parsed.baseModel)) { + return body; + } + + const tunedBody: Record = { ...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): Record { + 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); + }); } ); diff --git a/src/cliproxy/proxy/upstream-response-timeout.ts b/src/cliproxy/proxy/upstream-response-timeout.ts new file mode 100644 index 00000000..31d06f3c --- /dev/null +++ b/src/cliproxy/proxy/upstream-response-timeout.ts @@ -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; +} diff --git a/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts b/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts index 3950665a..5f6682b9 100644 --- a/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts +++ b/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts @@ -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; + }) 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(); + + 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; + }) as typeof setTimeout); + globalThis.clearTimeout = mock(((timerId?: ReturnType) => { + 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 = { diff --git a/src/cliproxy/routing/routing-strategy-http.ts b/src/cliproxy/routing/routing-strategy-http.ts index 6fdaa37d..f361eb7e 100644 --- a/src/cliproxy/routing/routing-strategy-http.ts +++ b/src/cliproxy/routing/routing-strategy-http.ts @@ -40,6 +40,8 @@ export async function fetchCliproxyRoutingResponse( } } + const requestUrl = new URL(url); + return new Promise((resolve, reject) => { const agent = new https.Agent({ rejectUnauthorized: false }); let settled = false; @@ -51,57 +53,65 @@ export async function fetchCliproxyRoutingResponse( callback(); }; + let req: ReturnType | 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(); }); } diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 925d7370..6216d641 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -38,6 +38,7 @@ const CHANNEL_TO_PROVIDER: Record = { kimi: 'kimi', kiro: 'kiro', 'github-copilot': 'ghcp', + qoder: 'qoder', }; /** CCS provider → channel name mapping (reverse) */ diff --git a/src/cliproxy/types/provider-types.ts b/src/cliproxy/types/provider-types.ts index 8aa97976..e96fade7 100644 --- a/src/cliproxy/types/provider-types.ts +++ b/src/cliproxy/types/provider-types.ts @@ -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 */ diff --git a/src/codex-auth/codex-config-symlink.ts b/src/codex-auth/codex-config-symlink.ts index 7857677d..1fb3fc61 100644 --- a/src/codex-auth/codex-config-symlink.ts +++ b/src/codex-auth/codex-config-symlink.ts @@ -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` diff --git a/src/codex-auth/commands/import-default-command.ts b/src/codex-auth/commands/import-default-command.ts index d4352813..5fb9db08 100644 --- a/src/codex-auth/commands/import-default-command.ts +++ b/src/codex-auth/commands/import-default-command.ts @@ -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 [--with-history] [--force] [--force-while-running] */ @@ -40,51 +40,37 @@ function sleep(ms: number): Promise { } /** - * 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; diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index b53dd770..5094e4e2 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -1056,8 +1056,13 @@ export async function handlePauseAccount(args: string[]): Promise { } 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; } diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index a4f81c32..c3b899a9 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -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', }) ); diff --git a/src/commands/config-channels-command.ts b/src/commands/config-channels-command.ts index c31cc9ab..6fffd019 100644 --- a/src/commands/config-channels-command.ts +++ b/src/commands/config-channels-command.ts @@ -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 ', 'command')} ${getOfficialChannelTokenHelp()}`); + console.log(` ${color('--set-token ', '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 } if (options.setTokenInvalid) { console.error( - fail(`Invalid --set-token value: ${options.setTokenInvalid} (use =)`) + fail( + `Invalid --set-token value: ${options.setTokenInvalid} (use ${getOfficialChannelChoices()})` + ) ); process.exitCode = 1; return; @@ -444,12 +430,19 @@ export async function handleConfigChannelsCommand(args: string[]): Promise 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(''); } diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index ed80ecd5..dacfb97d 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -16,6 +16,7 @@ export const RESERVED_PROFILE_NAMES = [ 'gitlab', 'codebuddy', 'kilo', + 'qoder', // Copilot API (GitHub Copilot proxy) 'copilot', // Cursor IDE (Cursor proxy daemon) diff --git a/src/cursor/cursor-daemon-auth.ts b/src/cursor/cursor-daemon-auth.ts new file mode 100644 index 00000000..23b6bd39 --- /dev/null +++ b/src/cursor/cursor-daemon-auth.ts @@ -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; +} diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index 868121da..8b0a42ef 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -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 { }); } +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, diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts index 5c58b712..9c5a5047 100644 --- a/src/cursor/cursor-daemon.ts +++ b/src/cursor/cursor-daemon.ts @@ -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 { * 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 { +export async function isDaemonRunning(port: number, daemonToken?: string): Promise { return new Promise((resolve) => { const req = http.request( { @@ -65,6 +66,7 @@ export async function isDaemonRunning(port: number): Promise { 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 */ 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) { diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 292ce5dc..384e50ae 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -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 { - if (!(await isDaemonRunning(port))) { + if (!(await isDaemonRunning(port, getCursorDaemonToken()))) { return DEFAULT_CURSOR_MODELS; } return fetchModelsFromDaemon(port); diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts index e2b9b531..03d4d139 100644 --- a/src/cursor/cursor-profile-executor.ts +++ b/src/cursor/cursor-profile-executor.ts @@ -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 { 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(); diff --git a/src/cursor/cursor-runtime-probe.ts b/src/cursor/cursor-runtime-probe.ts index 302bbb75..65b8feb5 100644 --- a/src/cursor/cursor-runtime-probe.ts +++ b/src/cursor/cursor-runtime-probe.ts @@ -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` 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; try { diff --git a/src/docker/docker-key-rotation.ts b/src/docker/docker-key-rotation.ts index 50f0f1b7..51b3fd6e 100644 --- a/src/docker/docker-key-rotation.ts +++ b/src/docker/docker-key-rotation.ts @@ -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 []; diff --git a/src/glmt/sse-parser.ts b/src/glmt/sse-parser.ts index d6fff5d6..a4cb12ba 100644 --- a/src/glmt/sse-parser.ts +++ b/src/glmt/sse-parser.ts @@ -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; } } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 6ce5b2ce..728fa0e9 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -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', diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 999376d5..19cab649 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -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 { diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index 5165ae2b..f33ff24f 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -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; diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index f1f9346f..d1bfbb2d 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -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( diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index 47ab8cc6..c3b9b0d0 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -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) { diff --git a/src/shared/cliproxy-model-routing.ts b/src/shared/cliproxy-model-routing.ts index b6194ac4..9c12f52d 100644 --- a/src/shared/cliproxy-model-routing.ts +++ b/src/shared/cliproxy-model-routing.ts @@ -57,6 +57,7 @@ const PROVIDER_OWNER_HINTS: Record = { kimi: ['kimi', 'moonshot'], kiro: ['kiro', 'aws'], ghcp: ['github', 'copilot'], + qoder: ['qoder'], }; function normalize(value: string | null | undefined): string { diff --git a/src/shared/stale-codex-translator-settings.ts b/src/shared/stale-codex-translator-settings.ts index 81156690..e0446ffd 100644 --- a/src/shared/stale-codex-translator-settings.ts +++ b/src/shared/stale-codex-translator-settings.ts @@ -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(); + 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 { diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 333dda86..ba22c63b 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -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') { diff --git a/src/utils/__tests__/retry-strategy.test.ts b/src/utils/__tests__/retry-strategy.test.ts index ad398b81..1e24bc34 100644 --- a/src/utils/__tests__/retry-strategy.test.ts +++ b/src/utils/__tests__/retry-strategy.test.ts @@ -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'); diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index 89faa134..edc6b383 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -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; diff --git a/src/utils/glmt-deprecation.ts b/src/utils/glmt-deprecation.ts index 8235aea8..38a3599f 100644 --- a/src/utils/glmt-deprecation.ts +++ b/src/utils/glmt-deprecation.ts @@ -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): GlmtNormalizationResult { diff --git a/src/utils/retry-strategy.ts b/src/utils/retry-strategy.ts index 33ebeee3..50489b6b 100644 --- a/src/utils/retry-strategy.ts +++ b/src/utils/retry-strategy.ts @@ -99,7 +99,7 @@ export async function withRetry(fn: () => Promise, 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++) { diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 21a0aaa2..7305eac1 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -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 { - 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(); diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 204b4c97..296cb2cd 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -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; } diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 6d7a8bfa..a8f42fe0 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -243,6 +243,12 @@ const PRICING_REGISTRY: Record = { 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 diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 72920473..f6874a08 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -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) : {}; 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) : {}; 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 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) { diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts index 220dae9a..f3c5eafc 100644 --- a/src/web-server/routes/cursor-routes.ts +++ b/src/web-server/routes/cursor-routes.ts @@ -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 => { /** * POST /api/cursor/probe - Run a live authenticated runtime probe */ -router.post('/probe', async (_req: Request, res: Response): Promise => { +router.post('/probe', async (req: Request, res: Response): Promise => { + 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); diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts index 25ec7fc9..db7a44f3 100644 --- a/src/web-server/routes/image-analysis-routes.ts +++ b/src/web-server/routes/image-analysis-routes.ts @@ -404,6 +404,9 @@ router.put('/', async (req: Request, res: Response): Promise => { 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 => { 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 }); } }); diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 9b812760..a5ea70ac 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -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 => { +router.get('/local-runtime-readiness', async (req: Request, res: Response): Promise => { + if (!requireLocalAccessWhenAuthDisabled(req, res, LOCAL_RUNTIME_READINESS_LOCAL_ACCESS_ERROR)) { + return; + } + try { res.json({ runtimes: await getLocalRuntimeReadiness() }); } catch (error) { diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 2c739fb9..323c9e76 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -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 => { 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.'); diff --git a/src/web-server/services/claude-extension-settings-service.ts b/src/web-server/services/claude-extension-settings-service.ts index aad9fe24..97d97474 100644 --- a/src/web-server/services/claude-extension-settings-service.ts +++ b/src/web-server/services/claude-extension-settings-service.ts @@ -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 }); diff --git a/src/web-server/usage/profile-filter.ts b/src/web-server/usage/profile-filter.ts index a319daa1..c3d0f2fd 100644 --- a/src/web-server/usage/profile-filter.ts +++ b/src/web-server/usage/profile-filter.ts @@ -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'); diff --git a/tests/docker/dashboard-sunset-guard.test.sh b/tests/docker/dashboard-sunset-guard.test.sh index c466cee6..5aea7a5a 100755 --- a/tests/docker/dashboard-sunset-guard.test.sh +++ b/tests/docker/dashboard-sunset-guard.test.sh @@ -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." diff --git a/tests/docker/image-size-logic.test.sh b/tests/docker/image-size-logic.test.sh index 5b5255c1..4e8a535e 100755 --- a/tests/docker/image-size-logic.test.sh +++ b/tests/docker/image-size-logic.test.sh @@ -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" diff --git a/tests/docker/image-size.sh b/tests/docker/image-size.sh index f1449bec..e53fee33 100755 --- a/tests/docker/image-size.sh +++ b/tests/docker/image-size.sh @@ -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' \ diff --git a/tests/docker/network-contract-env.test.sh b/tests/docker/network-contract-env.test.sh new file mode 100644 index 00000000..523a8b3c --- /dev/null +++ b/tests/docker/network-contract-env.test.sh @@ -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 diff --git a/tests/docker/network-contract.sh b/tests/docker/network-contract.sh index 6e5aa4b1..ec31b13f 100755 --- a/tests/docker/network-contract.sh +++ b/tests/docker/network-contract.sh @@ -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" ) diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts index 09acca70..acb21499 100644 --- a/tests/integration/cursor-daemon-lifecycle.test.ts +++ b/tests/integration/cursor-daemon-lifecycle.test.ts @@ -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: [ diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 0efcad03..194919ae 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -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(); diff --git a/tests/integration/web-server/codex-profiles-endpoint.test.ts b/tests/integration/web-server/codex-profiles-endpoint.test.ts index 44b3ce05..1c3a71a0 100644 --- a/tests/integration/web-server/codex-profiles-endpoint.test.ts +++ b/tests/integration/web-server/codex-profiles-endpoint.test.ts @@ -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((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((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'); diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 6c8293c4..7bdb344a 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -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', () => { diff --git a/tests/unit/codex-auth/codex-config-symlink.test.ts b/tests/unit/codex-auth/codex-config-symlink.test.ts index fc94a188..03206fac 100644 --- a/tests/unit/codex-auth/codex-config-symlink.test.ts +++ b/tests/unit/codex-auth/codex-config-symlink.test.ts @@ -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'); diff --git a/tests/unit/codex-auth/commands/import-default-command.test.ts b/tests/unit/codex-auth/commands/import-default-command.test.ts index 78107bbe..da1923b5 100644 --- a/tests/unit/codex-auth/commands/import-default-command.test.ts +++ b/tests/unit/codex-auth/commands/import-default-command.test.ts @@ -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(); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index 456e724f..b98ae57a 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -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'); diff --git a/tests/unit/commands/config-channels-command.test.ts b/tests/unit/commands/config-channels-command.test.ts index a8ea3af8..b68d7557 100644 --- a/tests/unit/commands/config-channels-command.test.ts +++ b/tests/unit/commands/config-channels-command.test.ts @@ -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'); }); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index bd7f6692..739f4488 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -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 ')).toBe(true); diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index f80ad8c5..5179f505 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -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; + }; + + 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(); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index edfac3bd..3724bc39 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -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((resolve) => { diff --git a/tests/unit/cursor/cursor-profile-executor.test.ts b/tests/unit/cursor/cursor-profile-executor.test.ts index 4d7d6260..1f144896 100644 --- a/tests/unit/cursor/cursor-profile-executor.test.ts +++ b/tests/unit/cursor/cursor-profile-executor.test.ts @@ -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'); diff --git a/tests/unit/dispatcher/profile-resolver-subcommand-passthrough.test.ts b/tests/unit/dispatcher/profile-resolver-subcommand-passthrough.test.ts new file mode 100644 index 00000000..4a59d8f6 --- /dev/null +++ b/tests/unit/dispatcher/profile-resolver-subcommand-passthrough.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test'; +import { isBareClaudeSubcommandPassthrough } from '../../../src/dispatcher/profile-resolver'; + +/** + * Bare Claude subcommand passthrough decision (`ccs agents`, `ccs mcp`, ...). + * + * The predicate is consulted only on the profile-not-found path, so a real + * configured profile of the same name is resolved earlier and never reaches + * this gate. These tests pin the decision matrix: forward documented Claude + * subcommands on the claude target, leave everything else alone. + */ +describe('isBareClaudeSubcommandPassthrough', () => { + it('reroutes a bare Claude subcommand on the default (claude) target', () => { + expect(isBareClaudeSubcommandPassthrough('agents', ['agents'])).toBe(true); + expect(isBareClaudeSubcommandPassthrough('mcp', ['mcp'])).toBe(true); + expect(isBareClaudeSubcommandPassthrough('plugin', ['plugin'])).toBe(true); + expect(isBareClaudeSubcommandPassthrough('setup-token', ['setup-token'])).toBe(true); + }); + + it('reroutes when the subcommand carries its own flags', () => { + expect( + isBareClaudeSubcommandPassthrough('agents', [ + 'agents', + '--permission-mode', + 'bypassPermissions', + ]) + ).toBe(true); + }); + + it('does not reroute the implicit default profile', () => { + expect(isBareClaudeSubcommandPassthrough('default', [])).toBe(false); + }); + + it('does not reroute an unknown non-subcommand token', () => { + expect(isBareClaudeSubcommandPassthrough('notaprofile', ['notaprofile'])).toBe(false); + expect(isBareClaudeSubcommandPassthrough('glm', ['glm'])).toBe(false); + }); + + it('does not reroute when an explicit non-claude target is selected', () => { + expect(isBareClaudeSubcommandPassthrough('agents', ['agents', '--target', 'droid'])).toBe( + false + ); + expect(isBareClaudeSubcommandPassthrough('mcp', ['mcp', '--target', 'codex'])).toBe(false); + }); +}); diff --git a/tests/unit/docker/docker-bootstrap.test.ts b/tests/unit/docker/docker-bootstrap.test.ts index cad19b1b..3877a4c8 100644 --- a/tests/unit/docker/docker-bootstrap.test.ts +++ b/tests/unit/docker/docker-bootstrap.test.ts @@ -23,6 +23,7 @@ import { const originalCcsHome = process.env.CCS_HOME; const originalGraceDays = process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS; const originalRestoreLegacyKey = process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY; +const originalEnableLegacyKeyAuth = process.env.CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH; const tempDirs: string[] = []; function useTempCcsHome(): string { @@ -48,6 +49,11 @@ afterEach(() => { } else { process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = originalRestoreLegacyKey; } + if (originalEnableLegacyKeyAuth === undefined) { + delete process.env.CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH; + } else { + process.env.CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH = originalEnableLegacyKeyAuth; + } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); @@ -98,7 +104,7 @@ describe('docker bootstrap auth', () => { expect(readDockerBootstrapState().state?.legacyKeyGrace).toBeUndefined(); }); - it('preserves the legacy key during the default upgrade grace window', () => { + it('tracks legacy key grace but does not enable it in config by default', () => { useTempCcsHome(); mutateConfig((config) => { config.cliproxy.auth = { @@ -118,7 +124,7 @@ describe('docker bootstrap auth', () => { expect(state?.legacyKeyGrace?.legacyKey).toBe(CCS_INTERNAL_API_KEY); expect(state?.legacyKeyGrace?.replacementKey).toBe(config.cliproxy.auth?.api_key); expect(content).toContain(`"${config.cliproxy.auth?.api_key}"`); - expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); }); it('honors CCS_DOCKER_LEGACY_KEY_GRACE_DAYS for upgrade expiry', () => { @@ -188,7 +194,7 @@ describe('docker bootstrap auth', () => { expect(changed).toBe(true); expect(content).toContain(`"${generatedKey}"`); - expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); }); it('restores the legacy key after an earlier run wrote a no-grace marker', () => { @@ -212,7 +218,7 @@ describe('docker bootstrap auth', () => { expect(changed).toBe(true); expect(content).toContain(`"${generatedKey}"`); - expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); }); it('recovers safely from a corrupted marker file during broken-install recovery', () => { @@ -232,7 +238,7 @@ describe('docker bootstrap auth', () => { expect(getDockerBootstrapStatePath()).toContain(DOCKER_BOOTSTRAP_STATE_FILENAME); expect(readDockerBootstrapState().corrupted).toBe(false); - expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); }); it('does not treat human custom keys as broken Docker-generated keys', () => { diff --git a/tests/unit/docker/docker-release-workflow-context.test.ts b/tests/unit/docker/docker-release-workflow-context.test.ts new file mode 100644 index 00000000..8a5ba0c3 --- /dev/null +++ b/tests/unit/docker/docker-release-workflow-context.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const repoRoot = join(import.meta.dir, '../../..'); + +describe('docker release workflow context', () => { + test('builds the integrated Dockerfile with the context its COPY paths expect', () => { + const workflow = readFileSync(join(repoRoot, '.github/workflows/docker-release.yml'), 'utf8'); + const dockerfile = readFileSync(join(repoRoot, 'docker/Dockerfile.integrated'), 'utf8'); + + expect(workflow).toMatch( + /Build and push integrated image[\s\S]*context: docker[\s\S]*file: docker\/Dockerfile\.integrated/, + ); + expect(dockerfile).toContain('COPY supervisord.conf /etc/supervisord.conf'); + expect(dockerfile).toContain('COPY entrypoint-integrated.sh /entrypoint-integrated.sh'); + expect(existsSync(join(repoRoot, 'docker/supervisord.conf'))).toBe(true); + expect(existsSync(join(repoRoot, 'docker/entrypoint-integrated.sh'))).toBe(true); + }); + + test('keeps integrated smoke tests independent from legacy dashboard publish', () => { + const workflow = readFileSync(join(repoRoot, '.github/workflows/docker-release.yml'), 'utf8'); + + expect(workflow).not.toMatch(/publish-integrated:[\s\S]*?needs:\s*\[?publish-dashboard/); + expect(workflow).toMatch(/smoke-test:[\s\S]*?needs:\s*\[publish-integrated\]/); + }); + + test('verifies immutable and promoted tags without registry credentials', () => { + const workflow = readFileSync(join(repoRoot, '.github/workflows/docker-release.yml'), 'utf8'); + + expect(workflow).toMatch( + /Verify anonymous pull access[\s\S]*DOCKER_CONFIG="\$\{CLEAN_DOCKER_CONFIG\}" docker pull/ + ); + expect(workflow).toMatch( + /Verify promoted tags are anonymously pullable[\s\S]*DOCKER_CONFIG="\$\{CLEAN_DOCKER_CONFIG\}" docker pull/ + ); + }); + + test('gives the raw integrated image a Docker healthcheck for release smoke tests', () => { + const dockerfile = readFileSync(join(repoRoot, 'docker/Dockerfile.integrated'), 'utf8'); + + expect(dockerfile).toContain('HEALTHCHECK'); + expect(dockerfile).toContain('127.0.0.1:3000'); + expect(dockerfile).toContain('127.0.0.1:8317'); + }); + + test('lets network-contract smoke tests avoid fixed host port collisions', () => { + const compose = readFileSync(join(repoRoot, 'docker/compose.yaml'), 'utf8'); + const contractScript = readFileSync(join(repoRoot, 'tests/docker/network-contract.sh'), 'utf8'); + + expect(compose).toContain('${CCS_DASHBOARD_PORT:-3000}:3000'); + expect(compose).toContain('${CCS_CLIPROXY_PORT:-8317}:8317'); + expect(contractScript).toContain('CCS_NETWORK_CONTRACT_DASHBOARD_PORT:-${CCS_DASHBOARD_PORT:-13001}'); + expect(contractScript).toContain('CCS_NETWORK_CONTRACT_CLIPROXY_PORT:-${CCS_CLIPROXY_PORT:-18318}'); + expect(contractScript).toContain('up -d --remove-orphans'); + }); + + test('passes collision-safe compose env through network-contract calls', () => { + const result = spawnSync('bash', ['tests/docker/network-contract-env.test.sh'], { + cwd: repoRoot, + encoding: 'utf8', + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + }); +}); diff --git a/tests/unit/github/stable-release-issue-cleanup.test.mjs b/tests/unit/github/stable-release-issue-cleanup.test.mjs new file mode 100644 index 00000000..81cc4f35 --- /dev/null +++ b/tests/unit/github/stable-release-issue-cleanup.test.mjs @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'bun:test'; +import { + buildReleaseIssueSet, + extractIssueNumbers, + extractPrNumbers, + planIssueCleanup, +} from '../../../scripts/github/stable-release-issue-cleanup-lib.mjs'; + +describe('stable release issue cleanup', () => { + it('extracts issue numbers from release action verbs', () => { + const text = [ + 'feat: promote dev to main (#1351), closes #1340 #1341', + 'fix: support aliases (#1197)', + 'Refs #760', + ].join('\n'); + + expect(extractIssueNumbers(text, { includeRefs: true })).toEqual([760, 1340, 1341]); + expect(extractIssueNumbers(text, { includeRefs: false })).toEqual([1340, 1341]); + }); + + it('extracts PR numbers from merge and squash commit subjects', () => { + const text = [ + 'Merge pull request #1392 from kaitranntt/kai/fix/foo', + 'fix(analytics): tighten top-bar layout (#1391)', + ].join('\n'); + + expect(extractPrNumbers(text)).toEqual([1391, 1392]); + }); + + it('combines release body, commit text, and PR body issue references', () => { + const result = buildReleaseIssueSet({ + releaseBody: 'closes #100', + commitText: 'fix: thing (#10)', + prText: 'Refs #200\nResolves #300', + }); + + expect(result.releaseIssues).toEqual([100, 200, 300]); + expect(result.resolvedIssues).toEqual([100, 300]); + }); + + it('closes dev-released issues when promoted to stable even if the PR used refs', () => { + const actions = planIssueCleanup({ + releaseIssues: [760], + resolvedIssues: [], + issueStates: new Map([[760, { state: 'OPEN', labels: ['released-dev'] }]]), + }); + + expect(actions[0]).toMatchObject({ + number: 760, + addReleasedLabel: true, + close: true, + reason: 'promoted from dev to stable', + }); + }); + + it('does not close weak refs unless the issue was already marked released-dev', () => { + const actions = planIssueCleanup({ + releaseIssues: [42], + resolvedIssues: [], + issueStates: new Map([[42, { state: 'OPEN', labels: ['enhancement'] }]]), + }); + + expect(actions[0]).toMatchObject({ + number: 42, + addReleasedLabel: false, + close: false, + }); + }); + + it('closes explicitly resolved issues without requiring a manual released label', () => { + const actions = planIssueCleanup({ + releaseIssues: [1340], + resolvedIssues: [1340], + issueStates: new Map([[1340, { state: 'OPEN', labels: ['bug'] }]]), + }); + + expect(actions[0]).toMatchObject({ + number: 1340, + addReleasedLabel: true, + close: true, + reason: 'resolved by stable release', + }); + }); +}); diff --git a/tests/unit/glmt/sse-parser.test.ts b/tests/unit/glmt/sse-parser.test.ts index 740fc0e1..bfe35016 100644 --- a/tests/unit/glmt/sse-parser.test.ts +++ b/tests/unit/glmt/sse-parser.test.ts @@ -46,4 +46,18 @@ describe('SSEParser', () => { ?.delta?.content ).toBe('Legacy'); }); + + + it('does not split events when CRLF is split across chunks', () => { + const parser = new SSEParser({ throwOnMalformedJson: true }); + + expect(parser.parse('data: {"choices":[\r')).toEqual([]); + const events = parser.parse('\ndata: {"delta":{"content":"Hello"}}\r\ndata: ]}\r\n\r\n'); + + expect(events).toHaveLength(1); + expect( + (events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0] + ?.delta?.content + ).toBe('Hello'); + }); }); diff --git a/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts b/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts index bbd09250..5e38d7e8 100644 --- a/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts +++ b/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts @@ -722,6 +722,47 @@ describe('ccs-browser MCP server - navigation and query', () => { ); }); + + it('rejects excessive clickCount and repeat values', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Limits Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 713, + method: 'tools/call', + params: { + name: 'browser_click', + arguments: { + selector: '#submit', + clickCount: 26, + }, + }, + }, + { + jsonrpc: '2.0', + id: 714, + method: 'tools/call', + params: { + name: 'browser_press_key', + arguments: { + key: 'k', + repeat: 26, + }, + }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 713); + expect((clickResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(clickResponse)).toContain('clickCount must be less than or equal to 25'); + + const keyResponse = responses.find((message) => message.id === 714); + expect((keyResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(keyResponse)).toContain('repeat must be less than or equal to 25'); + }); + it('scrolls an element into view with browser_scroll', async () => { const responses = await runMcpRequests( [ diff --git a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts index 5d324aa7..6c2aac6a 100644 --- a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts +++ b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts @@ -79,11 +79,17 @@ describe('ccs-browser MCP server - session and interception', () => { expect(clickTool?.inputSchema?.properties?.clickCount).toMatchObject({ type: 'integer', minimum: 1, + maximum: 25, }); const keyTool = tools.find((tool) => tool.name === 'browser_press_key'); expect(keyTool?.inputSchema?.properties?.key).toMatchObject({ type: 'string' }); expect(keyTool?.inputSchema?.properties?.modifiers).toMatchObject({ type: 'array' }); + expect(keyTool?.inputSchema?.properties?.repeat).toMatchObject({ + type: 'integer', + minimum: 1, + maximum: 25, + }); const scrollTool = tools.find((tool) => tool.name === 'browser_scroll'); expect(scrollTool?.inputSchema?.properties?.deltaX).toMatchObject({ type: 'number' }); @@ -780,8 +786,7 @@ describe('ccs-browser MCP server - session and interception', () => { expect(listText).toContain('requestId: req-1'); expect(listText).toContain('matchedRuleId: rule-1'); expect(listText).toContain('action: fail'); - expect(listText).toContain('requestId: req-2'); - expect(listText).toContain('action: continue'); + expect(listText).not.toContain('requestId: req-2'); }); it('removes rules and recent requests bound to a page after that page is closed', async () => { diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 9e6eca84..a6951211 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -256,14 +256,14 @@ describe('model-pricing', () => { expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 }); - it('should calculate Claude Opus 4.7 cache cost consistently across repeat lookups', () => { + it('should calculate Claude Opus 4.7 thinking cost including cache token rates', () => { const usage: TokenUsage = { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheCreationTokens: 1_000_000, cacheReadTokens: 1_000_000, }; - const cost = calculateCost(usage, 'claude-opus-4-7'); + const cost = calculateCost(usage, 'claude-opus-4-7-thinking'); expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 }); }); @@ -469,17 +469,15 @@ describe('model-pricing', () => { }); it('gracefully ignores malformed cached model entries', () => { - setCachedModelsDevRegistry( - { - openai: { - id: 'openai', - models: { - 'null-entry': null, - 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, - }, + setCachedModelsDevRegistry({ + openai: { + id: 'openai', + models: { + 'null-entry': null, + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, }, - } as unknown as Parameters[0] - ); + }, + } as unknown as Parameters[0]); expect(() => getModelPricing('openai/gpt-5.5')).not.toThrow(); expect(getModelPricing('openai/gpt-5.5').inputPerMillion).toBe(5); diff --git a/tests/unit/proxy/messages-route.test.ts b/tests/unit/proxy/messages-route.test.ts index b4fcbbe9..bcf4a031 100644 --- a/tests/unit/proxy/messages-route.test.ts +++ b/tests/unit/proxy/messages-route.test.ts @@ -97,6 +97,12 @@ beforeEach(() => { ANTHROPIC_MODEL: 'sonar-pro', CCS_DROID_PROVIDER: 'generic-chat-completion-api', }), + mm: writeSettings('mm', { + ANTHROPIC_BASE_URL: 'https://api.minimax.io/v1', + ANTHROPIC_AUTH_TOKEN: 'minimax_token', + ANTHROPIC_MODEL: 'MiniMax-M2.7', + CCS_DROID_PROVIDER: 'openai', + }), }; fs.writeFileSync( @@ -242,4 +248,102 @@ describe('handleProxyMessagesRequest', () => { expect(closeCalls).toBe(0); expect(res.statusCode).toBe(502); }); + + it('moves system messages into the first user message for MiniMax OpenAI-compatible upstreams', async () => { + const activeProfile = buildProfile('mm'); + let capturedBody: unknown; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + id: 'chatcmpl_1', + object: 'chat.completion', + created: 1, + model: 'MiniMax-M2.7', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' } }], + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as typeof globalThis.fetch; + + const req = new FakeRequest({ + 'x-api-key': 'local-token', + }); + const res = new FakeResponse(); + const pending = handleProxyMessagesRequest( + req as never, + res as never, + activeProfile, + 'local-token' + ); + req.end( + JSON.stringify({ + model: 'MiniMax-M2.7', + stream: false, + messages: [ + { role: 'system', content: 'Use Turkish.' }, + { role: 'user', content: 'bu hangi model' }, + ], + }) + ); + await pending; + + expect(capturedBody).toMatchObject({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'Use Turkish.\n\nbu hangi model' }], + }); + expect((capturedBody as { messages: Array<{ role: string }> }).messages).not.toContainEqual( + expect.objectContaining({ role: 'system' }) + ); + }); + + it('strips blank system messages for MiniMax OpenAI-compatible upstreams', async () => { + const activeProfile = buildProfile('mm'); + let capturedBody: unknown; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + id: 'chatcmpl_1', + object: 'chat.completion', + created: 1, + model: 'MiniMax-M2.7', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' } }], + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as typeof globalThis.fetch; + + const req = new FakeRequest({ + 'x-api-key': 'local-token', + }); + const res = new FakeResponse(); + const pending = handleProxyMessagesRequest( + req as never, + res as never, + activeProfile, + 'local-token' + ); + req.end( + JSON.stringify({ + model: 'MiniMax-M2.7', + stream: false, + messages: [ + { role: 'system', content: [{ type: 'text', text: '' }] }, + { role: 'user', content: 'bu hangi model' }, + ], + }) + ); + await pending; + + expect(capturedBody).toMatchObject({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'bu hangi model' }], + }); + expect((capturedBody as { messages: Array<{ role: string }> }).messages).not.toContainEqual( + expect.objectContaining({ role: 'system' }) + ); + }); }); diff --git a/tests/unit/proxy/transformers/request-transformer.test.ts b/tests/unit/proxy/transformers/request-transformer.test.ts index 543361a1..1047c2b1 100644 --- a/tests/unit/proxy/transformers/request-transformer.test.ts +++ b/tests/unit/proxy/transformers/request-transformer.test.ts @@ -43,6 +43,23 @@ describe('ProxyRequestTransformer', () => { }); }); + it('accepts Claude Code system messages in the messages array', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + messages: [ + { role: 'user', content: 'hello' }, + { role: 'system', content: [{ type: 'text', text: 'answer tersely' }] }, + { role: 'user', content: 'which model is this?' }, + ], + }); + + expect(result.messages).toEqual([ + { role: 'user', content: 'hello' }, + { role: 'system', content: 'answer tersely' }, + { role: 'user', content: 'which model is this?' }, + ]); + }); + it('translates base64 image blocks into OpenAI image_url parts', () => { const transformer = new ProxyRequestTransformer(); const result = transformer.transform({ diff --git a/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts b/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts index b35ffa5f..153169d4 100644 --- a/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts +++ b/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts @@ -45,14 +45,18 @@ describe('docker dashboard sunset guard', () => { }); }); - test('fails loudly if the baseline tag is missing after the baseline', () => { - expect(() => - evaluateDashboardSunset({ - targetTag: 'v7.81.2', - baselineVersion: '7.81.0', - releaseWindow: 2, - stableTags: ['v7.80.0'], - }), - ).toThrow('baseline tag v7.81.0 is missing'); + test('skips deprecated publish if the baseline tag is missing after the baseline', () => { + const result = evaluateDashboardSunset({ + targetTag: 'v7.81.2', + baselineVersion: '7.81.0', + releaseWindow: 2, + stableTags: ['v7.80.0'], + }); + + expect(result).toMatchObject({ + elapsed: 2, + publish: false, + }); + expect(result.reason).toContain('baseline v7.81.0 is missing'); }); }); diff --git a/tests/unit/scripts/github/ai-review-workflow.test.ts b/tests/unit/scripts/github/ai-review-workflow.test.ts index 017112db..e454aea8 100644 --- a/tests/unit/scripts/github/ai-review-workflow.test.ts +++ b/tests/unit/scripts/github/ai-review-workflow.test.ts @@ -44,6 +44,9 @@ describe('PR-Agent review lane migration', () => { expect(workflow).toContain('CCS_REVIEWER_APP_ID'); expect(workflow).toContain('CCS_REVIEWER_PRIVATE_KEY'); expect(workflow).toContain('id: pr-agent-app-token'); + expect(workflow).toContain('permission-issues: write'); + expect(workflow).toContain('permission-pull-requests: write'); + expect(workflow).toContain('permission-contents: read'); expect(workflow).toContain('GITHUB_TOKEN: ${{ steps.pr-agent-app-token.outputs.token }}'); expect(workflow).not.toContain('GITHUB_TOKEN: ${{ github.token }}'); expect(workflow).not.toContain('uses: anthropics/claude-code-action@v1'); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 063ae9a4..555953eb 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -307,6 +307,31 @@ describe('SharedManager', () => { }); describe('marketplace registry ownership', () => { + it('skips unstatable shared plugin entries during instance linking', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + const sharedPluginsPath = path.join(claudeDir(), 'plugins'); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + fs.mkdirSync(instancePath, { recursive: true }); + fs.mkdirSync(sharedPluginsPath, { recursive: true }); + fs.symlinkSync( + path.join(sharedPluginsPath, 'missing-plugin'), + path.join(sharedPluginsPath, 'broken-plugin'), + 'dir' + ); + + expect(() => manager.linkSharedDirectories(instancePath)).not.toThrow(); + expect(fs.existsSync(path.join(instancePath, 'plugins', 'broken-plugin'))).toBe(false); + expect( + logSpy.mock.calls.some(([message]) => + String(message).includes( + 'Skipping plugins/broken-plugin: unable to inspect shared plugin entry' + ) + ) + ).toBe(true); + }); + it('writes global and instance registries with different authoritative install locations', () => { const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); ensureMarketplacePayload(claudeDir()); diff --git a/tests/unit/shared/stale-codex-translator-settings.test.ts b/tests/unit/shared/stale-codex-translator-settings.test.ts index be0cfd89..1873fb3d 100644 --- a/tests/unit/shared/stale-codex-translator-settings.test.ts +++ b/tests/unit/shared/stale-codex-translator-settings.test.ts @@ -31,4 +31,14 @@ describe('stale Codex translator settings scanner', () => { '["custom-key"][0]', ]); }); + + it('does not overflow the stack on deeply nested local settings values', () => { + let settings: Record = { value: 'leaf' }; + for (let depth = 0; depth < 20000; depth += 1) { + settings = { nested: settings }; + } + + expect(() => findCodexTranslatorUrlPaths(settings)).not.toThrow(); + expect(findCodexTranslatorUrlPaths(settings)).toEqual([]); + }); }); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index a14dfed4..4018dd45 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -596,7 +596,9 @@ process.exit(0); expect(result.status).toBe(0); expect(fs.existsSync(freshCodexHome)).toBe(true); - expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true); + const codexHomeStat = fs.statSync(freshCodexHome); + expect(codexHomeStat.isDirectory()).toBe(true); + expect(codexHomeStat.mode & 0o777).toBe(0o700); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], diff --git a/tests/unit/ui/cliproxy-version-risk.test.ts b/tests/unit/ui/cliproxy-version-risk.test.ts index b44c398f..36738dd7 100644 --- a/tests/unit/ui/cliproxy-version-risk.test.ts +++ b/tests/unit/ui/cliproxy-version-risk.test.ts @@ -6,9 +6,11 @@ import { } from '../../../ui/src/lib/cliproxy-version-risk'; describe('cliproxy-version-risk helpers', () => { - it('compares versions while ignoring release suffixes', () => { + it('compares fork release suffixes after core versions', () => { expect(compareCliproxyVersions('6.6.88', '6.6.81-0')).toBe(1); expect(compareCliproxyVersions('6.6.81-0', '6.6.81')).toBe(0); + 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(compareCliproxyVersions('6.6.80', '6.6.81')).toBe(-1); }); diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index 9add8441..6033eecf 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -22,6 +22,7 @@ describe('browser status', () => { let originalBrowserUserDataDir: string | undefined; let originalBrowserProfileDir: string | undefined; let originalBrowserDevtoolsPort: string | undefined; + let originalBrowserEvalMode: string | undefined; beforeEach(() => { tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-status-')); @@ -29,11 +30,13 @@ describe('browser status', () => { originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE; process.env.CCS_HOME = tempHome; delete process.env.CCS_BROWSER_USER_DATA_DIR; delete process.env.CCS_BROWSER_PROFILE_DIR; delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + delete process.env.CCS_BROWSER_EVAL_MODE; }); afterEach(() => { @@ -60,6 +63,11 @@ describe('browser status', () => { } else { delete process.env.CCS_BROWSER_DEVTOOLS_PORT; } + if (originalBrowserEvalMode !== undefined) { + process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode; + } else { + delete process.env.CCS_BROWSER_EVAL_MODE; + } rmSync(tempHome, { recursive: true, force: true }); }); @@ -290,6 +298,29 @@ describe('browser status', () => { }); }); + it('honors CCS_BROWSER_EVAL_MODE over configured eval_mode', () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + policy: 'manual', + user_data_dir: '/config-browser', + devtools_port: 9333, + eval_mode: 'readonly', + }, + codex: { + enabled: false, + policy: 'manual', + }, + }; + }); + process.env.CCS_BROWSER_EVAL_MODE = 'disabled'; + + const effective = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); + + expect(effective.evalMode).toBe('disabled'); + }); + it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => { const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data'); mkdirSync(managedDir, { recursive: true }); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index d3f819cd..ddb3ab71 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -145,8 +145,16 @@ function registerChildProcessMock(): void { })); } +const tempCcsHomes = new Set(); + +function createTempCcsHome(prefix: string): string { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempCcsHomes.add(tempHome); + return tempHome; +} + function writeConfigWithAutoUpdatePreference(enabled: boolean): void { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auto-update-pref-')); + const tempHome = createTempCcsHome('ccs-auto-update-pref-'); process.env.CCS_HOME = tempHome; const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -158,7 +166,7 @@ preferences: } function writeConfigWithWebSearchSettings(yamlBody: string): void { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-env-')); + const tempHome = createTempCcsHome('ccs-websearch-env-'); process.env.CCS_HOME = tempHome; const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -225,10 +233,8 @@ describe('CLAUDECODE environment stripping', () => { }); afterEach(async () => { - const tempCcsHome = process.env.CCS_HOME?.startsWith(os.tmpdir()) - ? process.env.CCS_HOME - : undefined; - if (tempCcsHome) { + const tempCcsHome = process.env.CCS_HOME; + if (tempCcsHome && tempCcsHomes.has(tempCcsHome)) { await stopOpenAICompatProxy(); } @@ -277,9 +283,10 @@ describe('CLAUDECODE environment stripping', () => { } } - if (tempCcsHome) { - fs.rmSync(tempCcsHome, { recursive: true, force: true }); + for (const tempHome of tempCcsHomes) { + fs.rmSync(tempHome, { recursive: true, force: true }); } + tempCcsHomes.clear(); }); it('stripClaudeCodeEnv removes CLAUDECODE case-insensitively', () => { diff --git a/tests/unit/utils/glmt-deprecation.test.ts b/tests/unit/utils/glmt-deprecation.test.ts index d003a9e7..592b370a 100644 --- a/tests/unit/utils/glmt-deprecation.test.ts +++ b/tests/unit/utils/glmt-deprecation.test.ts @@ -20,6 +20,9 @@ describe('glmt deprecation helpers', () => { expect(isLegacyGlmtBaseUrl('https://api.z.ai/api/coding/paas/v4/chat/completions/')).toBe( true ); + expect(isLegacyGlmtBaseUrl('https://private.example.internal/v1/chat/completions')).toBe( + false + ); expect(isLegacyGlmtBaseUrl('https://api.z.ai/api/anthropic')).toBe(false); }); diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts index edb4a1ee..27fc2d16 100644 --- a/tests/unit/web-server/claude-extension-routes.test.ts +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -313,6 +313,12 @@ describe('web-server claude-extension-routes', () => { it('creates a binding and applies managed settings to shared + IDE targets', async () => { const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json'); + const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true }); + fs.mkdirSync(path.dirname(sharedSettingsPath), { recursive: true }); + fs.writeFileSync(sharedSettingsPath, JSON.stringify({ env: {} }, null, 2) + '\n', { mode: 0o600 }); + fs.writeFileSync(ideSettingsPath, JSON.stringify({}, null, 2) + '\n', { mode: 0o600 }); + const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -352,7 +358,6 @@ describe('web-server claude-extension-routes', () => { expect(applied.sharedSettings.state).toBe('applied'); expect(applied.ideSettings.state).toBe('applied'); - const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json'); const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as { env?: Record; }; @@ -370,6 +375,9 @@ describe('web-server claude-extension-routes', () => { ) ).toBe(true); expect(ideSettings['claudeCode.disableLoginPrompt']).toBe(true); + + expect(fs.statSync(sharedSettingsPath).mode & 0o777).toBe(0o600); + expect(fs.statSync(ideSettingsPath).mode & 0o777).toBe(0o600); }); it('resets only managed keys and preserves unrelated shared + IDE settings', async () => { diff --git a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts index dfd476ab..a4f4e651 100644 --- a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import * as http from 'http'; import type { Server } from 'http'; import cliproxyAuthRoutes from '../../../src/web-server/routes/cliproxy-auth-routes'; +import { registerAccountFromToken } from '../../../src/cliproxy/auth/token-manager'; import { clearQuotaCache, getCachedQuota, @@ -593,6 +594,61 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { } }); + it('reauthenticates a targeted existing Codex account when its token file is rewritten', async () => { + const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(tokenDir, { recursive: true }); + const tokenPath = path.join(tokenDir, 'codex-existing@example.com.json'); + fs.writeFileSync( + tokenPath, + JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 1 }), + 'utf8' + ); + + const initialAccount = registerAccountFromToken('codex', tokenDir, 'work'); + expect(initialAccount?.id).toBe('existing@example.com'); + + mockFetch([ + { + url: /\/v0\/management\/codex-auth-url\?is_webui=true$/, + response: { + auth_url: 'https://auth.example.com/authorize?state=state-targeted-rewrite', + state: 'state-targeted-rewrite', + }, + }, + { + url: /\/v0\/management\/get-auth-status\?state=state-targeted-rewrite$/, + response: { status: 'ok' }, + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', { + accountId: 'existing@example.com', + }); + expect(startResponse.status).toBe(200); + + fs.writeFileSync( + tokenPath, + JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 2 }), + 'utf8' + ); + + const statusResponse = await getJson( + '/api/cliproxy/auth/codex/status?state=state-targeted-rewrite' + ); + + expect(statusResponse.status).toBe(200); + expect(statusResponse.body).toEqual({ + status: 'ok', + account: { + id: 'existing@example.com', + email: 'existing@example.com', + nickname: 'work', + provider: 'codex', + isDefault: true, + }, + }); + }); + it('registers the new account before reporting polled auth success', async () => { mockFetch([ { diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index b6cf9d82..1a0fb267 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { getKiroStartIDCValidationError, + getReauthAccountTarget, getStartAuthFailureMessage, getStartAuthNicknameError, getStartUrlUnsupportedReason, @@ -17,6 +18,7 @@ describe('cliproxy-auth-routes start-url guard', () => { "Provider 'codebuddy' uses Device Code flow" ); expect(getStartUrlUnsupportedReason('kilo')).toContain("Provider 'kilo' uses Device Code flow"); + expect(getStartUrlUnsupportedReason('qoder')).toContain("Provider 'qoder' uses Device Code flow"); }); it('allows Cursor browser URL auth on start-url', () => { @@ -142,3 +144,27 @@ describe('cliproxy-auth-routes nickname validation', () => { ).toBeNull(); }); }); + +describe('cliproxy-auth-routes reauth account targeting', () => { + const existingAccounts = [ + { id: 'codex-user@example.com', nickname: 'work' }, + { id: 'codex-personal@example.com', nickname: 'personal' }, + ]; + + it('does not require a target for normal add-account auth', () => { + expect(getReauthAccountTarget(undefined, existingAccounts)).toEqual({}); + expect(getReauthAccountTarget('', existingAccounts)).toEqual({}); + }); + + it('resolves an existing account target for reauth', () => { + expect(getReauthAccountTarget('codex-user@example.com', existingAccounts)).toEqual({ + account: { id: 'codex-user@example.com', nickname: 'work' }, + }); + }); + + it('rejects unknown account targets instead of falling back to ambiguous registration', () => { + expect(getReauthAccountTarget('missing', existingAccounts)).toEqual({ + error: "Account 'missing' not found for this provider", + }); + }); +}); diff --git a/tests/unit/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts index 5934ed9c..2ff96d7b 100644 --- a/tests/unit/web-server/cursor-routes.test.ts +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -510,6 +510,17 @@ describe('Cursor Routes Logic', () => { expect(json.current).toBe('gpt-5.3-codex'); }); + it('POST /api/cursor/probe rejects cross-origin requests', async () => { + const res = await fetch(`${baseUrl}/api/cursor/probe`, { + method: 'POST', + headers: { Origin: 'https://attacker.example' }, + }); + expect(res.status).toBe(403); + + const json = (await res.json()) as { error?: string }; + expect(json.error).toContain('Cross-origin probe requests are not allowed.'); + }); + it('POST /api/cursor/probe returns auth failure when credentials are missing', async () => { const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' }); expect(res.status).toBe(401); diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts index 27a6ef6f..20e6ca76 100644 --- a/tests/unit/web-server/image-analysis-routes.test.ts +++ b/tests/unit/web-server/image-analysis-routes.test.ts @@ -340,4 +340,31 @@ describe('image-analysis routes', () => { error: 'Profile mapping for "codexProfile" references an unknown backend.', }); }); + + it('rejects unsupported provider backends and preserves existing configuration', async () => { + const putResponse = await fetch(`${baseUrl}/api/image-analysis`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerModels: { + gemini: 'gemini-3-flash-preview', + evil: 'vision-model', + }, + fallbackBackend: 'gemini', + }), + }); + + expect(putResponse.status).toBe(400); + expect(await putResponse.json()).toEqual({ + error: 'Unsupported provider backend "evil".', + }); + + const getResponse = await fetch(`${baseUrl}/api/image-analysis`); + expect(getResponse.status).toBe(200); + const payload = await getResponse.json(); + expect(payload.config.providerModels).toMatchObject({ + gemini: 'gemini-3-flash-preview', + ghcp: 'claude-haiku-4.5', + }); + }); }); diff --git a/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts b/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts index 46c89b25..31b2488d 100644 --- a/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts +++ b/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts @@ -6,11 +6,20 @@ import profileRoutes from '../../../src/web-server/routes/profile-routes'; describe('profile-routes local runtime readiness', () => { let server: Server; let baseUrl = ''; + let forcedRemoteAddress = '127.0.0.1'; + let originalDashboardAuthEnabled: string | undefined; const originalFetch = globalThis.fetch; beforeAll(async () => { const app = express(); app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); app.use('/api/profiles', profileRoutes); await new Promise((resolve, reject) => { @@ -36,6 +45,10 @@ describe('profile-routes local runtime readiness', () => { }); beforeEach(() => { + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '127.0.0.1'; + globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); @@ -73,6 +86,12 @@ describe('profile-routes local runtime readiness', () => { afterEach(() => { globalThis.fetch = originalFetch; + + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } }); it('reports local runtimes as ready when their endpoints respond with models', async () => { @@ -103,6 +122,30 @@ describe('profile-routes local runtime readiness', () => { ); }); + it('blocks remote readiness probes when dashboard auth is disabled', async () => { + let probeCount = 0; + forcedRemoteAddress = '10.10.0.24'; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(baseUrl)) { + return originalFetch(input); + } + probeCount += 1; + return new Response(JSON.stringify({ models: [{ name: 'gemma4:e4b' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + const response = await fetch(`${baseUrl}/api/profiles/local-runtime-readiness`); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Local runtime readiness requires localhost access when dashboard auth is disabled.', + }); + expect(probeCount).toBe(0); + }); + it('reports setup guidance when local endpoints are unavailable', async () => { globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); diff --git a/tests/unit/web-server/usage-handlers-semantics.test.ts b/tests/unit/web-server/usage-handlers-semantics.test.ts index e91aec59..dfb1de23 100644 --- a/tests/unit/web-server/usage-handlers-semantics.test.ts +++ b/tests/unit/web-server/usage-handlers-semantics.test.ts @@ -83,11 +83,7 @@ function writeAssistantEntriesToDir(baseClaudeDir: string, entries: AssistantFix }, }); - fs.writeFileSync( - path.join(projectDir, `${entry.sessionId}.jsonl`), - `${line}\n`, - 'utf-8' - ); + fs.writeFileSync(path.join(projectDir, `${entry.sessionId}.jsonl`), `${line}\n`, 'utf-8'); } } @@ -222,7 +218,10 @@ describe('usage handlers semantics', () => { res as never ); - const payload = res.payload as { success: boolean; data: Array<{ hour: string; requests: number }> }; + const payload = res.payload as { + success: boolean; + data: Array<{ hour: string; requests: number }>; + }; const targetHour = payload.data.find((row) => row.hour === '2026-03-02 10:00'); expect(targetHour?.requests).toBe(3); @@ -415,6 +414,19 @@ describe('usage handlers semantics', () => { }); }); + it('rejects non-string profile filters as validation errors', async () => { + for (const profile of [['work', 'default'], { name: 'work' }]) { + const res = createMockResponse(); + await handlers.handleSummary({ query: { profile } } as never, res as never); + + expect(res.statusCode).toBe(400); + expect(res.payload).toMatchObject({ + success: false, + error: 'Invalid profile filter', + }); + } + }); + it('rejects reversed date ranges before computing summary totals', async () => { const res = createMockResponse(); diff --git a/ui/public/assets/providers/qoder.svg b/ui/public/assets/providers/qoder.svg new file mode 100644 index 00000000..829ec79a --- /dev/null +++ b/ui/public/assets/providers/qoder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 12cfda55..77e8a2fb 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -29,7 +29,7 @@ import { Loader2, ExternalLink, User, Download, Copy, Check, ShieldAlert } from import { useKiroImport } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { applyDefaultPreset } from '@/lib/preset-utils'; -import type { CliproxyProviderCatalog } from '@/lib/api-client'; +import type { CliproxyProviderCatalog, OAuthAccount } from '@/lib/api-client'; import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card'; import { AntigravityResponsibilityChecklist } from '@/components/account/antigravity-responsibility-checklist'; import { @@ -58,6 +58,8 @@ interface AddAccountDialogProps { provider: string; displayName: string; catalog?: CliproxyProviderCatalog; + /** Existing account to reauthenticate instead of adding a new account. */ + account?: OAuthAccount; /** Whether this is the first account being added (shows different toast message) */ isFirstAccount?: boolean; } @@ -77,6 +79,7 @@ export function AddAccountDialog({ provider, displayName, catalog, + account, isFirstAccount = false, }: AddAccountDialogProps) { const [nickname, setNickname] = useState(''); @@ -127,6 +130,8 @@ export function AddAccountDialog({ const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim(); const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim(); const errorMessage = localError || authFlow.error; + const isReauth = Boolean(account); + const accountLabel = account?.email || account?.nickname || account?.id || displayName; const fetchPowerUserModeState = useCallback(async (): Promise => { const response = await fetch('/api/settings/auth/antigravity-risk'); @@ -325,7 +330,8 @@ export function AddAccountDialog({ } wasAuthenticatingRef.current = true; authFlow.startAuth(provider, { - nickname: nicknameTrimmed || undefined, + accountId: account?.id, + nickname: account ? account.nickname : nicknameTrimmed || undefined, kiroMethod: isKiro ? kiroAuthMethod : undefined, kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined, kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined, @@ -385,13 +391,19 @@ export function AddAccountDialog({ }} > - {t('addAccountDialog.title', { displayName })} + + {isReauth + ? `Reauthenticate ${displayName}` + : t('addAccountDialog.title', { displayName })} + - {isKiro - ? t('addAccountDialog.descKiro') - : isDeviceCode - ? t('addAccountDialog.descDeviceCode') - : t('addAccountDialog.descOauth')} + {isReauth + ? `Refresh credentials for ${accountLabel}.` + : isKiro + ? t('addAccountDialog.descKiro') + : isDeviceCode + ? t('addAccountDialog.descDeviceCode') + : t('addAccountDialog.descOauth')} @@ -600,7 +612,16 @@ export function AddAccountDialog({ )} {/* Nickname input - only show before auth starts */} - {!showAuthUI && ( + {!showAuthUI && isReauth && ( +
+
{accountLabel}
+
+ This sign-in updates the selected account instead of creating another account. +
+
+ )} + + {!showAuthUI && !isReauth && (
@@ -784,7 +805,7 @@ export function AddAccountDialog({ } > - {t('addAccountDialog.authenticate')} + {isReauth ? 'Reauthenticate' : t('addAccountDialog.authenticate')} )}
diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 1a5c6b1d..6a013a6e 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -57,6 +57,10 @@ function clearLocalControlPanelSession(): void { window.localStorage.removeItem(CONTROL_PANEL_LOGIN_FLAG_KEY); } +function clearPersistedLocalControlPanelSecret(): void { + window.localStorage.removeItem(CONTROL_PANEL_MANAGEMENT_KEY); +} + export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) { const { t } = useTranslation(); const iframeRef = useRef(null); @@ -162,7 +166,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel return; } + const handleBeforeUnload = () => { + clearLocalControlPanelSession(); + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); clearLocalControlPanelSession(); }; }, [isRemote]); @@ -266,9 +276,15 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel const handleIframeLoad = () => { setLoadedFrameKey(iframeKey); postRemoteAutoLoginCredentials(); + if (!isRemote) { + clearPersistedLocalControlPanelSecret(); + } }; const handleRefresh = () => { + if (!isRemote) { + clearLocalControlPanelSession(); + } setLoadedFrameKey(null); setIframeRevision((value) => value + 1); setError(null); diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 5da3a360..1627706d 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -24,6 +24,7 @@ import { MoreHorizontal, Pause, Play, + RefreshCw, Star, Trash2, } from 'lucide-react'; @@ -87,6 +88,7 @@ export function AccountItem({ account, onSetDefault, onRemove, + onReauth, onPauseToggle, isRemoving, isPausingAccount, @@ -167,6 +169,12 @@ export function AccountItem({ Set as default )} + {onReauth && ( + + + Reauthenticate + + )} void; + onReauthAccount?: (account: OAuthAccount) => void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; onPauseToggle?: (accountId: string, paused: boolean) => void; @@ -48,6 +49,7 @@ interface AccountsSectionProps { export function AccountsSection({ accounts, onAddAccount, + onReauthAccount, onSetDefault, onRemoveAccount, onPauseToggle, @@ -175,6 +177,7 @@ export function AccountsSection({ account={account} onSetDefault={() => onSetDefault(account.id)} onRemove={() => onRemoveAccount(account.id)} + onReauth={onReauthAccount ? () => onReauthAccount(account) : undefined} onPauseToggle={ onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined } diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 65be5b3b..b4df3f7a 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -42,6 +42,7 @@ export function ProviderEditor({ defaultTarget, topNotice, onAddAccount, + onReauthAccount, onSetDefault, onRemoveAccount, onPauseToggle, @@ -92,6 +93,7 @@ export function ProviderEditor({ qwen: ['alibaba', 'qwen'], iflow: ['iflow'], kilo: ['kilo'], + qoder: ['qoder'], kiro: ['kiro', 'aws'], ghcp: ['github', 'copilot'], kimi: ['kimi', 'moonshot'], @@ -282,6 +284,7 @@ export function ProviderEditor({ isDeletePending={deletePresetMutation.isPending} accounts={accounts} onAddAccount={onAddAccount} + onReauthAccount={onReauthAccount} onSetDefault={onSetDefault} onRemoveAccount={onRemoveAccount} onPauseToggle={onPauseToggle} diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index 5ba8219e..c49f6225 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -40,6 +40,7 @@ interface ModelConfigTabProps { isDeletePending?: boolean; accounts: OAuthAccount[]; onAddAccount: () => void; + onReauthAccount?: (account: OAuthAccount) => void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; onPauseToggle?: (accountId: string, paused: boolean) => void; @@ -82,6 +83,7 @@ export function ModelConfigTab({ isDeletePending, accounts, onAddAccount, + onReauthAccount, onSetDefault, onRemoveAccount, onPauseToggle, @@ -176,6 +178,7 @@ export function ModelConfigTab({ void; + onReauthAccount?: (account: OAuthAccount) => void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; onPauseToggle?: (accountId: string, paused: boolean) => void; @@ -63,6 +64,7 @@ export interface AccountItemProps { account: OAuthAccount; onSetDefault: () => void; onRemove: () => void; + onReauth?: () => void; onPauseToggle?: (paused: boolean) => void; /** Solo mode: activate this account, pause all others */ onSoloMode?: () => void; diff --git a/ui/src/components/logs/derive-trace-groups.ts b/ui/src/components/logs/derive-trace-groups.ts index a533fc38..04e0d8ad 100644 --- a/ui/src/components/logs/derive-trace-groups.ts +++ b/ui/src/components/logs/derive-trace-groups.ts @@ -17,20 +17,30 @@ export interface LeafItem { export type DerivedItem = LeafItem | TraceGroup; +function stableStringify(value: unknown): string { + if (value === undefined) return ''; + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(',')}}`; +} + /** - * Tuple key for coalescing standalone leaves. Includes `message` and - * `source` so two adjacent logs that share event/module/level but report - * different content (or come from a different service) stay distinct - * (e.g. `User logged in: alice` and `User logged in: bob`). Excludes - * `latencyMs` and `metadata` because those drift per request even on - * truly redundant polls — including them would prevent any coalescing. + * Tuple key for coalescing standalone leaves. The row/detail UI exposes more + * than event/module/level, so every inspectable payload field must participate + * in equality; otherwise two adjacent logs with different metadata, context, + * error, or latency could collapse into one selectable row. `timestamp` stays + * out of the key because collapsed rows surface their time span separately. * * NB: this only applies to *leaves* (entries without `requestId`). Trace * children render uncoalesced so retries and duplicated-stage emissions * stay individually inspectable. */ function coalesceKey(entry: LogsEntry): string { - return [ + return JSON.stringify([ entry.event ?? '', entry.message ?? '', entry.stage ?? '', @@ -38,7 +48,13 @@ function coalesceKey(entry: LogsEntry): string { entry.level, entry.requestId ?? '', entry.source ?? '', - ].join(' '); + entry.runId ?? '', + String(entry.processId ?? ''), + String(entry.latencyMs ?? ''), + stableStringify(entry.context), + stableStringify(entry.metadata), + stableStringify(entry.error), + ]); } /** diff --git a/ui/src/components/logs/logs-detail-panel.tsx b/ui/src/components/logs/logs-detail-panel.tsx index 09c68564..4ff0d38d 100644 --- a/ui/src/components/logs/logs-detail-panel.tsx +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -9,6 +9,7 @@ import { LogLevelBadge } from './log-level-badge'; import { LogsEmpty } from './logs-empty'; import { formatJson, + formatLogTimestampIso, getDisplayLatency, getDisplayModule, getDisplayRequestId, @@ -34,7 +35,7 @@ interface OverviewRow { function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] { // Use shared accessors so this panel and the list row never diverge. return [ - { label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true }, + { label: 'Time', value: formatLogTimestampIso(entry.timestamp), mono: true }, { label: 'Level', value: entry.level }, { label: 'Module', value: getDisplayModule(entry, sourceLabel) }, { label: 'Stage', value: getDisplayStage(entry) }, diff --git a/ui/src/components/logs/utils.ts b/ui/src/components/logs/utils.ts index 1c77dbf3..11180fc5 100644 --- a/ui/src/components/logs/utils.ts +++ b/ui/src/components/logs/utils.ts @@ -21,6 +21,19 @@ export function formatLogTimestamp(timestamp: string | null | undefined) { }).format(date); } +export function formatLogTimestampIso(timestamp: string | null | undefined) { + if (!timestamp) { + return 'No activity yet'; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return timestamp; + } + + return date.toISOString(); +} + export function formatRelativeLogTime(timestamp: string | null | undefined) { if (!timestamp) { return 'No activity yet'; diff --git a/ui/src/components/monitoring/auth-monitor/hooks.ts b/ui/src/components/monitoring/auth-monitor/hooks.ts index 0ed653d0..cd944488 100644 --- a/ui/src/components/monitoring/auth-monitor/hooks.ts +++ b/ui/src/components/monitoring/auth-monitor/hooks.ts @@ -4,7 +4,7 @@ import { useState, useMemo, useEffect } from 'react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; -import { useCliproxyStats } from '@/hooks/use-cliproxy-stats'; +import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; import { buildAccountVisualGroups } from '@/lib/account-visual-groups'; import { getProviderDisplayName } from '@/lib/provider-config'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; @@ -26,14 +26,25 @@ export interface AuthMonitorData { /** Hook for computing auth monitor data from CLIProxy auth and stats */ export function useAuthMonitorData(): AuthMonitorData { const { data, isLoading, error } = useCliproxyAuth(); - const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats(); + const { data: proxyStatus } = useCliproxyStatus(); + const statsEnabled = proxyStatus?.running === true; + const { + data: statsData, + isLoading: statsLoading, + dataUpdatedAt, + } = useCliproxyStats(statsEnabled); + const activeStatsData = statsEnabled ? statsData : undefined; + const activeStatsUpdatedAt = statsEnabled ? dataUpdatedAt : 0; const [timeSinceUpdate, setTimeSinceUpdate] = useState(''); // Live countdown showing time since last data update useEffect(() => { - if (!dataUpdatedAt) return; + if (!activeStatsUpdatedAt) { + setTimeSinceUpdate(''); + return; + } const updateTime = () => { - const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000); + const diff = Math.floor((Date.now() - activeStatsUpdatedAt) / 1000); if (diff < 60) { setTimeSinceUpdate(`${diff}s ago`); } else { @@ -43,7 +54,7 @@ export function useAuthMonitorData(): AuthMonitorData { updateTime(); const interval = setInterval(updateTime, 1000); return () => clearInterval(interval); - }, [dataUpdatedAt]); + }, [activeStatsUpdatedAt]); // Transform auth status data into account rows const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => { @@ -79,7 +90,7 @@ export function useAuthMonitorData(): AuthMonitorData { provider: account.provider || status.provider, })); - buildAccountVisualGroups(normalizedAccounts, statsData).forEach((groupedAccount) => { + buildAccountVisualGroups(normalizedAccounts, activeStatsData).forEach((groupedAccount) => { tSuccess += groupedAccount.successCount; tFailure += groupedAccount.failureCount; providerData.success += groupedAccount.successCount; @@ -131,7 +142,7 @@ export function useAuthMonitorData(): AuthMonitorData { totalRequests: tSuccess + tFailure, providerStats: providerStatsArr, }; - }, [data?.authStatus, statsData]); + }, [data?.authStatus, activeStatsData]); const overallSuccessRate = totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100; @@ -143,7 +154,7 @@ export function useAuthMonitorData(): AuthMonitorData { totalRequests, providerStats, overallSuccessRate, - isLoading: isLoading || statsLoading, + isLoading: isLoading || (statsEnabled && statsLoading), error: error ?? null, timeSinceUpdate, }; diff --git a/ui/src/components/monitoring/auth-monitor/index.tsx b/ui/src/components/monitoring/auth-monitor/index.tsx index a521a162..d9e6e2b7 100644 --- a/ui/src/components/monitoring/auth-monitor/index.tsx +++ b/ui/src/components/monitoring/auth-monitor/index.tsx @@ -16,7 +16,7 @@ import { usePauseAccount, useResumeAccount, } from '@/hooks/use-cliproxy'; -import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react'; +import { Activity, AlertTriangle, CheckCircle2, XCircle, Radio } from 'lucide-react'; import { useAuthMonitorData } from './hooks'; import { LivePulse } from './components/live-pulse'; @@ -86,6 +86,8 @@ export function AuthMonitor() { const displayedSuccessRate = selectedProviderData ? getSuccessRate(selectedProviderData.successCount, selectedProviderData.failureCount) : overallSuccessRate; + const showHighFailureGuidance = + displayedAccountCount >= 5 && displayedFailure >= 20 && displayedSuccessRate < 50; const handlePauseToggle = (accountIds: string[], paused: boolean) => { if ( @@ -204,6 +206,21 @@ export function AuthMonitor() { />
+ {showHighFailureGuidance && ( +
+ +
+

High CLIProxy failure rate detected

+

+ Select the affected provider, pause failing accounts, then check{' '} + ccs cliproxy routing. + For large Codex pools, prefer a smaller healthy active set and attach CLIProxy logs if + failures continue. +

+
+
+ )} + {/* Flow Visualization */}
{selectedProviderData ? ( diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index f40b591e..1fca25f4 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -25,6 +25,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ 'iflow', 'kilo', 'kiro', + 'qoder', ]; export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({ diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 8fd4f5c0..f11774dc 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -378,7 +378,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro if (isGeminiQuotaResult(quota)) { const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime); const hasEntitlementTier = - !!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown'; + !!quota.entitlement?.rawTierLabel || + (!!quota.entitlement && quota.entitlement.normalizedTier !== 'unknown'); const distinctTokenTypes = Array.from( new Set( quota.buckets diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 509abdf9..176a95fe 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -25,6 +25,7 @@ interface AuthFlowState { } interface StartAuthOptions { + accountId?: string; nickname?: string; kiroMethod?: string; kiroIDCStartUrl?: string; @@ -286,6 +287,7 @@ export function useCliproxyAuthFlow() { const deviceCodeFlow = flowType === 'device_code'; const startEndpoint = options?.startEndpoint || (deviceCodeFlow ? 'start' : 'start-url'); const payload = { + accountId: options?.accountId, nickname: options?.nickname, kiroMethod: options?.kiroMethod, kiroIDCStartUrl: options?.kiroIDCStartUrl, diff --git a/ui/src/lib/cliproxy-version-risk.ts b/ui/src/lib/cliproxy-version-risk.ts index 3e55ea2e..73adf225 100644 --- a/ui/src/lib/cliproxy-version-risk.ts +++ b/ui/src/lib/cliproxy-version-risk.ts @@ -1,20 +1,39 @@ -function normalizeVersionParts(version: string): number[] { - return version.replace(/-\d+$/, '').split('.').map(Number); +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) => Number.parseInt(part, 10) || 0); + const forkRelease = /^\d+$/.test(forkReleaseValue) + ? Number.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 aParts = normalizeVersionParts(a); - const bParts = normalizeVersionParts(b); + const left = parseCliproxyVersion(a); + const right = parseCliproxyVersion(b); - for (let index = 0; index < 3; index += 1) { - const aPart = aParts[index] || 0; - const bPart = bParts[index] || 0; - - if (aPart > bPart) return 1; - if (aPart < bPart) return -1; - } - - return 0; + return ( + compareVersionPart(left.major, right.major) || + compareVersionPart(left.minor, right.minor) || + compareVersionPart(left.patch, right.patch) || + compareVersionPart(left.forkRelease, right.forkRelease) + ); } export function isCliproxyVersionExperimental(version: string, maxStableVersion: string): boolean { diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index df7d35dd..805120af 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -454,6 +454,128 @@ export const MODEL_CATALOGS: Record = { }, ], }, + 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', + presetMapping: { + default: 'qoder/auto', + opus: 'qoder/auto', + sonnet: 'qoder/auto', + haiku: 'qoder/auto', + }, + }, + { + id: 'qoder/ultimate', + name: 'Qoder Ultimate', + description: 'Highest quality Qoder tier', + presetMapping: { + default: 'qoder/ultimate', + opus: 'qoder/ultimate', + sonnet: 'qoder/ultimate', + haiku: 'qoder/ultimate', + }, + }, + { + id: 'qoder/performance', + name: 'Qoder Performance', + description: 'Balanced quality and speed', + presetMapping: { + default: 'qoder/performance', + opus: 'qoder/performance', + sonnet: 'qoder/performance', + haiku: 'qoder/performance', + }, + }, + { + id: 'qoder/efficient', + name: 'Qoder Efficient', + description: 'Cost-efficient Qoder tier', + presetMapping: { + default: 'qoder/efficient', + opus: 'qoder/efficient', + sonnet: 'qoder/efficient', + haiku: 'qoder/efficient', + }, + }, + { + id: 'qoder/lite', + name: 'Qoder Lite', + description: 'Fastest and most affordable Qoder tier', + presetMapping: { + default: 'qoder/lite', + opus: 'qoder/lite', + sonnet: 'qoder/lite', + haiku: 'qoder/lite', + }, + }, + { + id: 'qoder/qmodel', + name: 'Qwen 3.6 Plus (via Qoder)', + description: 'Qwen 3.6 Plus frontier model', + presetMapping: { + default: 'qoder/qmodel', + opus: 'qoder/qmodel', + sonnet: 'qoder/qmodel', + haiku: 'qoder/qmodel', + }, + }, + { + id: 'qoder/dmodel', + name: 'DeepSeek V4 Pro (via Qoder)', + description: 'DeepSeek V4 Pro frontier model', + presetMapping: { + default: 'qoder/dmodel', + opus: 'qoder/dmodel', + sonnet: 'qoder/dmodel', + haiku: 'qoder/dfmodel', + }, + }, + { + 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', + presetMapping: { + default: 'qoder/gm51model', + opus: 'qoder/gm51model', + sonnet: 'qoder/gm51model', + haiku: 'qoder/gm51model', + }, + }, + { + id: 'qoder/kmodel', + name: 'Kimi K2.6 (via Qoder)', + description: 'Kimi K2.6 frontier model', + presetMapping: { + default: 'qoder/kmodel', + opus: 'qoder/kmodel', + sonnet: 'qoder/kmodel', + haiku: 'qoder/kmodel', + }, + }, + { + id: 'qoder/mmodel', + name: 'MiniMax M2.7 (via Qoder)', + description: 'MiniMax M2.7 frontier model', + presetMapping: { + default: 'qoder/mmodel', + opus: 'qoder/mmodel', + sonnet: 'qoder/mmodel', + haiku: 'qoder/mmodel', + }, + }, + ], + }, kimi: { provider: 'kimi', displayName: 'Kimi (Moonshot)', diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index b99bf270..cda84a9b 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -105,6 +105,7 @@ export const PROVIDER_ASSETS: Partial> = { gitlab: '/assets/providers/gitlab.svg', codebuddy: '/assets/providers/codebuddy.png', kilo: '/assets/providers/kilo.png', + qoder: '/assets/providers/qoder.svg', ghcp: '/assets/providers/copilot.svg', claude: '/assets/providers/claude.svg', kimi: '/assets/providers/kimi.svg', @@ -135,6 +136,7 @@ export const PROVIDER_FALLBACK_VISUALS: Record = { gitlab: '#FC6D26', codebuddy: '#2563EB', kilo: '#E11D48', + qoder: '#D97706', ghcp: '#43aa8b', claude: '#D97757', kimi: '#FF6B35', diff --git a/ui/src/pages/analytics/components/analytics-header.tsx b/ui/src/pages/analytics/components/analytics-header.tsx index 51ea43dd..620fe771 100644 --- a/ui/src/pages/analytics/components/analytics-header.tsx +++ b/ui/src/pages/analytics/components/analytics-header.tsx @@ -15,8 +15,9 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { RefreshCw } from 'lucide-react'; +import { Info, RefreshCw } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import type { AnalyticsProfileOption } from '../hooks'; interface AnalyticsHeaderProps { @@ -53,21 +54,44 @@ export function AnalyticsHeader({

{t('analytics.subtitle')}

- +
+ + {selectedProfile !== 'all' && ( + + + + + + + Selected-profile analytics include only default/account data with stable profile + attribution. CLIProxy and native runtime snapshots remain in All profiles. + + + + )} +
- {selectedProfile !== 'all' && ( -

- Selected-profile analytics include only default/account data with stable profile - attribution. CLIProxy and native runtime snapshots remain in All profiles. -

- )} ); } diff --git a/ui/src/pages/analytics/hooks.ts b/ui/src/pages/analytics/hooks.ts index 80e16838..d08c2537 100644 --- a/ui/src/pages/analytics/hooks.ts +++ b/ui/src/pages/analytics/hooks.ts @@ -6,7 +6,18 @@ import { useState, useMemo, useCallback } from 'react'; import type { DateRange } from 'react-day-picker'; -import { subDays, formatDistanceToNow } from 'date-fns'; +import { subDays } from 'date-fns'; + +function formatCompactRelativeTime(date: Date): string { + const seconds = Math.max(1, Math.floor((Date.now() - date.getTime()) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} import { useUsageSummary, useUsageTrends, @@ -151,10 +162,10 @@ export function useAnalyticsPage() { persistSelectedProfile(profile); }, []); - // Format "Last updated" text + // Compact "Last updated" text (e.g. "1m ago", "2h ago") const lastUpdatedText = useMemo(() => { if (!status?.lastFetch) return null; - return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true }); + return formatCompactRelativeTime(new Date(status.lastFetch)); }, [status?.lastFetch]); // Handle model click for popover diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 95a8d6e3..e28f21dd 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -31,7 +31,7 @@ import { useBulkResumeAccounts, useDeleteVariant, } from '@/hooks/use-cliproxy'; -import type { AuthStatus, Variant } from '@/lib/api-client'; +import type { AuthStatus, OAuthAccount, Variant } from '@/lib/api-client'; import { buildUiCatalogs } from '@/lib/model-catalogs'; import { getProviderDisplayName, @@ -256,6 +256,7 @@ export function CliproxyPage() { provider: string; displayName: string; isFirstAccount: boolean; + account?: OAuthAccount; } | null>(() => { if (typeof window === 'undefined') { return null; @@ -404,6 +405,23 @@ export function CliproxyPage() { }); }; + const handleReauthAccount = ( + provider: string, + displayName: string, + accounts: OAuthAccount[] | undefined, + accountId: string + ) => { + const account = accounts?.find((candidate) => candidate.id === accountId); + if (!account) return; + + setAddAccountProvider({ + provider, + displayName, + isFirstAccount: false, + account, + }); + }; + return (
{/* Left Sidebar */} @@ -567,6 +585,14 @@ export function CliproxyPage() { isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0, }) } + onReauthAccount={(account) => + handleReauthAccount( + selectedVariantData.provider, + parentAuthForVariant.displayName, + parentAuthForVariant.accounts, + account.id + ) + } onSetDefault={(accountId) => setDefaultMutation.mutate({ provider: selectedVariantData.provider, @@ -617,6 +643,14 @@ export function CliproxyPage() { isFirstAccount: (selectedStatus.accounts?.length || 0) === 0, }) } + onReauthAccount={(account) => + handleReauthAccount( + selectedStatus.provider, + selectedStatus.displayName, + selectedStatus.accounts, + account.id + ) + } onSetDefault={(accountId) => setDefaultMutation.mutate({ provider: selectedStatus.provider, @@ -663,6 +697,7 @@ export function CliproxyPage() { : undefined } isFirstAccount={addAccountProvider?.isFirstAccount || false} + account={addAccountProvider?.account} />
); diff --git a/ui/tests/unit/components/cliproxy/control-panel-embed.test.tsx b/ui/tests/unit/components/cliproxy/control-panel-embed.test.tsx index e70c0a52..c2463db5 100644 --- a/ui/tests/unit/components/cliproxy/control-panel-embed.test.tsx +++ b/ui/tests/unit/components/cliproxy/control-panel-embed.test.tsx @@ -94,6 +94,7 @@ describe('ControlPanelEmbed', () => { const iframe = await screen.findByTitle('CLIProxy Management Panel'); expect(iframe).toHaveAttribute('src', '/api/cliproxy-local/management.html'); + fireEvent.load(iframe); await waitFor(() => { expect(window.localStorage.removeItem).toHaveBeenCalledWith('cli-proxy-auth'); @@ -107,6 +108,7 @@ describe('ControlPanelEmbed', () => { ); expect(window.localStorage.setItem).toHaveBeenCalledWith('managementKey', 'custom-secret'); expect(window.localStorage.setItem).toHaveBeenCalledWith('isLoggedIn', 'true'); + expect(window.localStorage.removeItem).toHaveBeenCalledWith('managementKey'); }); vi.clearAllMocks(); diff --git a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx index 5827b444..8cf8f533 100644 --- a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx +++ b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx @@ -86,6 +86,47 @@ describe('useCliproxyAuthFlow', () => { ); }); + it('includes the selected account id when reauth starts from the dashboard', async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/codex/start-url')) { + return Promise.resolve( + createJsonResponse({ + success: true, + authUrl: 'https://auth.example.com/codex?state=codex-reauth', + state: 'codex-reauth', + }) + ); + } + + if (url.includes('/status?state=codex-reauth')) { + return Promise.resolve(createJsonResponse({ status: 'wait' })); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('codex', { + accountId: 'existing@example.com', + startEndpoint: 'start-url', + }); + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/cliproxy/auth/codex/start-url', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"accountId":"existing@example.com"'), + }) + ); + }); + it('surfaces manual OAuth start guidance from the dashboard API', async () => { const guidance = [ 'Start local CLIProxy first: ccs cliproxy start', diff --git a/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts b/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts index 1fef043f..21025f6b 100644 --- a/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts +++ b/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts @@ -141,6 +141,65 @@ describe('deriveTraceGroups', () => { expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true); }); + it('keeps adjacent leaves with different detail payloads as separate rows', () => { + const result = deriveTraceGroups( + leafEntries( + { + id: '1', + timestamp: 't1', + event: 'poll', + message: 'same', + latencyMs: 25, + metadata: { attempt: 1, nested: { status: 'warm' } }, + context: { account: 'alpha' }, + error: { code: 'E_ONE', message: 'first' }, + }, + { + id: '2', + timestamp: 't2', + event: 'poll', + message: 'same', + latencyMs: 50, + metadata: { attempt: 2, nested: { status: 'cold' } }, + context: { account: 'beta' }, + error: { code: 'E_TWO', message: 'second' }, + } + ) + ); + + expect(result).toHaveLength(2); + expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true); + expect(result.map((r) => (r.kind === 'leaf' ? r.entry.id : 'trace'))).toEqual(['2', '1']); + }); + + it('coalesces leaves with semantically identical structured payloads', () => { + const result = deriveTraceGroups( + leafEntries( + { + id: '1', + timestamp: 't1', + event: 'poll', + message: 'tick', + latencyMs: 25, + metadata: { b: 2, a: 1 }, + context: { nested: { b: false, a: true } }, + }, + { + id: '2', + timestamp: 't2', + event: 'poll', + message: 'tick', + latencyMs: 25, + metadata: { a: 1, b: 2 }, + context: { nested: { a: true, b: false } }, + } + ) + ); + + expect(result).toHaveLength(1); + expect((result[0] as LeafItem).repeatCount).toBe(2); + }); + it('display-sorts items reverse-chronologically', () => { // Use distinct events so leaves don't coalesce — testing display sort, // not coalesce. diff --git a/ui/tests/unit/ui/components/logs-detail-panel.test.tsx b/ui/tests/unit/ui/components/logs-detail-panel.test.tsx new file mode 100644 index 00000000..4e57da9c --- /dev/null +++ b/ui/tests/unit/ui/components/logs-detail-panel.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@tests/setup/test-utils'; +import { describe, expect, it } from 'vitest'; +import { LogsDetailPanel } from '@/components/logs/logs-detail-panel'; +import type { LogsEntry } from '@/lib/api-client'; + +function buildEntry(overrides: Partial = {}): LogsEntry { + return { + id: 'entry-1', + timestamp: '2026-04-07T11:00:00.000Z', + level: 'info', + source: 'dashboard', + event: 'logs.bootstrap', + message: 'Dashboard log entry', + processId: 25582, + runId: 'run-1', + ...overrides, + }; +} + +describe('LogsDetailPanel', () => { + it('falls back to the raw timestamp when the selected log entry has an invalid timestamp', () => { + render(); + + expect(screen.getByText('not-a-date')).toBeInTheDocument(); + expect(screen.getByTitle('not-a-date')).toHaveTextContent('not-a-date'); + }); +}); diff --git a/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor-hooks.test.tsx b/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor-hooks.test.tsx new file mode 100644 index 00000000..b9c8816b --- /dev/null +++ b/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor-hooks.test.tsx @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthMonitorData } from '@/components/monitoring/auth-monitor/hooks'; + +const { useCliproxyAuthMock, useCliproxyStatsMock, useCliproxyStatusMock } = vi.hoisted(() => ({ + useCliproxyAuthMock: vi.fn(), + useCliproxyStatsMock: vi.fn(), + useCliproxyStatusMock: vi.fn(), +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useCliproxyAuth: useCliproxyAuthMock, +})); + +vi.mock('@/hooks/use-cliproxy-stats', () => ({ + useCliproxyStats: useCliproxyStatsMock, + useCliproxyStatus: useCliproxyStatusMock, +})); + +const authStatus = [ + { + provider: 'codex', + displayName: 'OpenAI Codex', + accounts: [ + { + id: 'codex-account', + email: 'codex@example.com', + tokenFile: '/tmp/codex.json', + provider: 'codex', + isDefault: true, + }, + ], + }, +]; + +describe('useAuthMonitorData', () => { + beforeEach(() => { + vi.clearAllMocks(); + useCliproxyAuthMock.mockReturnValue({ + data: { authStatus }, + isLoading: false, + error: null, + }); + useCliproxyStatsMock.mockReturnValue({ + data: undefined, + isLoading: false, + dataUpdatedAt: 0, + }); + }); + + it('keeps account data visible without polling stats when CLIProxy is unavailable', () => { + useCliproxyStatusMock.mockReturnValue({ + data: { running: false }, + isLoading: false, + }); + + const { result } = renderHook(() => useAuthMonitorData()); + + expect(useCliproxyStatsMock).toHaveBeenCalledWith(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.accounts).toHaveLength(1); + expect(result.current.totalRequests).toBe(0); + expect(result.current.providerStats[0]).toMatchObject({ + provider: 'codex', + accountCount: 1, + totalRequests: 0, + }); + }); + + it('enables live stats only after CLIProxy is running', () => { + useCliproxyStatusMock.mockReturnValue({ + data: { running: true }, + isLoading: false, + }); + useCliproxyStatsMock.mockReturnValue({ + data: { + accountStats: { + 'codex:codex@example.com': { + source: 'codex@example.com', + successCount: 8, + failureCount: 2, + totalTokens: 100, + provider: 'codex', + }, + }, + }, + isLoading: false, + dataUpdatedAt: Date.now(), + }); + + const { result } = renderHook(() => useAuthMonitorData()); + + expect(useCliproxyStatsMock).toHaveBeenCalledWith(true); + expect(result.current.totalSuccess).toBe(8); + expect(result.current.totalFailure).toBe(2); + expect(result.current.totalRequests).toBe(10); + }); +}); diff --git a/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor.test.tsx b/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor.test.tsx index c7174ff6..e98c1630 100644 --- a/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor.test.tsx +++ b/ui/tests/unit/ui/components/monitoring/auth-monitor/auth-monitor.test.tsx @@ -191,4 +191,41 @@ describe('AuthMonitor', () => { expect(within(getSummaryCard('Failed')).getByText('3')).toBeInTheDocument(); expect(within(getSummaryCard('Success Rate')).getByText('80%')).toBeInTheDocument(); }); + + it('shows CLIProxy guidance when a large account pool has a high failure rate', () => { + useAuthMonitorDataMock.mockReturnValue({ + ...authMonitorData, + accounts: Array.from({ length: 5 }, (_, index) => ({ + id: `codex-${index}`, + email: `codex-${index}@example.com`, + tokenFile: `/tmp/codex-${index}.json`, + provider: 'codex', + displayName: 'OpenAI Codex', + isDefault: index === 0, + successCount: index === 0 ? 1 : 0, + failureCount: 5, + color: '#10a37f', + })), + totalSuccess: 1, + totalFailure: 25, + totalRequests: 26, + providerStats: [ + { + provider: 'codex', + displayName: 'OpenAI Codex', + totalRequests: 26, + successCount: 1, + failureCount: 25, + accountCount: 5, + accounts: [], + }, + ], + overallSuccessRate: 4, + }); + + render(); + + expect(screen.getByText('High CLIProxy failure rate detected')).toBeInTheDocument(); + expect(screen.getByText(/ccs cliproxy routing/)).toBeInTheDocument(); + }); }); diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index bf600694..6780fc7a 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -83,6 +83,20 @@ describe('QuotaTooltipContent', () => { expect(screen.getByText(expectedReset)).toBeInTheDocument(); }); + it('renders the Gemini tier label when entitlement evidence is absent', () => { + const quota = createGeminiQuotaResult({ + entitlement: undefined, + tierLabel: 'Legacy Pro', + tierId: null, + creditBalance: null, + }); + + render(); + + expect(screen.getByText('Tier')).toBeInTheDocument(); + expect(screen.getByText('Legacy Pro')).toBeInTheDocument(); + }); + it('falls back to the shared reset indicator when Gemini buckets omit reset timestamps', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-29T00:00:00Z')); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index f4552276..8d11c520 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -68,6 +68,7 @@ describe('provider presentation metadata', () => { 'gitlab', 'codebuddy', 'kilo', + 'qoder', ]); expect(getProviderSection('gitlab')?.id).toBe('plus-extra'); expect(getProviderSection('gemini')?.id).toBe('core'); @@ -128,6 +129,7 @@ describe('provider presentation metadata', () => { '/assets/providers/codebuddy.png', ], ['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.png'], + ['qoder', 'Qoder', 'Qoder AI coding assistant', '/assets/providers/qoder.svg'], ])('recognizes %s across dashboard display helpers', (provider, name, description, asset) => { expect(getProviderDisplayName(provider)).toBe(name); expect(getProviderDescription(provider)).toBe(description);