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/ci.yml b/.github/workflows/ci.yml index 3ba79cce..e4315973 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,3 +160,37 @@ jobs: - name: Assert compose parity (compose.yaml vs docker-compose.integrated.yml) run: bash tests/docker/compose-parity.sh + + # Single always-reporting status that branch protection requires. + # + # The validate/build/test jobs are gated to trusted author associations so + # untrusted fork code never executes on the self-hosted runners. A job skipped + # by a job-level `if` reports no status, so requiring those job names directly + # leaves fork PRs stuck on "Expected - waiting for status to be reported" + # forever. Requiring this gate instead keeps the contract satisfiable: + # - trusted PR: gate is green only if every gated job actually succeeded + # - fork PR: gated jobs skip, gate reports green so the PR is mergeable + # (fork code is still reviewed by a maintainer before merge) + # + # No checkout here: the gate only inspects upstream job results, so it runs no + # third-party code and is safe on the self-hosted runner. + ci-gate: + if: always() + needs: [validate, build, test] + runs-on: [self-hosted, linux, x64] + name: CI Gate + steps: + - name: Verify gated jobs did not fail + env: + VALIDATE: ${{ needs.validate.result }} + BUILD: ${{ needs.build.result }} + TEST: ${{ needs.test.result }} + run: | + echo "validate=$VALIDATE build=$BUILD test=$TEST" + for result in "$VALIDATE" "$BUILD" "$TEST"; do + if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then + echo "[X] A required CI job did not pass (result: $result)" + exit 1 + fi + done + echo "[OK] CI gate satisfied (success or skipped-for-fork)" diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 875a9cb6..afb7b699 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -139,7 +139,7 @@ jobs: COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true) ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \ - perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ grep -oE '#[0-9]+' || true) PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \ @@ -155,7 +155,7 @@ jobs: done ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \ - perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ grep -oE '#[0-9]+' || true) ISSUES=$(printf '%s\n%s\n' "$ISSUES_FROM_COMMITS" "$ISSUES_FROM_PRS" | \ diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 214bacda..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 @@ -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 diff --git a/.github/workflows/label-pending-release.yml b/.github/workflows/label-pending-release.yml index 7452f85d..3b10a329 100644 --- a/.github/workflows/label-pending-release.yml +++ b/.github/workflows/label-pending-release.yml @@ -38,7 +38,7 @@ jobs: $PR_BODY $COMMIT_TEXT" PR_ISSUES=$(printf '%s\n' "$PR_TEXT" | \ - perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ grep -oE '#[0-9]+' || true) if [[ -n "$PR_ISSUES" ]]; then ALL_REFERENCED_ISSUES=$(printf '%s\n%s\n' "$ALL_REFERENCED_ISSUES" "$PR_ISSUES") 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/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/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/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 6d785128..a2b11eb4 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -136,7 +136,7 @@ export function resolveTargetType( // 4. Check profile config if (profileConfig?.target) { - // Persisted targets intentionally exclude runtime-only codex. + // Persisted targets are validated before profile configuration is saved. return profileConfig.target; } diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index bfb0d2f2..aed06fb0 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.', }, }, @@ -1230,11 +1234,16 @@ function getTools() { ]; } -async function fetchJson(url, options = undefined) { +async function fetchOk(url, options = undefined) { const response = await fetch(url, options); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } + return response; +} + +async function fetchJson(url, options = undefined) { + const response = await fetchOk(url, options); return await response.json(); } @@ -2305,10 +2314,13 @@ function requirePositiveIntegerOrDefault(value, label, fallback) { return value; } -function requirePositiveInteger(value, label) { +function requirePositiveInteger(value, label, maximum = undefined) { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${label} must be a positive integer`); } + if (maximum !== undefined && value > maximum) { + throw new Error(`${label} must be less than or equal to ${maximum}`); + } return value; } @@ -3555,7 +3567,7 @@ async function handleClick(toolArgs) { const clickCount = toolArgs.clickCount === undefined ? 1 - : requirePositiveInteger(toolArgs.clickCount, 'clickCount'); + : requirePositiveInteger(toolArgs.clickCount, 'clickCount', MAX_CLICK_COUNT); const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); @@ -3805,7 +3817,9 @@ async function handlePressKey(toolArgs) { 'Shift', ]); const repeat = - toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat'); + toolArgs.repeat === undefined + ? 1 + : requirePositiveInteger(toolArgs.repeat, 'repeat', MAX_KEY_REPEAT); const modifierMask = (modifiers.includes('Alt') ? 1 : 0) | (modifiers.includes('Control') ? 2 : 0) | @@ -5097,16 +5111,18 @@ async function ensureInterceptSession(page) { }) ); } - pushRecentRequest({ - requestId: String(paused.requestId || ''), - pageId: page.id, - url: String(paused.request?.url || ''), - method: String(paused.request?.method || ''), - resourceType: String(paused.resourceType || ''), - matchedRuleId: matchedRule ? matchedRule.ruleId : '', - action, - statusCode: action === 'fulfill' ? matchedRule.statusCode : 0, - }); + if (matchedRule) { + pushRecentRequest({ + requestId: String(paused.requestId || ''), + pageId: page.id, + url: String(paused.request?.url || ''), + method: String(paused.request?.method || ''), + resourceType: String(paused.resourceType || ''), + matchedRuleId: matchedRule.ruleId, + action, + statusCode: action === 'fulfill' ? matchedRule.statusCode : 0, + }); + } })(); activityChain = activityChain .catch(() => {}) @@ -5193,7 +5209,7 @@ async function handleClosePage(toolArgs) { activeRecordingSession = null; } - await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`); + await fetchOk(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`, { method: 'PUT' }); const interceptSession = interceptSessionsByPageId.get(page.id); if (interceptSession) { closeSocket(interceptSession.ws); diff --git a/package.json b/package.json index b75b7d5e..39289e0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.4", + "version": "8.1.4-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index f7d19e10..a1b27984 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -22,18 +22,6 @@ __ccs_completion_run() { local current="$1" shift || true - local script_dir repo_root repo_cli - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - repo_root="$(cd "${script_dir}/../.." && pwd)" - repo_cli="${repo_root}/dist/ccs.js" - if [[ ! -f "${repo_cli}" ]]; then - repo_cli="${repo_root}/bin/ccs.js" - fi - if [[ -f "${repo_cli}" ]]; then - node "${repo_cli}" __complete --shell bash --current "${current}" -- "$@" 2>/dev/null - return 0 - fi - if command -v ccs >/dev/null 2>&1; then ccs __complete --shell bash --current "${current}" -- "$@" 2>/dev/null fi diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index 96d801bf..c06111e3 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -11,17 +11,6 @@ function __fish_ccs_complete set -e tokens_before_current[-1] end - set -l script_file (status filename) - set -l repo_root (realpath (dirname $script_file)/../.. 2>/dev/null) - set -l repo_cli "$repo_root/dist/ccs.js" - if not test -f "$repo_cli" - set repo_cli "$repo_root/bin/ccs.js" - end - if test -f "$repo_cli" - node "$repo_cli" __complete --shell fish --current "$current" -- $tokens_before_current 2>/dev/null - return - end - if command -sq ccs ccs __complete --shell fish --current "$current" -- $tokens_before_current 2>/dev/null end diff --git a/scripts/completion/ccs.ps1 b/scripts/completion/ccs.ps1 index 0ec00e94..04051ead 100644 --- a/scripts/completion/ccs.ps1 +++ b/scripts/completion/ccs.ps1 @@ -6,16 +6,6 @@ function Invoke-CcsCompletionBackend { [string[]]$TokensBeforeCurrent ) - $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path - $repoCli = Join-Path $repoRoot 'dist\ccs.js' - if (-not (Test-Path $repoCli)) { - $repoCli = Join-Path $repoRoot 'bin\ccs.js' - } - if (Test-Path $repoCli) { - & node $repoCli __complete --shell powershell --current $CurrentWord -- @TokensBeforeCurrent 2>$null - return - } - if (Get-Command ccs -ErrorAction SilentlyContinue) { & ccs __complete --shell powershell --current $CurrentWord -- @TokensBeforeCurrent 2>$null } diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index 97e59220..281da9e3 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -22,19 +22,6 @@ __ccs_completion_run() { local current="$1" shift || true - local script_path script_dir repo_root repo_cli - script_path="${(%):-%N}" - script_dir="${script_path:A:h}" - repo_root="${script_dir:h:h}" - repo_cli="${repo_root}/dist/ccs.js" - if [[ ! -f "${repo_cli}" ]]; then - repo_cli="${repo_root}/bin/ccs.js" - fi - if [[ -f "${repo_cli}" ]]; then - node "${repo_cli}" __complete --shell zsh --current "${current}" -- "$@" 2>/dev/null - return 0 - fi - if (( $+commands[ccs] )); then ccs __complete --shell zsh --current "${current}" -- "$@" 2>/dev/null fi 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..959b2e35 --- /dev/null +++ b/scripts/github/stable-release-issue-cleanup-lib.mjs @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +const ISSUE_REF_PATTERN = /#([0-9]+)/g; +const ACTION_VERB_PATTERN = + /\b(?:fixes|closes|resolves|refs?)\b\s+(#\d+\b(?:\s*(?:,|and)?\s*#\d+\b)*)/gi; +const RESOLVE_VERB_PATTERN = + /\b(?:fixes|closes|resolves)\b\s+(#\d+\b(?:\s*(?:,|and)?\s*#\d+\b)*)/gi; +const PR_REF_PATTERN = /(?:Merge pull request #|\(#)([0-9]+)/g; +const STABLE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+$/; + +export function extractIssueNumbers(text, { includeRefs = true } = {}) { + const pattern = includeRefs ? ACTION_VERB_PATTERN : RESOLVE_VERB_PATTERN; + const issues = new Set(); + let actionMatch; + + pattern.lastIndex = 0; + while ((actionMatch = pattern.exec(text || '')) !== null) { + const tail = actionMatch[1] || ''; + let issueMatch; + ISSUE_REF_PATTERN.lastIndex = 0; + while ((issueMatch = ISSUE_REF_PATTERN.exec(tail)) !== null) { + issues.add(Number(issueMatch[1])); + } + } + + return [...issues].sort((a, b) => a - b); +} + +export function extractPrNumbers(text) { + const prs = new Set(); + let match; + + PR_REF_PATTERN.lastIndex = 0; + while ((match = PR_REF_PATTERN.exec(text || '')) !== null) { + prs.add(Number(match[1])); + } + + return [...prs].sort((a, b) => a - b); +} + +export function planIssueCleanup({ releaseIssues, resolvedIssues, issueStates }) { + const resolved = new Set(resolvedIssues); + return releaseIssues.map((number) => { + const state = issueStates.get(number) || { labels: [], state: 'UNKNOWN' }; + const labels = new Set(state.labels); + const wasReleasedDev = labels.has('released-dev'); + const shouldClose = state.state === 'OPEN' && (wasReleasedDev || resolved.has(number)); + + return { + number, + removeLabels: ['released-dev', 'pending-release'], + addReleasedLabel: shouldClose, + close: shouldClose, + reason: wasReleasedDev ? 'promoted from dev to stable' : 'resolved by stable release', + }; + }); +} + +export function getStableReleaseContext({ env = process.env, exec = runCommand } = {}) { + const repo = env.GITHUB_REPOSITORY; + if (!repo) throw new Error('GITHUB_REPOSITORY is required'); + + const version = JSON.parse(readFileSync('package.json', 'utf8')).version; + const currentTag = `v${version}`; + const releaseBody = exec('gh', [ + 'release', + 'view', + currentTag, + '--repo', + repo, + '--json', + 'body', + '--jq', + '.body', + ]); + const tags = exec('git', ['tag', '-l', 'v[0-9]*.[0-9]*.[0-9]*', '--sort=-v:refname']) + .split('\n') + .map((tag) => tag.trim()) + .filter((tag) => STABLE_TAG_PATTERN.test(tag) && tag !== currentTag); + const previousStableTag = tags[0] || ''; + const range = previousStableTag ? `${previousStableTag}..HEAD~1` : 'HEAD~50..HEAD~1'; + const commitText = exec('git', ['log', range, '--pretty=format:%s%n%b'], { optional: true }); + + return { repo, version, currentTag, releaseBody, range, commitText }; +} + +export function buildReleaseIssueSet({ releaseBody, commitText, prText }) { + const releaseIssues = new Set([ + ...extractIssueNumbers(releaseBody, { includeRefs: true }), + ...extractIssueNumbers(commitText, { includeRefs: true }), + ...extractIssueNumbers(prText, { includeRefs: true }), + ]); + const resolvedIssues = new Set([ + ...extractIssueNumbers(releaseBody, { includeRefs: false }), + ...extractIssueNumbers(commitText, { includeRefs: false }), + ...extractIssueNumbers(prText, { includeRefs: false }), + ]); + + return { + releaseIssues: [...releaseIssues].sort((a, b) => a - b), + resolvedIssues: [...resolvedIssues].sort((a, b) => a - b), + }; +} + +export function runCommand(command, args, { optional = false } = {}) { + const result = spawnSync(command, args, { encoding: 'utf8' }); + if (result.status !== 0) { + if (optional) return ''; + throw new Error( + `${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}` + ); + } + return result.stdout.trim(); +} 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__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index 4ec11c1f..9fe98a30 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -55,6 +55,18 @@ describe('model-catalog compatibility lookups', () => { expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']); }); + it('falls back to a visible live model when the static default is absent', () => { + const catalog = mergeCatalog('gemini', [ + { + id: 'gemini-live-only', + display_name: 'Gemini Live Only', + }, + ]); + + expect(catalog?.defaultModel).toBe('gemini-live-only'); + expect(catalog?.models.map((model) => model.id)).toContain(catalog?.defaultModel); + }); + it('preserves static maxLevel when live thinking metadata omits it', () => { const catalog = mergeCatalog('claude', [ { diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index bc11ce90..bb3c3bdd 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -155,6 +155,19 @@ describe('Model Catalog', () => { assert.strictEqual(opus47.extendedContext, true); }); + it('includes Claude Opus 4.8 with adaptive levels and extended context', () => { + const { MODEL_CATALOG } = modelCatalog; + const opus48 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-4-8'); + assert(opus48, 'Should include Claude Opus 4.8'); + assert.strictEqual(opus48.name, 'Claude Opus 4.8'); + // Mirrors 4.7: Anthropic only accepts adaptive thinking levels on the + // current Opus generation; budget_tokens is rejected with 400. + assert.strictEqual(opus48.thinking.type, 'levels'); + assert.deepStrictEqual(opus48.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']); + assert.strictEqual(opus48.thinking.maxLevel, 'max'); + assert.strictEqual(opus48.extendedContext, true); + }); + it('retains previous 4.5 snapshot models for explicit selection', () => { const { MODEL_CATALOG } = modelCatalog; const ids = MODEL_CATALOG.claude.models.map((m) => m.id); 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/__tests__/thinking-validator.test.ts b/src/cliproxy/__tests__/thinking-validator.test.ts index c1c21ebf..221eca94 100644 --- a/src/cliproxy/__tests__/thinking-validator.test.ts +++ b/src/cliproxy/__tests__/thinking-validator.test.ts @@ -51,6 +51,15 @@ describe('Thinking Validator', () => { expect(result.warning).toBeUndefined(); }); + it('should treat max as a distinct top tier on Opus 4.8', () => { + // Opus 4.8 inherits 4.7's adaptive thinking surface; max must remain + // distinct from xhigh. + const result = validateThinking('claude', 'claude-opus-4-8', 'max'); + expect(result.valid).toBe(true); + expect(result.value).toBe('max'); + expect(result.warning).toBeUndefined(); + }); + it('should still alias max -> xhigh for models without a max level (backcompat)', () => { // Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'. // User input "max" should map down to xhigh rather than be rejected. diff --git a/src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts b/src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts index 935f9787..a12f514c 100644 --- a/src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts +++ b/src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts @@ -280,6 +280,40 @@ describe('account registry integrity', () => { }); }); + it('strips alternate token file prefixes when inferring missing emails', async () => { + const cases = [ + { provider: 'gemini', tokenFile: 'google-user@example.com.json', type: 'gemini' }, + { provider: 'codex', tokenFile: 'openai-user@example.com.json', type: 'codex' }, + { provider: 'agy', tokenFile: 'antigravity-user@example.com.json', type: 'antigravity' }, + { + provider: 'ghcp', + tokenFile: 'github-copilot-user@example.com.json', + type: 'github-copilot', + }, + ] as const; + + for (const testCase of cases) { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, testCase.tokenFile), + JSON.stringify({ type: testCase.type }), + 'utf8' + ); + + const { discoverExistingAccounts, loadAccountsRegistry } = await loadRegistryModule(); + discoverExistingAccounts(); + const registry = loadAccountsRegistry(); + const providerAccounts = registry.providers[testCase.provider]; + + expect(Object.keys(providerAccounts?.accounts ?? {})).toEqual(['user@example.com']); + expect(providerAccounts?.accounts['user@example.com']?.email).toBe('user@example.com'); + expect(providerAccounts?.accounts['user@example.com']?.tokenFile).toBe(testCase.tokenFile); + }); + } + }); + it('preserves the corrupted registry backup when recovery cannot rewrite accounts.json', async () => { await withIsolatedHome(async (homeDir) => { const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); 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..b666c1e9 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as lockfile from 'proper-lockfile'; import { CLIProxyProvider } from '../types'; +import { PROVIDER_CAPABILITIES } from '../provider-capabilities'; import { PROVIDER_TYPE_VALUES } from '../auth/auth-types'; import { getAuthDir, getCliproxyDir } from '../config/config-generator'; import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types'; @@ -61,15 +62,21 @@ function resolveProviderFromTokenType(typeValue: string): CLIProxyProvider | und const EMAIL_FILE_NAME_PATTERN = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i; +function stripTokenFileProviderPrefix(baseName: string, provider: CLIProxyProvider): string { + const knownPrefixes = [...PROVIDER_CAPABILITIES[provider].authFilePrefixes, `${provider}-`].sort( + (a, b) => b.length - a.length + ); + const prefix = knownPrefixes.find((knownPrefix) => baseName.startsWith(knownPrefix)); + + return prefix ? baseName.slice(prefix.length) : baseName; +} + function inferEmailFromTokenFileName( tokenFile: string, provider: CLIProxyProvider ): string | undefined { const baseName = tokenFile.replace(/\.json$/i, ''); - const providerPrefix = `${provider}-`; - const candidate = baseName.startsWith(providerPrefix) - ? baseName.slice(providerPrefix.length) - : baseName; + const candidate = stripTokenFileProviderPrefix(baseName, provider); if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { const scopedCandidate = candidate.slice(candidate.indexOf('-') + 1); @@ -660,6 +667,10 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo const accountMeta = providerAccounts.accounts[accountId]; if (accountMeta.paused) { + // Treat an explicit pause request for an already paused account as a fresh + // manual decision. This changes the pause metadata so quota cooldown + // restore cannot later mistake the pause for its original auto-pause. + accountMeta.pausedAt = new Date().toISOString(); return true; } diff --git a/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts b/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts index 28201039..45548c1d 100644 --- a/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts +++ b/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts @@ -78,6 +78,21 @@ describe('codex plan compatibility', () => { ).toBe('gpt-5.4-mini'); }); + it('prefers a rejected model explicit free-plan fallback over saved paid-only models', () => { + expect( + resolveRuntimeCodexFallbackModel({ + requestedModel: 'gpt-5.3-codex-spark', + modelMap: { + defaultModel: 'gpt-5.3-codex', + opusModel: 'gpt-5.3-codex', + sonnetModel: 'gpt-5.3-codex', + haikuModel: 'gpt-5.3-codex-spark', + }, + excludeModels: ['gpt-5.3-codex-spark'], + }) + ).toBe('gpt-5.4-mini'); + }); + it('tracks Codex thinking caps for current safe defaults, paid models, and legacy aliases', () => { expect(getModelMaxLevel('codex', 'gpt-5.5')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.4')).toBe('xhigh'); 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-plan-compatibility.ts b/src/cliproxy/ai-providers/codex-plan-compatibility.ts index 291b396e..dad29a02 100644 --- a/src/cliproxy/ai-providers/codex-plan-compatibility.ts +++ b/src/cliproxy/ai-providers/codex-plan-compatibility.ts @@ -114,8 +114,8 @@ export function resolveRuntimeCodexFallbackModel(options: { (options.excludeModels ?? []).map((model) => normalizeCodexModelId(model)).filter(Boolean) ); const candidates = [ - options.modelMap.defaultModel, getFreePlanFallbackCodexModel(requestedModel), + options.modelMap.defaultModel, options.modelMap.opusModel, options.modelMap.sonnetModel, options.modelMap.haikuModel, 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/executor/__tests__/executor-option-value.test.ts b/src/cliproxy/executor/__tests__/executor-option-value.test.ts index 9315f3f7..45e1eba0 100644 --- a/src/cliproxy/executor/__tests__/executor-option-value.test.ts +++ b/src/cliproxy/executor/__tests__/executor-option-value.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as http from 'http'; import { execClaudeWithCLIProxy, hasGitLabTokenLoginFlag, readOptionValue } from '../index'; describe('readOptionValue', () => { @@ -57,18 +58,84 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); fs.chmodSync(fakeClaudePath, 0o755); originalCcsHome = process.env.CCS_HOME; + process.exitCode = 0; process.env.CCS_HOME = tmpHome; }); + async function waitForFile(filePath: string): Promise { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + if (fs.existsSync(filePath)) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return fs.existsSync(filePath); + } + afterEach(() => { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { delete process.env.CCS_HOME; } + process.exitCode = 0; fs.rmSync(tmpHome, { recursive: true, force: true }); }); + it('validates conflicting browser launch flags before remote proxy checks', async () => { + let requestCount = 0; + const server = http.createServer((_req, res) => { + requestCount += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Test server did not bind to a TCP port'); + } + + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await execClaudeWithCLIProxy( + fakeClaudePath, + 'gemini', + [ + '--proxy-host', + '127.0.0.1', + '--proxy-port', + String(address.port), + '--proxy-auth-token', + 'SECRET_TOKEN_FOR_VALIDATION', + '--remote-only', + '--browser', + '--no-browser', + ], + {} + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + '[X] Use either `--browser` or `--no-browser`, not both.' + ); + expect(requestCount).toBe(0); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it('exits cleanly when conflicting browser launch flags are provided', async () => { const exitSpy = jest .spyOn(process, 'exit') @@ -87,4 +154,69 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { errorSpy.mockRestore(); } }); + + it('does not treat a stale global exitCode as a current parse failure', async () => { + const markerPath = path.join(tmpHome, 'fake-claude-launched'); + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh\nprintf launched > ${JSON.stringify(markerPath)}\nexit 0\n`, + { mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + let requestCount = 0; + const server = http.createServer((_req, res) => { + requestCount += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Test server did not bind to a TCP port'); + } + + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + process.exitCode = 1; + + await execClaudeWithCLIProxy( + fakeClaudePath, + 'gemini', + [ + '--proxy-host', + '127.0.0.1', + '--proxy-port', + String(address.port), + '--proxy-auth-token', + 'SECRET_TOKEN_FOR_VALIDATION', + '--remote-only', + '--print', + 'hello', + ], + {} + ); + + expect(await waitForFile(markerPath)).toBe(true); + expect(requestCount).toBeGreaterThan(0); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); }); diff --git a/src/cliproxy/executor/__tests__/proxy-resolver.test.ts b/src/cliproxy/executor/__tests__/proxy-resolver.test.ts index 86af7137..d67f4797 100644 --- a/src/cliproxy/executor/__tests__/proxy-resolver.test.ts +++ b/src/cliproxy/executor/__tests__/proxy-resolver.test.ts @@ -5,7 +5,7 @@ * logic extracted from executor/index.ts. */ -import { beforeEach, describe, expect, it, jest } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; import type { ResolveExecutorProxyContext } from '../proxy-resolver'; import type { ExecutorConfig } from '../../types'; import type { UnifiedConfig } from '../../../config/schemas/unified-config'; @@ -15,11 +15,29 @@ import type { UnifiedConfig } from '../../../config/schemas/unified-config'; const mockEnsureCLIProxyBinary = jest.fn().mockResolvedValue('/usr/local/bin/cliproxy'); const mockGetConfiguredBackend = jest.fn().mockReturnValue('original'); const mockGetPlusBackendUnavailableMessage = jest.fn().mockReturnValue('Plus backend unavailable'); +const mockInstallCliproxyVersion = jest.fn().mockResolvedValue(undefined); +const mockFetchLatestCliproxyVersion = jest.fn().mockResolvedValue('test-version'); +const mockCheckCliproxyUpdate = jest.fn().mockResolvedValue({ available: false }); jest.mock('../../binary-manager', () => ({ ensureCLIProxyBinary: mockEnsureCLIProxyBinary, getConfiguredBackend: mockGetConfiguredBackend, getPlusBackendUnavailableMessage: mockGetPlusBackendUnavailableMessage, + getStoredConfiguredBackend: mockGetConfiguredBackend, + getCLIProxyPath: jest.fn().mockReturnValue('/usr/local/bin/cliproxy'), + getInstalledCliproxyVersion: jest.fn().mockReturnValue('test-version'), + isCLIProxyInstalled: jest.fn().mockReturnValue(true), + resolveLocalBackend: mockGetConfiguredBackend, + syncPlusFallbackStateIfNeeded: jest.fn(), + installCliproxyVersion: mockInstallCliproxyVersion, + fetchLatestCliproxyVersion: mockFetchLatestCliproxyVersion, + checkCliproxyUpdate: mockCheckCliproxyUpdate, + getPinnedVersion: jest.fn().mockReturnValue(null), + savePinnedVersion: jest.fn(), + clearPinnedVersion: jest.fn(), + isVersionPinned: jest.fn().mockReturnValue(false), + getVersionPinPath: jest.fn().mockReturnValue('/tmp/cliproxy-version-pin'), + BinaryManager: class {}, })); const mockCheckRemoteProxy = jest.fn(); @@ -30,21 +48,28 @@ jest.mock('../../services/remote-proxy-client', () => ({ jest.mock('../retry-handler', () => ({ isNetworkError: jest.fn().mockReturnValue(false), handleNetworkError: jest.fn(), -})); - -const mockResolveProxyConfig = jest.fn(); -jest.mock('../../proxy/proxy-config-resolver', () => ({ - resolveProxyConfig: mockResolveProxyConfig, -})); - -jest.mock('../../config/config-generator', () => ({ - CLIPROXY_DEFAULT_PORT: 8317, - validatePort: jest.fn((port: number | undefined) => port ?? 8317), + handleTokenExpiration: jest.fn(), + handleQuotaCheck: jest.fn(), + PROVIDER_ERROR_PATTERNS: [], + detectFailedTier: jest.fn().mockReturnValue(null), + isProviderError: jest.fn().mockReturnValue(false), })); // ── Import after mocks ──────────────────────────────────────────────────────── -const { resolveExecutorProxy } = await import('../proxy-resolver'); +const { resolveExecutorProxy, resolveExecutorProxyConfig } = await import('../proxy-resolver'); + +const PROXY_ENV_KEYS = [ + 'CCS_PROXY_HOST', + 'CCS_PROXY_PORT', + 'CCS_PROXY_PROTOCOL', + 'CCS_PROXY_AUTH_TOKEN', + 'CCS_PROXY_TIMEOUT', + 'CCS_PROXY_FALLBACK_ENABLED', + 'CCS_ALLOW_SELF_SIGNED', +] as const; + +let proxyEnvSnapshot: Record = {}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -76,51 +101,37 @@ function makeContext( }; } -/** Mock resolveProxyConfig to return a local-mode config */ -function mockLocalProxyConfig(remainingArgs: string[] = []): void { - mockResolveProxyConfig.mockReturnValue({ - config: { - mode: 'local', - port: 8317, - protocol: 'http', - fallbackEnabled: false, - autoStartLocal: false, - remoteOnly: false, - forceLocal: true, - }, - remainingArgs, - }); -} - -/** Mock resolveProxyConfig to return a remote-mode config */ -function mockRemoteProxyConfig(remainingArgs: string[] = []): void { - mockResolveProxyConfig.mockReturnValue({ - config: { - mode: 'remote', - host: '192.168.1.100', - port: 8317, - protocol: 'http', - fallbackEnabled: false, - autoStartLocal: false, - remoteOnly: false, - forceLocal: false, - }, - remainingArgs, - }); +async function resolveProxyForTest(args: string[], context = makeContext()) { + const resolvedConfig = resolveExecutorProxyConfig(args, context); + return resolveExecutorProxy(resolvedConfig, context); } // ── Tests ───────────────────────────────────────────────────────────────────── beforeEach(() => { jest.clearAllMocks(); + proxyEnvSnapshot = Object.fromEntries(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of PROXY_ENV_KEYS) { + delete process.env[key]; + } mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy'); mockGetConfiguredBackend.mockReturnValue('original'); }); +afterEach(() => { + for (const key of PROXY_ENV_KEYS) { + const value = proxyEnvSnapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + describe('resolveExecutorProxy — local mode', () => { it('returns useRemoteProxy=false and correct binary for local mode', async () => { - mockLocalProxyConfig(['--verbose']); - const result = await resolveExecutorProxy(['--verbose'], makeContext()); + const result = await resolveProxyForTest(['--local-proxy', '--verbose']); expect(result.useRemoteProxy).toBe(false); expect(result.localBackend).toBe('original'); @@ -129,16 +140,14 @@ describe('resolveExecutorProxy — local mode', () => { }); it('strips proxy flags and passes remainingArgs through', async () => { - mockLocalProxyConfig(['clean-arg']); - const result = await resolveExecutorProxy(['--local-proxy', 'clean-arg'], makeContext()); + const result = await resolveProxyForTest(['--local-proxy', 'clean-arg']); expect(result.argsWithoutProxy).toEqual(['clean-arg']); expect(result.useRemoteProxy).toBe(false); }); it('does not call checkRemoteProxy in local mode', async () => { - mockLocalProxyConfig(); - await resolveExecutorProxy([], makeContext()); + await resolveProxyForTest(['--local-proxy']); expect(mockCheckRemoteProxy).not.toHaveBeenCalled(); }); @@ -146,19 +155,17 @@ describe('resolveExecutorProxy — local mode', () => { describe('resolveExecutorProxy — remote mode reachable', () => { it('returns useRemoteProxy=true when remote proxy is reachable', async () => { - mockRemoteProxyConfig(); mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 12, error: undefined }); - const result = await resolveExecutorProxy([], makeContext()); + const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']); expect(result.useRemoteProxy).toBe(true); }); it('skips binary acquisition when remote proxy is reachable', async () => { - mockRemoteProxyConfig(); mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 5, error: undefined }); - const result = await resolveExecutorProxy([], makeContext()); + const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']); expect(result.binaryPath).toBeUndefined(); expect(mockEnsureCLIProxyBinary).not.toHaveBeenCalled(); @@ -167,65 +174,27 @@ describe('resolveExecutorProxy — remote mode reachable', () => { describe('resolveExecutorProxy — remote mode unreachable', () => { it('throws expected message when remoteOnly=true and remote is unreachable', async () => { - mockResolveProxyConfig.mockReturnValue({ - config: { - mode: 'remote', - host: '192.168.1.100', - port: 8317, - protocol: 'http', - fallbackEnabled: false, - autoStartLocal: false, - remoteOnly: true, - forceLocal: false, - }, - remainingArgs: [], - }); mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Connection refused' }); - await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow( - 'Remote proxy unreachable and --remote-only specified' - ); + await expect( + resolveProxyForTest(['--proxy-host', '192.168.1.100', '--remote-only']) + ).rejects.toThrow('Remote proxy unreachable and --remote-only specified'); }); it('throws when fallback disabled and remote is unreachable', async () => { - mockResolveProxyConfig.mockReturnValue({ - config: { - mode: 'remote', - host: '192.168.1.100', - port: 8317, - protocol: 'http', - fallbackEnabled: false, - autoStartLocal: false, - remoteOnly: false, - forceLocal: false, - }, - remainingArgs: [], - }); + process.env.CCS_PROXY_FALLBACK_ENABLED = '0'; mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' }); - await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow( + await expect(resolveProxyForTest(['--proxy-host', '192.168.1.100'])).rejects.toThrow( 'Remote proxy unreachable and fallback disabled' ); }); it('falls back to local and acquires binary when autoStartLocal=true', async () => { - mockResolveProxyConfig.mockReturnValue({ - config: { - mode: 'remote', - host: '192.168.1.100', - port: 8317, - protocol: 'http', - fallbackEnabled: true, - autoStartLocal: true, - remoteOnly: false, - forceLocal: false, - }, - remainingArgs: [], - }); mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' }); mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy'); - const result = await resolveExecutorProxy([], makeContext()); + const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']); expect(result.useRemoteProxy).toBe(false); expect(result.binaryPath).toBe('/usr/local/bin/cliproxy'); @@ -235,9 +204,7 @@ describe('resolveExecutorProxy — remote mode unreachable', () => { describe('resolveExecutorProxy — proxyConfig propagated in result', () => { it('returns the resolved proxyConfig object', async () => { - mockLocalProxyConfig(); - - const result = await resolveExecutorProxy([], makeContext()); + const result = await resolveProxyForTest(['--local-proxy']); expect(result.proxyConfig).toBeDefined(); expect(result.proxyConfig.mode).toBe('local'); @@ -245,10 +212,9 @@ describe('resolveExecutorProxy — proxyConfig propagated in result', () => { }); it('returns mutated cfg with validated port', async () => { - mockLocalProxyConfig(); const ctx = makeContext(); - const result = await resolveExecutorProxy([], ctx); + const result = await resolveProxyForTest(['--local-proxy'], ctx); // cfg is mutated in place and also returned expect(result.cfg).toBe(ctx.cfg); diff --git a/src/cliproxy/executor/arg-parser.ts b/src/cliproxy/executor/arg-parser.ts index ea581f98..d1a74bc2 100644 --- a/src/cliproxy/executor/arg-parser.ts +++ b/src/cliproxy/executor/arg-parser.ts @@ -8,7 +8,9 @@ * - parseExecutorFlags() — flag extraction block (lines ~411-639 in original) * - validateFlagCombinations() — cross-flag guard block (lines ~531-585) * - * IMPORTANT: process.exit semantics are kept identical to original index.ts. + * IMPORTANT: process.exit semantics are kept identical to original index.ts, + * with explicit parseFailed/validation return state for callers that must not + * depend on ambient process.exitCode. * All console.error messages are byte-identical. */ @@ -153,6 +155,7 @@ export function filterCcsFlags(args: string[]): string[] { /** Result of parsing CCS executor flags from args. */ export interface ParsedExecutorFlags { + parseFailed?: boolean; forceAuth: boolean; pasteCallback: boolean; portForward: boolean; @@ -181,7 +184,7 @@ export interface ParsedExecutorFlags { /** * Parse all CCS executor flags from args. * - * Exits with code 1 (process.exitCode = 1 + return) on invalid flag values. + * Exits with code 1 (process.exitCode = 1 + parseFailed return) on invalid flag values. * Exits with process.exit(1) on conflicting flag combinations — identical to * the original index.ts behavior. * @@ -247,7 +250,7 @@ export function parseExecutorFlags( console.error(fail('--kiro-auth-method requires a value')); console.error(' Supported values: aws, aws-authcode, google, github, idc'); process.exitCode = 1; - // Caller must check process.exitCode = 1 and bail — matching original return behavior + // Caller must check parseFailed and bail — matching original return behavior return buildPartialFlags({ forceAuth, pasteCallback, @@ -272,6 +275,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } const normalized = rawMethod.trim().toLowerCase(); @@ -303,6 +307,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } kiroAuthMethod = normalizeKiroAuthMethod(normalized); @@ -339,6 +344,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } @@ -373,6 +379,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } @@ -408,6 +415,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } const normalized = rawFlow.trim().toLowerCase(); @@ -439,6 +447,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } kiroIDCFlow = normalizeKiroIDCFlow(normalized); @@ -475,6 +484,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } @@ -538,6 +548,7 @@ export function parseExecutorFlags( gitlabBaseUrl, extendedContextOverride, thinkingParse, + parseFailed: false, }; } @@ -550,8 +561,8 @@ function buildPartialFlags(fields: ParsedExecutorFlags): ParsedExecutorFlags { /** * Validate flag combinations that are mutually exclusive or provider-scoped. - * Calls process.exit(1) on any violation — identical to original index.ts. - * Call AFTER parseExecutorFlags() and only if process.exitCode is still 0. + * Sets process.exitCode=1 and returns false on any violation. + * Call AFTER parseExecutorFlags() and only if parseFailed is false. * * @param parsed Result of parseExecutorFlags() * @param context Provider context (provider string + compositeProviders list) @@ -561,7 +572,7 @@ export function validateFlagCombinations( parsed: ParsedExecutorFlags, context: { provider: string; compositeProviders: string[] }, args: string[] -): void { +): boolean { const { provider, compositeProviders } = context; const { kiroAuthMethod, @@ -575,7 +586,7 @@ export function validateFlagCombinations( if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { console.error(fail('--kiro-auth-method is only valid for ccs kiro')); process.exitCode = 1; - return; + return false; } if ( @@ -589,7 +600,7 @@ export function validateFlagCombinations( ) ); process.exitCode = 1; - return; + return false; } if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) { @@ -598,7 +609,7 @@ export function validateFlagCombinations( ' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start' ); process.exitCode = 1; - return; + return false; } if ( @@ -612,13 +623,15 @@ export function validateFlagCombinations( ) ); process.exitCode = 1; - return; + return false; } if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') { const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url'; console.error(fail(`${flagName} is only valid for ccs gitlab`)); process.exitCode = 1; - return; + return false; } + + return true; } diff --git a/src/cliproxy/executor/browser-launch-setup.ts b/src/cliproxy/executor/browser-launch-setup.ts index 94405a33..c33f3bdc 100644 --- a/src/cliproxy/executor/browser-launch-setup.ts +++ b/src/cliproxy/executor/browser-launch-setup.ts @@ -9,7 +9,7 @@ * 4. Browser MCP ensure + sync-to-config-dir */ -import { warn } from '../../utils/ui'; +import { fail, warn } from '../../utils/ui'; import { type BrowserLaunchOverride, ensureBrowserMcpOrThrow, @@ -41,6 +41,7 @@ export interface BrowserLaunchSetupResult { export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { browserLaunchOverride: BrowserLaunchOverride | undefined; argsWithoutBrowserFlags: string[]; + parseFailed: boolean; } { let browserLaunchOverride: BrowserLaunchOverride | undefined; let argsWithoutBrowserFlags = argsWithoutProxy; @@ -49,9 +50,10 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { browserLaunchOverride = browserLaunchFlags.override; argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags; } catch (error) { - console.error(warn((error as Error).message)); + console.error(fail((error as Error).message)); + process.exitCode = 1; process.exit(1); - return { browserLaunchOverride: undefined, argsWithoutBrowserFlags }; + return { browserLaunchOverride: undefined, argsWithoutBrowserFlags, parseFailed: true }; } const browserConfig = getBrowserConfig(); @@ -71,7 +73,7 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { console.error(warn(blockedBrowserOverrideWarning)); } - return { browserLaunchOverride, argsWithoutBrowserFlags }; + return { browserLaunchOverride, argsWithoutBrowserFlags, parseFailed: false }; } /** diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index d88ff83e..0a5ee7fa 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -63,7 +63,7 @@ import { } from './thinking-override-resolver'; import { shouldStartHttpsTunnel } from './https-tunnel-policy'; import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser'; -import { resolveExecutorProxy } from './proxy-resolver'; +import { resolveExecutorProxy, resolveExecutorProxyConfig } from './proxy-resolver'; import { buildProxyChain } from './proxy-chain-builder'; import { warnBrokenModels } from './model-warnings'; import { launchClaude } from './claude-launcher'; @@ -129,8 +129,23 @@ export async function execClaudeWithCLIProxy( // Collect all providers to validate (default + composite tiers) const allProviders = [provider, ...compositeProviders]; + const proxyResolution = resolveExecutorProxyConfig(args, { + unifiedConfig, + allProviders, + verbose, + cfg, + log, + }); + + const { + browserLaunchOverride, + argsWithoutBrowserFlags, + parseFailed: browserLaunchParseFailed, + } = resolveBrowserLaunchFlags(proxyResolution.argsWithoutProxy); + if (browserLaunchParseFailed) return; + const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } = - await resolveExecutorProxy(args, { + await resolveExecutorProxy(proxyResolution, { unifiedConfig, allProviders, verbose, @@ -138,9 +153,6 @@ export async function execClaudeWithCLIProxy( log, }); - const { browserLaunchOverride, argsWithoutBrowserFlags } = - resolveBrowserLaunchFlags(argsWithoutProxy); - // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); @@ -158,11 +170,15 @@ export async function execClaudeWithCLIProxy( compositeProviders, unifiedConfig, }); - if (process.exitCode === 1) return; + if (parsedFlags.parseFailed) return; - // Validate cross-flag combinations (exits with code 1 on violation) - validateFlagCombinations(parsedFlags, { provider, compositeProviders }, argsWithoutProxy); - if (process.exitCode === 1) return; + // Validate cross-flag combinations (reports failure without relying on ambient exitCode) + const flagCombinationsValid = validateFlagCombinations( + parsedFlags, + { provider, compositeProviders }, + argsWithoutProxy + ); + if (!flagCombinationsValid) return; const { forceConfig, diff --git a/src/cliproxy/executor/proxy-resolver.ts b/src/cliproxy/executor/proxy-resolver.ts index eae39abb..bade6f32 100644 --- a/src/cliproxy/executor/proxy-resolver.ts +++ b/src/cliproxy/executor/proxy-resolver.ts @@ -24,20 +24,23 @@ import type { ResolvedProxyConfig } from '../types'; import type { UnifiedConfig } from '../../config/schemas/unified-config'; import { isNetworkError, handleNetworkError } from './retry-handler'; -/** Result returned from resolveExecutorProxy */ -export interface ResolvedProxy { +export interface ResolvedExecutorProxyConfig { /** Resolved proxy config after merging CLI > ENV > config.yaml > defaults */ proxyConfig: ResolvedProxyConfig; + /** Args after proxy-related flags are stripped out */ + argsWithoutProxy: string[]; + /** Mutated executor config (port resolved and validated) */ + cfg: ExecutorConfig; +} + +/** Result returned from resolveExecutorProxy */ +export interface ResolvedProxy extends ResolvedExecutorProxyConfig { /** Whether to use the remote proxy (vs spawning a local one) */ useRemoteProxy: boolean; /** Which local backend binary to use ('original' | 'plus') */ localBackend: CLIProxyBackend; /** Absolute path to CLIProxy binary; undefined when useRemoteProxy=true */ binaryPath: string | undefined; - /** Args after proxy-related flags are stripped out */ - argsWithoutProxy: string[]; - /** Mutated executor config (port resolved and validated) */ - cfg: ExecutorConfig; } /** Dependencies injected by the orchestrator */ @@ -50,16 +53,15 @@ export interface ResolveExecutorProxyContext { } /** - * Resolves proxy configuration, checks remote reachability, selects the local - * backend, and ensures the CLIProxy binary is present when running locally. + * Resolves side-effect-free proxy configuration and strips proxy flags. * * Mutates `context.cfg.port` in-place (same as original orchestrator behaviour). */ -export async function resolveExecutorProxy( +export function resolveExecutorProxyConfig( args: string[], context: ResolveExecutorProxyContext -): Promise { - const { unifiedConfig, allProviders, verbose: _verbose, cfg, log } = context; +): ResolvedExecutorProxyConfig { + const { unifiedConfig, cfg, log } = context; // Resolve proxy config from CLI flags > ENV > config.yaml > defaults const cliproxyServerConfig = unifiedConfig.cliproxy_server; @@ -98,6 +100,20 @@ export async function resolveExecutorProxy( log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); } + return { proxyConfig, argsWithoutProxy, cfg }; +} + +/** + * Resolves proxy configuration, checks remote reachability, selects the local + * backend, and ensures the CLIProxy binary is present when running locally. + */ +export async function resolveExecutorProxy( + resolvedConfig: ResolvedExecutorProxyConfig, + context: ResolveExecutorProxyContext +): Promise { + const { allProviders, verbose: _verbose } = context; + const { proxyConfig, argsWithoutProxy, cfg } = resolvedConfig; + // Check remote proxy reachability let useRemoteProxy = false; let localBackend: CLIProxyBackend = 'original'; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 29f6052e..2192107a 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)', @@ -307,10 +369,25 @@ export const MODEL_CATALOG: Partial> = displayName: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-6', models: [ + { + id: 'claude-opus-4-8', + name: 'Claude Opus 4.8', + description: 'Latest flagship model', + nativeImageInput: true, + // Mirrors 4.7: Anthropic accepts only adaptive thinking levels on the + // current Opus generation; manual budget_tokens is rejected with 400. + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', + dynamicAllowed: true, + }, + extendedContext: true, + }, { id: 'claude-opus-4-7', name: 'Claude Opus 4.7', - description: 'Latest flagship model', + description: 'Previous flagship model', nativeImageInput: true, // Opus 4.7 only supports adaptive thinking on the Anthropic API; manual // thinking.type: "enabled" with budget_tokens is rejected with 400. @@ -327,7 +404,7 @@ export const MODEL_CATALOG: Partial> = { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', - description: 'Previous flagship model', + description: 'Older flagship model', nativeImageInput: true, thinking: { type: 'budget', 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/quota/__tests__/quota-fetcher-codex.test.ts b/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts index 7a913713..d51e3af9 100644 --- a/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts +++ b/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts @@ -382,6 +382,44 @@ describe('Codex Quota Fetcher', () => { expect(windows[0].featureLabel).toBe('Custom-Feature'); expect(windows[0].usedPercent).toBe(50); }); + + it('should remove terminal control characters from additional limit labels', () => { + const response = { + additional_rate_limits: [ + { + limit_name: '\u001b[2JGPT-5.3-Codex-Spark\u001b]52;c;payload\u0007', + rate_limit: { + primary_window: { used_percent: 25, reset_after_seconds: 3600 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].featureLabel).toBe('GPT-5.3-Codex-Spark'); + expect(windows[0].label).toBe('GPT-5.3-Codex-Spark (Primary)'); + }); + + it('should bound additional limit labels before storing them', () => { + const response = { + additional_rate_limits: [ + { + limit_name: `Feature-${'x'.repeat(120)}`, + rate_limit: { + primary_window: { used_percent: 25, reset_after_seconds: 3600 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].featureLabel).toHaveLength(80); + expect(windows[0].label).toBe(`${windows[0].featureLabel} (Primary)`); + }); }); describe('buildCodexCoreUsageSummary', () => { diff --git a/src/cliproxy/quota/quota-fetcher-codex.ts b/src/cliproxy/quota/quota-fetcher-codex.ts index 78ab225b..c9d66a94 100644 --- a/src/cliproxy/quota/quota-fetcher-codex.ts +++ b/src/cliproxy/quota/quota-fetcher-codex.ts @@ -11,6 +11,7 @@ import { getAuthDir } from '../config/config-generator'; import { getAccount, getProviderAccounts, getPausedDir } from '../accounts/account-manager'; import { sanitizeEmail, isTokenExpired } from '../auth/auth-utils'; import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types'; +import { sanitizeCodexFeatureLabel } from './quota-label-sanitizer'; import { extractCanonicalEmailFromAccountId } from '../accounts/email-account-identity'; /** ChatGPT backend API base URL */ @@ -58,8 +59,8 @@ interface CodexRateLimitWindow { * Each entry surfaces its own primary/secondary windows under a feature-specific limit name. */ interface CodexAdditionalRateLimit { - limit_name?: string; - limitName?: string; + limit_name?: unknown; + limitName?: unknown; metered_feature?: string; meteredFeature?: string; rate_limit?: CodexRateLimitWindow; @@ -380,11 +381,7 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[] const entryRateLimit = entry.rate_limit || entry.rateLimit; if (!entryRateLimit) continue; - const rawFeatureLabel = entry.limit_name ?? entry.limitName; - const featureLabel = - typeof rawFeatureLabel === 'string' && rawFeatureLabel.trim().length > 0 - ? rawFeatureLabel.trim() - : 'Additional'; + const featureLabel = sanitizeCodexFeatureLabel(entry.limit_name ?? entry.limitName); addWindow( `${featureLabel} (Primary)`, entryRateLimit.primary_window || entryRateLimit.primaryWindow, diff --git a/src/cliproxy/quota/quota-label-sanitizer.ts b/src/cliproxy/quota/quota-label-sanitizer.ts new file mode 100644 index 00000000..b7279eb1 --- /dev/null +++ b/src/cliproxy/quota/quota-label-sanitizer.ts @@ -0,0 +1,29 @@ +const CODEX_FEATURE_LABEL_FALLBACK = 'Additional'; +const CODEX_FEATURE_LABEL_MAX_LENGTH = 80; +const TERMINAL_ESCAPE_SEQUENCE_REGEX = + /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b\[[0-?]*[ -/]*[@-~]/g; +const TERMINAL_CONTROL_CHARS_REGEX = /[\u0000-\u001f\u007f-\u009f]/g; + +/** + * Sanitize upstream Codex feature labels before storing or rendering them. + * + * The quota API is not schema-validated at runtime, so additional-rate-limit + * labels must be constrained to safe, printable strings before they reach the + * terminal. + */ +export function sanitizeCodexFeatureLabelOrNull(value: unknown): string | null { + if (typeof value !== 'string') return null; + + const sanitized = value + .replace(TERMINAL_ESCAPE_SEQUENCE_REGEX, '') + .replace(TERMINAL_CONTROL_CHARS_REGEX, '') + .trim() + .slice(0, CODEX_FEATURE_LABEL_MAX_LENGTH) + .trimEnd(); + + return sanitized.length > 0 ? sanitized : null; +} + +export function sanitizeCodexFeatureLabel(value: unknown): string { + return sanitizeCodexFeatureLabelOrNull(value) ?? CODEX_FEATURE_LABEL_FALLBACK; +} 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..75fe40d6 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) */ @@ -249,7 +250,6 @@ export function mergeCatalog( if (!staticCatalog && filteredRemoteModels.length === 0) return undefined; const displayName = staticCatalog?.displayName || provider; - const defaultModel = staticCatalog?.defaultModel || (filteredRemoteModels[0]?.id ?? ''); // Build map of static models by lowercase ID for fast lookup const staticMap = new Map(); @@ -260,14 +260,11 @@ export function mergeCatalog( } // Process remote models: merge with static entries - const mergedIds = new Set(); const mergedModels: ModelEntry[] = []; for (const remote of filteredRemoteModels) { const remoteEntry = mapRemoteToModelEntry(remote); const staticEntry = staticMap.get(remote.id.toLowerCase()); - mergedIds.add(remote.id.toLowerCase()); - if (staticEntry) { const mergedThinking = remoteEntry.thinking ? { @@ -292,6 +289,12 @@ export function mergeCatalog( } } + const staticDefaultModel = staticCatalog?.defaultModel; + const hasStaticDefaultModel = + typeof staticDefaultModel === 'string' && + mergedModels.some((model) => model.id.toLowerCase() === staticDefaultModel.toLowerCase()); + const defaultModel = hasStaticDefaultModel ? staticDefaultModel : (mergedModels[0]?.id ?? ''); + return { provider, displayName, 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/api-command/help.ts b/src/commands/api-command/help.ts index 2da106d2..1c13d2e5 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -62,7 +62,7 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P ` ${color('--1m / --no-1m', 'command')} Write or clear [1m] on compatible Claude mappings` ); writeLine( - ` ${color('--target ', 'command')} Default target: claude or droid (create)` + ` ${color('--target ', 'command')} Default target: claude, droid, or codex (create)` ); writeLine(` ${color('--register', 'command')} Register discovered orphan settings`); writeLine(` ${color('--json', 'command')} JSON output for discover command`); diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index 47bc4ac2..017ef4c4 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -36,55 +36,75 @@ function formatBytes(bytes: number): string { return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`; } -/** Calculate total size of regular top-level files in a directory */ -function getDirSize(dirPath: string): number { - if (!fs.existsSync(dirPath)) return 0; +interface DirectorySummary { + fileCount: number; + size: number; +} - let totalSize = 0; - const entries = fs.readdirSync(dirPath); +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function pathExistsForCleanup(dirPath: string): boolean { + try { + fs.lstatSync(dirPath); + return true; + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +/** Return entries for a real directory, rejecting symlinked directory targets. */ +function readRealDirectory(dirPath: string): string[] { + let stats: fs.Stats; + + try { + stats = fs.lstatSync(dirPath); + } catch (error) { + if (isMissingPathError(error)) return []; + throw error; + } + + if (!stats.isDirectory() || stats.isSymbolicLink()) return []; + return fs.readdirSync(dirPath); +} + +/** Summarize regular top-level files in a real directory */ +function summarizeDirectory(dirPath: string): DirectorySummary { + const summary = { fileCount: 0, size: 0 }; + const entries = readRealDirectory(dirPath); for (const entry of entries) { const filePath = path.join(dirPath, entry); try { const stats = fs.lstatSync(filePath); if (stats.isFile() && !stats.isSymbolicLink()) { - totalSize += stats.size; + summary.fileCount++; + summary.size += stats.size; } } catch { // File may have been deleted between readdir and stat - skip } } - return totalSize; + return summary; } -/** Count files in a directory */ -function countFiles(dirPath: string): number { - if (!fs.existsSync(dirPath)) return 0; - let count = 0; - const entries = fs.readdirSync(dirPath); - - for (const entry of entries) { - const filePath = path.join(dirPath, entry); - try { - const stats = fs.lstatSync(filePath); - if (stats.isFile() && !stats.isSymbolicLink()) { - count++; - } - } catch { - // File may have been deleted - skip - } - } - return count; -} - -/** Delete all regular files in a directory (skips symlinks for safety) */ +/** Delete all regular files in a real directory (skips symlinks for safety) */ function cleanDirectory(dirPath: string): { deleted: number; freedBytes: number } { - if (!fs.existsSync(dirPath)) return { deleted: 0, freedBytes: 0 }; - let deleted = 0; let freedBytes = 0; - const files = fs.readdirSync(dirPath); + const files = readRealDirectory(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); @@ -116,11 +136,9 @@ interface ErrorLogInfo { /** Get error log files with metadata */ function getErrorLogFiles(logsDir: string): ErrorLogInfo[] { - if (!fs.existsSync(logsDir)) return []; - const now = Date.now(); const files: ErrorLogInfo[] = []; - const entries = fs.readdirSync(logsDir); + const entries = readRealDirectory(logsDir); for (const entry of entries) { // Only process error-*.log files @@ -150,10 +168,9 @@ function getErrorLogFiles(logsDir: string): ErrorLogInfo[] { /** Delete error logs older than specified days */ function cleanErrorLogs( - logsDir: string, + files: ErrorLogInfo[], maxAgeDays: number ): { deleted: number; freedBytes: number; kept: number } { - const files = getErrorLogFiles(logsDir); let deleted = 0; let freedBytes = 0; let kept = 0; @@ -251,14 +268,23 @@ async function handleErrorLogCleanup( dryRun: boolean, force: boolean ): Promise { - // Check if logs directory exists - if (!fs.existsSync(logsDir)) { - console.log(info('No CLIProxy logs directory found.')); + try { + if (!pathExistsForCleanup(logsDir)) { + console.log(info('No CLIProxy logs directory found.')); + return; + } + } catch (error) { + console.log(warn(`Could not inspect CLIProxy logs: ${getErrorMessage(error)}`)); return; } - // Get error log files - const errorLogs = getErrorLogFiles(logsDir); + let errorLogs: ErrorLogInfo[]; + try { + errorLogs = getErrorLogFiles(logsDir); + } catch (error) { + console.log(warn(`Could not read CLIProxy logs: ${getErrorMessage(error)}`)); + return; + } if (errorLogs.length === 0) { console.log(info('No error logs found.')); return; @@ -323,7 +349,14 @@ async function handleErrorLogCleanup( } // Perform cleanup - const { deleted, freedBytes, kept } = cleanErrorLogs(logsDir, maxAgeDays); + let result: { deleted: number; freedBytes: number; kept: number }; + try { + result = cleanErrorLogs(errorLogs, maxAgeDays); + } catch (error) { + console.log(warn(`Could not clean CLIProxy logs: ${getErrorMessage(error)}`)); + return; + } + const { deleted, freedBytes, kept } = result; console.log(ok(`Deleted ${deleted} error logs, freed ${formatBytes(freedBytes)}`)); if (kept > 0) { console.log(info(`Kept ${kept} recent error logs (less than ${maxAgeDays} days old)`)); @@ -340,15 +373,30 @@ async function handleMainLogCleanup(options: { dryRun: boolean; force: boolean; }): Promise { - const targets = [ + const targets: Array<{ label: string; dir: string } & DirectorySummary> = []; + const unreadableTargets: Array<{ label: string; dir: string; error: unknown }> = []; + for (const target of [ { label: 'CCS Logs', dir: options.ccsLogsDir }, { label: 'CCS Log Archives', dir: options.ccsArchiveDir }, { label: 'CLIProxy Logs', dir: options.cliproxyLogsDir }, - ].map((target) => ({ - ...target, - fileCount: countFiles(target.dir), - size: getDirSize(target.dir), - })); + ]) { + try { + targets.push({ + ...target, + ...summarizeDirectory(target.dir), + }); + } catch (error) { + unreadableTargets.push({ ...target, error }); + } + } + + if (unreadableTargets.length > 0) { + for (const target of unreadableTargets) { + console.log(warn(`Could not read ${target.label}: ${getErrorMessage(target.error)}`)); + console.log(` ${target.dir}`); + } + return; + } const activeTargets = targets.filter((target) => target.fileCount > 0); if (activeTargets.length === 0) { @@ -396,9 +444,13 @@ async function handleMainLogCleanup(options: { let deleted = 0; let freedBytes = 0; for (const target of activeTargets) { - const result = cleanDirectory(target.dir); - deleted += result.deleted; - freedBytes += result.freedBytes; + try { + const result = cleanDirectory(target.dir); + deleted += result.deleted; + freedBytes += result.freedBytes; + } catch (error) { + console.log(warn(`Could not clean ${target.label}: ${getErrorMessage(error)}`)); + } } console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`)); diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index b53dd770..176e2aa4 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -18,6 +18,10 @@ import { } from '../../cliproxy/accounts/account-manager'; import { fetchAllProviderQuotas } from '../../cliproxy/quota/quota-fetcher'; import { fetchAllCodexQuotas } from '../../cliproxy/quota/quota-fetcher-codex'; +import { + sanitizeCodexFeatureLabel, + sanitizeCodexFeatureLabelOrNull, +} from '../../cliproxy/quota/quota-label-sanitizer'; import { fetchAllClaudeQuotas } from '../../cliproxy/quota/quota-fetcher-claude'; import { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota/quota-fetcher-claude-normalizer'; import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota/quota-fetcher-gemini-cli'; @@ -279,9 +283,12 @@ function inferCodeReviewCadence( * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the * remainder into a Codex-prefixed display name. Other labels pass through unchanged. */ -function prettifyCodexFeatureLabel(featureLabel: string): string { - const trimmed = featureLabel.trim(); - if (!trimmed) return 'Additional'; +function prettifyCodexFeatureLabel(featureLabel: unknown, fallbackLabel?: unknown): string { + const trimmed = + sanitizeCodexFeatureLabelOrNull(featureLabel) ?? + (fallbackLabel === undefined + ? sanitizeCodexFeatureLabel(featureLabel) + : sanitizeCodexFeatureLabel(fallbackLabel)); const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, ''); if (stripped !== trimmed && stripped.length > 0) { return `Codex ${stripped}`; @@ -302,7 +309,7 @@ function getCodexWindowDisplayLabel( } if (window.category === 'additional') { - const pretty = prettifyCodexFeatureLabel(window.featureLabel || window.label || 'Additional'); + const pretty = prettifyCodexFeatureLabel(window.featureLabel, window.label); if (window.cadence === '5h') return `${pretty} (5h)`; if (window.cadence === 'weekly') return `${pretty} (weekly)`; return pretty; @@ -850,7 +857,9 @@ const QUOTA_PROVIDER_RUNTIME: Record { } 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/config/schemas/proxy-server.ts b/src/config/schemas/proxy-server.ts index 745ebab3..93fba4cf 100644 --- a/src/config/schemas/proxy-server.ts +++ b/src/config/schemas/proxy-server.ts @@ -183,7 +183,7 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { codex: 'gpt-5.1-codex-mini', kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', - claude: 'claude-haiku-4.5-20251001', + claude: 'claude-haiku-4-5-20251001', qwen: 'vision-model', iflow: 'qwen3-vl-plus', kimi: 'vision-model', 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/profile-router.ts b/src/proxy/profile-router.ts index 9fe6b092..ead4efc2 100644 --- a/src/proxy/profile-router.ts +++ b/src/proxy/profile-router.ts @@ -11,6 +11,7 @@ export interface OpenAICompatProfileConfig { apiKey: string; provider: DroidProvider; insecure?: boolean; + forceOpenAIReasoningModel?: boolean; model?: string; opusModel?: string; sonnetModel?: string; @@ -28,12 +29,18 @@ export interface OpenAICompatProfileEnv { ANTHROPIC_SMALL_FAST_MODEL?: string; CCS_DROID_PROVIDER?: string; CCS_OPENAI_PROXY_INSECURE?: string; + CCS_OPENAI_REASONING_MODEL?: string; } export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider { return provider === 'openai' || provider === 'generic-chat-completion-api'; } +function isTruthyEnv(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + export function resolveOpenAICompatProfileConfig( profileName: string, settingsPath: string, @@ -63,9 +70,8 @@ export function resolveOpenAICompatProfileConfig( baseUrl, apiKey, provider, - insecure: - env.CCS_OPENAI_PROXY_INSECURE === '1' || - env.CCS_OPENAI_PROXY_INSECURE?.toLowerCase() === 'true', + insecure: isTruthyEnv(env.CCS_OPENAI_PROXY_INSECURE), + forceOpenAIReasoningModel: isTruthyEnv(env.CCS_OPENAI_REASONING_MODEL), model: env.ANTHROPIC_MODEL?.trim() || undefined, opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined, sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined, 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..cf79b027 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -31,26 +31,110 @@ function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record part.type === 'text') + .map((part) => part.text) + .join('\n\n'); +} + +function shapeMiniMaxChatPayload(payload: ProxyOpenAIRequest): ProxyOpenAIRequest { + const systemMessages: string[] = []; + let removedSystemMessage = false; + const messages = payload.messages.filter((message) => { + if (message.role !== 'system') { + return true; + } + removedSystemMessage = true; + const systemText = extractTextContent(message.content).trim(); + if (systemText.length > 0) { + systemMessages.push(systemText); + } + return false; + }); + + if (!removedSystemMessage) { + return payload; + } + + if (systemMessages.length === 0) { + return { ...payload, messages }; + } + + const systemPrefix = systemMessages.join('\n\n'); + const firstUserIndex = messages.findIndex((message) => message.role === 'user'); + + if (firstUserIndex >= 0) { + messages[firstUserIndex] = { + ...messages[firstUserIndex], + content: prependTextToContent(messages[firstUserIndex].content, systemPrefix), + }; + } else { + messages.unshift({ role: 'user', content: systemPrefix }); + } + + return { ...payload, messages }; } function shapeUpstreamChatPayload( payload: ProxyOpenAIRequest, profile: OpenAICompatProfileConfig ): ProxyOpenAIRequest { - if (!isDirectOpenAIReasoningChatModel(profile, payload.model)) { - return payload; + let shaped = payload; + + if (isMiniMaxOpenAICompatProfile(profile)) { + shaped = shapeMiniMaxChatPayload(shaped); } - const shaped = { ...payload }; + if (!shouldShapeOpenAIReasoningChatPayload(profile, shaped.model)) { + return shaped; + } + + shaped = { ...shaped }; if (shaped.max_tokens !== undefined) { shaped.max_completion_tokens = shaped.max_tokens; 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..c8630709 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -143,6 +143,10 @@ function normalizeCcsxpCodexModelFlagAliases(args: string[]): { for (let index = 0; index < normalizedArgs.length; index += 1) { const arg = normalizedArgs[index]; + if (arg === '--') { + break; + } + if (arg === '-m' || arg === '--model') { const nextValue = normalizedArgs[index + 1]; if (typeof nextValue === 'string') { @@ -222,7 +226,7 @@ function prepareExplicitCodexHome( } try { - fs.mkdirSync(codexHome, { recursive: true }); + fs.mkdirSync(codexHome, { mode: 0o700, recursive: true }); } catch (err) { const error = err as NodeJS.ErrnoException; if (error.code !== 'EEXIST') { @@ -289,20 +293,19 @@ export class CodexAdapter implements TargetAdapter { const runtimeConfigOverrides = creds?.runtimeConfigOverrides ?? []; if (profileType === 'default') { - const modelFlagNormalization = isCcsxpCliproxyShortcut() + const isCcsxpShortcut = isCcsxpCliproxyShortcut(); + const modelFlagNormalization = isCcsxpShortcut ? normalizeCcsxpCodexModelFlagAliases(userArgs) : { args: userArgs, overrides: [] }; const overrides = [...runtimeConfigOverrides, ...modelFlagNormalization.overrides]; if (reasoningOverride) { overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`); } - if (overrides.length === 0) { - return modelFlagNormalization.args; + const needsConfigOverrideSupport = isCcsxpShortcut || overrides.length > 0; + if (needsConfigOverrideSupport && !codexBinarySupportsConfigOverrides(options?.binaryInfo)) { + throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo)); } - if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { - if (reasoningOverride || modelFlagNormalization.overrides.length > 0) { - throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo)); - } + if (overrides.length === 0) { return modelFlagNormalization.args; } return [...buildConfigOverrideArgs(overrides), ...modelFlagNormalization.args]; diff --git a/src/targets/target-metadata.ts b/src/targets/target-metadata.ts index 035ba432..00b2f60c 100644 --- a/src/targets/target-metadata.ts +++ b/src/targets/target-metadata.ts @@ -23,7 +23,7 @@ export const TARGET_METADATA: Record = { displayName: 'Codex CLI', runtimeAliases: ['ccs-codex', 'ccsx', 'ccsxp'], legacyAliasEnvVar: 'CCS_CODEX_ALIASES', - persistedTarget: false, + persistedTarget: true, }, } satisfies Record; 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..7c01c32e 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -17,13 +17,23 @@ import { // TYPE DEFINITIONS // ============================================================================ -export interface ModelPricing { +export interface PricingRates { inputPerMillion: number; outputPerMillion: number; cacheCreationPerMillion: number; cacheReadPerMillion: number; } +export interface ModelPricing extends PricingRates { + /** + * Optional per-service-tier rate overrides keyed by Anthropic's + * `service_tier` request parameter (e.g. `'fast'`). When the lookup is + * called with a known tier, these rates replace the base ones; otherwise + * the base rates apply. + */ + serviceTiers?: Record; +} + export interface TokenUsage { inputTokens: number; outputTokens: number; @@ -31,7 +41,41 @@ export interface TokenUsage { cacheReadTokens: number; } -export type PricingLookupOptions = ModelsDevPricingLookupOptions; +export interface PricingLookupOptions extends ModelsDevPricingLookupOptions { + /** + * Anthropic `service_tier` (e.g. `'fast'`). When set and the resolved model + * has matching `serviceTiers` rates, those are returned instead of the + * base rates. Unknown tiers transparently fall through to base. + */ + serviceTier?: string; +} + +// Anthropic prompt-caching multipliers, expressed relative to the base input +// rate (see https://platform.claude.com/docs/en/about-claude/pricing#prompt-caching). +// Keeping them as named constants avoids drift when deriving per-tier cache rates. +const CACHE_5M_WRITE_MULTIPLIER = 1.25; +const CACHE_READ_MULTIPLIER = 0.1; + +/** + * Build a full rate set from input/output rates, deriving cache rates from the + * documented Anthropic multipliers so fast-tier entries stay in sync with the + * base-rate math instead of carrying hand-computed cache numbers. + */ +function buildRates(inputPerMillion: number, outputPerMillion: number): PricingRates { + return { + inputPerMillion, + outputPerMillion, + cacheCreationPerMillion: inputPerMillion * CACHE_5M_WRITE_MULTIPLIER, + cacheReadPerMillion: inputPerMillion * CACHE_READ_MULTIPLIER, + }; +} + +// Anthropic fast-mode premiums (per +// https://platform.claude.com/docs/en/about-claude/pricing#fast-mode-pricing). +// Opus 4.6 and 4.7 share the same 6x premium; 4.8 is 2x. Shared constants keep +// the registry entries in sync rather than repeating the literal rates. +const OPUS_46_47_FAST_RATES = buildRates(30.0, 150.0); +const OPUS_48_FAST_RATES = buildRates(10.0, 50.0); // ============================================================================ // USER-EDITABLE PRICING TABLE @@ -223,25 +267,54 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, }, - // Claude 4.6 Opus ($5/$25) + // Claude 4.6 Opus ($5/$25) — fast mode ($30/$150, 6x premium per Anthropic docs) 'claude-opus-4-6': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_46_47_FAST_RATES, + }, }, 'claude-opus-4-6-thinking': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_46_47_FAST_RATES, + }, }, - // Claude 4.7 Opus ($5/$25) + // Claude 4.7 Opus ($5/$25) — fast mode ($30/$150, 6x premium per Anthropic docs) 'claude-opus-4-7': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_46_47_FAST_RATES, + }, + }, + // Legacy pricing-only entry for historical analytics data; this id has no + // catalog model (Opus 4.7 moved to adaptive thinking levels in 84dc4e24, so + // there is no separate -thinking variant). Kept so older usage records still + // resolve to the correct rate. No fast tier: the id is never requested live. + 'claude-opus-4-7-thinking': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, + // Claude 4.8 Opus ($5/$25) — fast mode ($10/$50, 2x premium per Anthropic docs) + 'claude-opus-4-8': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_48_FAST_RATES, + }, }, // --------------------------------------------------------------------------- @@ -886,12 +959,35 @@ function hasProviderContext(model: string, options: PricingLookupOptions): boole return Boolean(options.provider || /^[^/]+\//.test(model.trim())); } +/** + * Apply per-service-tier rates if the resolved model declares them and the + * caller requested a matching tier. Unknown tiers transparently fall through + * to the base rates so existing callers stay unaffected. + * + * TODO(opus-fast-mode): No production caller currently passes `serviceTier`. + * Anthropic's `service_tier` is not yet captured on CliproxyRequestDetail, so + * usage transformers (cliproxy-usage-transformer.ts, data-aggregator.ts) bill + * fast-mode requests at the standard rate — i.e. fast Opus is under-reported by + * the tier premium ($10/$50 vs $5/$25). Wire `serviceTier` through once the + * usage pipeline records it. The schema below is ready for that integration. + */ +function applyServiceTier(pricing: ModelPricing, tier: string | undefined): ModelPricing { + if (!tier) return pricing; + const tierRates = pricing.serviceTiers?.[tier]; + if (!tierRates) return pricing; + return { ...tierRates, serviceTiers: pricing.serviceTiers }; +} + /** * Get pricing for a model with narrow fuzzy matching fallback. * Unknown future model families should fall back instead of inheriting the * first known family tier that happens to share a prefix. */ export function getModelPricing(model: string, options: PricingLookupOptions = {}): ModelPricing { + return applyServiceTier(resolveBasePricing(model, options), options.serviceTier); +} + +function resolveBasePricing(model: string, options: PricingLookupOptions): ModelPricing { if (hasProviderContext(model, options)) { const ccsOverridePricing = getCcsPolicyOverridePricing(model); if (ccsOverridePricing !== undefined) { diff --git a/src/web-server/models-dev/pricing-resolver.ts b/src/web-server/models-dev/pricing-resolver.ts index 715e7600..9be07d1b 100644 --- a/src/web-server/models-dev/pricing-resolver.ts +++ b/src/web-server/models-dev/pricing-resolver.ts @@ -18,19 +18,19 @@ export interface ModelsDevPricingLookupOptions { provider?: string; } -const PROVIDER_ALIASES: Record = { - agy: 'google', - antigravity: 'google', - claude: 'anthropic', - codex: 'openai', - copilot: 'github-copilot', - gemini: 'google', - ghcp: 'github-copilot', - github: 'github-copilot', - kimi: 'moonshotai', - moonshot: 'moonshotai', - qwen: 'alibaba', -}; +const PROVIDER_ALIASES = new Map([ + ['agy', 'google'], + ['antigravity', 'google'], + ['claude', 'anthropic'], + ['codex', 'openai'], + ['copilot', 'github-copilot'], + ['gemini', 'google'], + ['ghcp', 'github-copilot'], + ['github', 'github-copilot'], + ['kimi', 'moonshotai'], + ['moonshot', 'moonshotai'], + ['qwen', 'alibaba'], +]); function normalizeId(value: string): string { return value.trim().toLowerCase(); @@ -45,7 +45,7 @@ export function normalizeModelsDevProviderId( ): string | undefined { if (!provider) return undefined; const normalized = normalizeId(provider); - return PROVIDER_ALIASES[normalized] ?? normalized; + return PROVIDER_ALIASES.get(normalized) ?? normalized; } function splitProviderPrefix(model: string): { provider?: string; model: string } { 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/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index 83d15a69..f72adce4 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -15,6 +15,7 @@ import { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails, mergeCliproxyUsageHistoryDetails, + normalizeCliproxyUsageHistoryDetail, pruneCliproxyUsageHistoryDetails, type CliproxyUsageHistoryDetail, } from './cliproxy-usage-transformer'; @@ -50,6 +51,8 @@ const HISTORY_RETENTION_DAYS = Math.max( ); const HISTORY_RETENTION_MS = HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000; const MAX_WRITE_ATTEMPTS = 3; +const PRIVATE_DIR_MODE = 0o700; +const PRIVATE_FILE_MODE = 0o600; /** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */ const SYNC_INTERVAL_MS = Math.max( @@ -68,11 +71,19 @@ function getLatestSnapshotPath(): string { return path.join(getCliproxyCacheDir(), 'latest.json'); } +function ensurePrivateDirectory(dir: string): void { + fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE }); + fs.chmodSync(dir, PRIVATE_DIR_MODE); +} + function ensureCliproxyCacheDir(): void { - const dir = getCliproxyCacheDir(); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } + const ccsDir = getCcsDir(); + const cacheDir = path.join(ccsDir, 'cache'); + const cliproxyCacheDir = path.join(cacheDir, 'cliproxy-usage'); + + ensurePrivateDirectory(ccsDir); + ensurePrivateDirectory(cacheDir); + ensurePrivateDirectory(cliproxyCacheDir); } function getSnapshotTimestamp(): number { @@ -112,8 +123,6 @@ function buildLegacyHistoryDetails( details.push({ model: breakdown.modelName, timestamp: buildHourlyTimestamp(hour.hour), - source: hour.source, - authIndex: 'legacy-hourly', inputTokens: breakdown.inputTokens, outputTokens: breakdown.outputTokens, cacheReadTokens: breakdown.cacheReadTokens, @@ -135,8 +144,6 @@ function buildLegacyHistoryDetails( details.push({ model: breakdown.modelName, timestamp: buildDailyTimestamp(day.date), - source: day.source, - authIndex: 'legacy-daily', inputTokens: breakdown.inputTokens, outputTokens: breakdown.outputTokens, cacheReadTokens: breakdown.cacheReadTokens, @@ -197,14 +204,27 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null { return null; } - if (!Array.isArray((snapshot as CliproxyUsageSnapshot).details)) { + const details = (snapshot as CliproxyUsageSnapshot).details; + if (!Array.isArray(details)) { if (emitWarnings) { console.log(info('CLIProxy snapshot details missing, will refresh on next sync')); } return null; } - return snapshot as CliproxyUsageSnapshot; + const normalizedDetails = details + .map((detail) => normalizeCliproxyUsageHistoryDetail(detail)) + .filter((detail): detail is CliproxyUsageHistoryDetail => detail !== null); + const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(normalizedDetails); + + return { + version: SNAPSHOT_VERSION, + timestamp: Number(snapshot.timestamp), + details: normalizedDetails, + daily, + hourly, + monthly, + }; } if (snapshot.version === 1 || snapshot.version === 2) { @@ -255,7 +275,11 @@ async function writeSnapshotWithMerge( const snapshot = buildSnapshot(baseSnapshot?.details ?? [], incomingDetails); const tempFile = `${snapshotPath}.${process.pid}.${snapshot.timestamp}.tmp`; - fs.writeFileSync(tempFile, JSON.stringify(snapshot), 'utf-8'); + fs.writeFileSync(tempFile, JSON.stringify(snapshot), { + encoding: 'utf-8', + mode: PRIVATE_FILE_MODE, + }); + fs.chmodSync(tempFile, PRIVATE_FILE_MODE); const latestSnapshot = readSnapshot(false); const latestTimestamp = latestSnapshot?.timestamp ?? -Infinity; @@ -265,6 +289,7 @@ async function writeSnapshotWithMerge( } fs.renameSync(tempFile, snapshotPath); + fs.chmodSync(snapshotPath, PRIVATE_FILE_MODE); console.log(ok('CLIProxy usage snapshot updated')); return; } diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index 5f96d632..472601b7 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -22,8 +22,6 @@ export interface CliproxyUsageHistoryDetail { model: string; provider?: string; timestamp: string; - source: string; - authIndex: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; @@ -64,30 +62,104 @@ function createHistoryDetail( detail: CliproxyRequestDetail ): CliproxyUsageHistoryDetail { const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase(); + const inputTokens = detail.tokens?.input_tokens ?? 0; + const outputTokens = detail.tokens?.output_tokens ?? 0; + const cacheReadTokens = detail.tokens?.cached_tokens ?? 0; + return { model, provider: pricingProvider, timestamp: detail.timestamp, - source: detail.source, - authIndex: String(detail.auth_index), - inputTokens: detail.tokens?.input_tokens ?? 0, - outputTokens: detail.tokens?.output_tokens ?? 0, - cacheReadTokens: detail.tokens?.cached_tokens ?? 0, + inputTokens, + outputTokens, + cacheReadTokens, requestCount: 1, - cost: calculateCost( - { - inputTokens: detail.tokens?.input_tokens ?? 0, - outputTokens: detail.tokens?.output_tokens ?? 0, - cacheCreationTokens: 0, - cacheReadTokens: detail.tokens?.cached_tokens ?? 0, - }, + cost: calculateHistoryDetailCost( model, - { provider: pricingProvider } + pricingProvider, + inputTokens, + outputTokens, + cacheReadTokens ), failed: detail.failed, }; } +function calculateHistoryDetailCost( + model: string, + provider: string | undefined, + inputTokens: number, + outputTokens: number, + cacheReadTokens: number +): number { + return calculateCost( + { + inputTokens, + outputTokens, + cacheCreationTokens: 0, + cacheReadTokens, + }, + model, + provider ? { provider } : undefined + ); +} + +function normalizePersistedNumber(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function normalizePersistedProvider(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + + return normalizeUsageProvider(trimmed) ?? trimmed.toLowerCase(); +} + +export function normalizeCliproxyUsageHistoryDetail( + detail: unknown +): CliproxyUsageHistoryDetail | null { + if (!detail || typeof detail !== 'object') return null; + + const candidate = detail as Record; + if ( + typeof candidate.model !== 'string' || + typeof candidate.timestamp !== 'string' || + !Number.isFinite(Date.parse(candidate.timestamp)) + ) { + return null; + } + + const provider = normalizePersistedProvider(candidate.provider); + const inputTokens = normalizePersistedNumber(candidate.inputTokens); + const outputTokens = normalizePersistedNumber(candidate.outputTokens); + const cacheReadTokens = normalizePersistedNumber(candidate.cacheReadTokens); + const requestCount = Math.max(1, normalizePersistedNumber(candidate.requestCount, 1)); + const cost = normalizePersistedNumber( + candidate.cost, + calculateHistoryDetailCost( + candidate.model, + provider, + inputTokens, + outputTokens, + cacheReadTokens + ) + ); + + return { + model: candidate.model, + ...(provider && { provider }), + timestamp: candidate.timestamp, + inputTokens, + outputTokens, + cacheReadTokens, + requestCount, + cost, + failed: candidate.failed === true, + }; +} + // ============================================================================ // FLATTEN // ============================================================================ @@ -128,13 +200,25 @@ export function extractCliproxyUsageHistoryDetails( return results; } +function sanitizeHistoryDetail(detail: CliproxyUsageHistoryDetail): CliproxyUsageHistoryDetail { + return { + model: detail.model, + ...(detail.provider && { provider: detail.provider }), + timestamp: detail.timestamp, + inputTokens: detail.inputTokens, + outputTokens: detail.outputTokens, + cacheReadTokens: detail.cacheReadTokens, + requestCount: detail.requestCount, + cost: detail.cost, + failed: detail.failed, + }; +} + function createHistorySignature(detail: CliproxyUsageHistoryDetail): string { return [ detail.model, detail.provider ?? '', detail.timestamp, - detail.source, - detail.authIndex, detail.inputTokens, detail.outputTokens, detail.cacheReadTokens, @@ -143,12 +227,51 @@ function createHistorySignature(detail: CliproxyUsageHistoryDetail): string { ].join('|'); } +function createProviderlessHistorySignature(detail: CliproxyUsageHistoryDetail): string { + return [ + detail.model, + detail.timestamp, + detail.inputTokens, + detail.outputTokens, + detail.cacheReadTokens, + detail.requestCount, + detail.failed ? '1' : '0', + ].join('|'); +} + +function hydrateProviderlessHistoryDetails( + existing: CliproxyUsageHistoryDetail[], + incoming: CliproxyUsageHistoryDetail[] +): CliproxyUsageHistoryDetail[] { + const incomingByProviderlessSignature = new Map(); + for (const detail of incoming) { + if (!detail.provider) continue; + + const signature = createProviderlessHistorySignature(detail); + incomingByProviderlessSignature.set(signature, [ + ...(incomingByProviderlessSignature.get(signature) ?? []), + detail, + ]); + } + + return existing.map((detail) => { + if (detail.provider) return detail; + + const matches = incomingByProviderlessSignature.get(createProviderlessHistorySignature(detail)); + const providers = new Set(matches?.map((match) => match.provider).filter(Boolean)); + if (!matches || providers.size !== 1) return detail; + + return { ...detail, provider: matches[0].provider, cost: matches[0].cost }; + }); +} + export function mergeCliproxyUsageHistoryDetails( existing: CliproxyUsageHistoryDetail[], incoming: CliproxyUsageHistoryDetail[] ): CliproxyUsageHistoryDetail[] { + const hydratedExisting = hydrateProviderlessHistoryDetails(existing, incoming); const existingCounts = new Map(); - for (const detail of existing) { + for (const detail of hydratedExisting) { const signature = createHistorySignature(detail); const entry = existingCounts.get(signature); if (entry) { @@ -182,7 +305,7 @@ export function mergeCliproxyUsageHistoryDetails( const merged: CliproxyUsageHistoryDetail[] = []; for (const { detail, count } of existingCounts.values()) { for (let index = 0; index < count; index++) { - merged.push({ ...detail }); + merged.push(sanitizeHistoryDetail(detail)); } } diff --git a/src/web-server/usage/codex-native-usage-collector.ts b/src/web-server/usage/codex-native-usage-collector.ts index 160fcfb7..a0ba3d8e 100644 --- a/src/web-server/usage/codex-native-usage-collector.ts +++ b/src/web-server/usage/codex-native-usage-collector.ts @@ -14,6 +14,8 @@ interface CodexNativeUsageCollectorOptions { } const CODEX_NATIVE_USAGE_CACHE_VERSION = 1; +const SECURE_CACHE_DIR_MODE = 0o700; +const SECURE_CACHE_FILE_MODE = 0o600; interface CachedRolloutFile { path: string; @@ -133,6 +135,14 @@ function isCodexNativeUsageCache(value: unknown): value is CodexNativeUsageCache return Object.values(value.files).every(isCachedRolloutFile); } +function chmodBestEffort(targetPath: string, mode: number): void { + try { + fs.chmodSync(targetPath, mode); + } catch { + // Cache reads and writes remain best-effort on filesystems that do not support chmod. + } +} + function readUsageCache( cacheDir: string, includeCliproxySessions: boolean @@ -153,11 +163,17 @@ function readUsageCache( function writeUsageCache(cacheDir: string, cache: CodexNativeUsageCache): void { try { - fs.mkdirSync(cacheDir, { recursive: true }); + fs.mkdirSync(cacheDir, { recursive: true, mode: SECURE_CACHE_DIR_MODE }); + chmodBestEffort(cacheDir, SECURE_CACHE_DIR_MODE); const cachePath = getCacheFilePath(cacheDir, cache.includeCliproxySessions); const tempPath = `${cachePath}.${process.pid}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(cache), 'utf8'); + fs.writeFileSync(tempPath, JSON.stringify(cache), { + encoding: 'utf8', + mode: SECURE_CACHE_FILE_MODE, + }); + chmodBestEffort(tempPath, SECURE_CACHE_FILE_MODE); fs.renameSync(tempPath, cachePath); + chmodBestEffort(cachePath, SECURE_CACHE_FILE_MODE); } catch { // Best-effort only. } 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/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/proxy/request-routing.test.ts b/tests/integration/proxy/request-routing.test.ts index 3fe350bf..d6ff7edb 100644 --- a/tests/integration/proxy/request-routing.test.ts +++ b/tests/integration/proxy/request-routing.test.ts @@ -419,4 +419,126 @@ describe('openai proxy request routing', () => { expect(body.reasoning_effort).toBeUndefined(); expect(body.tools?.length).toBe(1); }); + + it('keeps generic opaque model payloads unchanged unless reasoning shaping is opted in', async () => { + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + const upstreamPort = await startMockUpstream('gateway', hits, bodies); + + const settingsPath = writeSettings('gateway', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`, + ANTHROPIC_AUTH_TOKEN: 'gateway_token', + ANTHROPIC_MODEL: 'b3f9a2c7e8d14f60', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify({ profiles: { gateway: settingsPath } }, null, 2), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'gateway', + settingsPath, + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'gateway_token', + provider: 'generic-chat-completion-api', + model: 'b3f9a2c7e8d14f60', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'test-proxy-token', + }); + proxyPort = await waitForServerListening(proxyServer); + + const response = await requestProxy({ + model: 'b3f9a2c7e8d14f60', + max_tokens: 1024, + metadata: { trace: 'abc' }, + messages: [{ role: 'user', content: 'stay compatible' }], + }); + + expect(response.status).toBe(200); + expect(hits).toEqual(['gateway']); + + const body = bodies[0]?.body as { + max_tokens?: number; + max_completion_tokens?: number; + metadata?: unknown; + }; + expect(body.max_tokens).toBe(1024); + expect(body.max_completion_tokens).toBeUndefined(); + expect(body.metadata).toEqual({ trace: 'abc' }); + }); + + it('shapes generic opaque model payloads when reasoning shaping is opted in', async () => { + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + const upstreamPort = await startMockUpstream('gateway', hits, bodies); + + const settingsPath = writeSettings('gateway', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`, + ANTHROPIC_AUTH_TOKEN: 'gateway_token', + ANTHROPIC_MODEL: 'b3f9a2c7e8d14f60', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + CCS_OPENAI_REASONING_MODEL: '1', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify({ profiles: { gateway: settingsPath } }, null, 2), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'gateway', + settingsPath, + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'gateway_token', + provider: 'generic-chat-completion-api', + forceOpenAIReasoningModel: true, + model: 'b3f9a2c7e8d14f60', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'test-proxy-token', + }); + proxyPort = await waitForServerListening(proxyServer); + + const response = await requestProxy({ + model: 'b3f9a2c7e8d14f60', + thinking: { type: 'adaptive' }, + output_config: { effort: 'max' }, + max_tokens: 1024, + metadata: { trace: 'abc' }, + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + messages: [{ role: 'user', content: 'think with tools' }], + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + content: [{ type: 'text', text: 'Reply from gateway' }], + }); + expect(hits).toEqual(['gateway']); + + const body = bodies[0]?.body as { + max_tokens?: number; + max_completion_tokens?: number; + metadata?: unknown; + reasoning_effort?: string; + tools?: unknown[]; + }; + expect(body).toMatchObject({ + model: 'b3f9a2c7e8d14f60', + max_completion_tokens: 1024, + tool_choice: 'auto', + }); + expect(body.max_tokens).toBeUndefined(); + expect(body.metadata).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.tools?.length).toBe(1); + }); }); 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/api/profile-reader.test.ts b/tests/unit/api/profile-reader.test.ts index f677b2a1..c7bcc24f 100644 --- a/tests/unit/api/profile-reader.test.ts +++ b/tests/unit/api/profile-reader.test.ts @@ -56,7 +56,7 @@ describe('profile reader target sanitization', () => { } }); - it('normalizes legacy stored codex targets back to claude for profiles and variants', async () => { + it('normalizes legacy invalid stored targets back to claude for profiles and variants', async () => { const ccsDir = getScopedCcsDir(); fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync( @@ -64,12 +64,12 @@ describe('profile reader target sanitization', () => { JSON.stringify( { profiles: { demo: '~/.ccs/demo.settings.json' }, - profile_targets: { demo: 'codex' }, + profile_targets: { demo: 'glm' }, cliproxy: { routed: { provider: 'codex', settings: '~/.ccs/routed.settings.json', - target: 'codex', + target: 'glm', }, }, }, @@ -94,7 +94,7 @@ describe('profile reader target sanitization', () => { expect(result.variants[0]?.target).toBe('claude'); }); - it('normalizes unified stored codex targets back to claude for profiles and variants', async () => { + it('normalizes unified invalid stored targets back to claude for profiles and variants', async () => { const ccsDir = getScopedCcsDir(); fs.mkdirSync(ccsDir, { recursive: true }); process.env.CCS_UNIFIED_CONFIG = '1'; @@ -106,7 +106,7 @@ describe('profile reader target sanitization', () => { ' demo:', ' type: api', ' settings: ~/.ccs/demo.settings.json', - ' target: codex', + ' target: glm', 'cliproxy:', ' oauth_accounts: {}', ' providers: []', @@ -114,7 +114,7 @@ describe('profile reader target sanitization', () => { ' routed:', ' provider: codex', ' settings: ~/.ccs/routed.settings.json', - ' target: codex', + ' target: glm', '', ].join('\n'), 'utf8' 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/bin/ccsxp-runtime.test.ts b/tests/unit/bin/ccsxp-runtime.test.ts index f853f114..b2a7a5a9 100644 --- a/tests/unit/bin/ccsxp-runtime.test.ts +++ b/tests/unit/bin/ccsxp-runtime.test.ts @@ -1,16 +1,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import * as os from 'os'; import * as path from 'path'; +import { CCSXP_CLIPROXY_SHORTCUT_ENV } from '../../../src/targets/codex-cliproxy-provider-config'; const wrapperPath = require.resolve('../../../src/bin/ccsxp-runtime.ts'); const ccsPath = require.resolve('../../../src/ccs.ts'); describe('ccsxp runtime wrapper', () => { const originalArgv = process.argv; - const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; - const originalCodexHome = process.env.CODEX_HOME; - const originalCcsCodexProfile = process.env.CCS_CODEX_PROFILE; - const originalCcsxpCodexHome = process.env.CCSXP_CODEX_HOME; + const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; + const originalShortcut = process.env[CCSXP_CLIPROXY_SHORTCUT_ENV]; + const originalCodexHome = process.env.CODEX_HOME; + const originalCcsCodexProfile = process.env.CCS_CODEX_PROFILE; + const originalCcsxpCodexHome = process.env.CCSXP_CODEX_HOME; beforeEach(() => { delete require.cache[wrapperPath]; @@ -25,6 +27,11 @@ describe('ccsxp runtime wrapper', () => { } else { process.env.CCS_INTERNAL_ENTRY_TARGET = originalEntryTarget; } + if (originalShortcut === undefined) { + delete process.env[CCSXP_CLIPROXY_SHORTCUT_ENV]; + } else { + process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = originalShortcut; + } if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; } else { 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/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 83387831..9eebee5d 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -71,15 +71,15 @@ describe('api-command arg parser', () => { expect(parsed.target).toBeUndefined(); expect(parsed.errors).toEqual([ - 'Invalid --target value "invalid-target". Use: claude or droid', + 'Invalid --target value "invalid-target". Use: claude, droid, or codex', ]); }); - test('rejects runtime-only codex as a persisted API target value', () => { + test('accepts codex as a persisted API target value', () => { const parsed = parseApiCommandArgs(['my-api', '--target', 'codex']); - expect(parsed.target).toBeUndefined(); - expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']); + expect(parsed.target).toBe('codex'); + expect(parsed.errors).toEqual([]); }); test('collects missing-value error for --target with no value', () => { diff --git a/tests/unit/commands/cleanup-command.test.ts b/tests/unit/commands/cleanup-command.test.ts index 7f234dae..9c1ae433 100644 --- a/tests/unit/commands/cleanup-command.test.ts +++ b/tests/unit/commands/cleanup-command.test.ts @@ -53,4 +53,122 @@ describe('cleanup command', () => { logSpy.mockRestore(); } }); + + it('does not follow a symlinked CCS archive directory during cleanup', async () => { + if (process.platform === 'win32') return; + + const ccsLogsDir = getNativeLogsDir(); + const archiveDir = getLogArchiveDir(); + const victimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cleanup-victim-')); + + fs.mkdirSync(ccsLogsDir, { recursive: true }); + fs.writeFileSync(path.join(victimDir, 'keepme.log'), 'do not delete'); + fs.symlinkSync(victimDir, archiveDir, 'dir'); + + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleCleanupCommand(['--force']); + + const output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join('\n'); + + expect(output).toContain('No CCS or CLIProxy logs found.'); + expect(fs.existsSync(path.join(victimDir, 'keepme.log'))).toBe(true); + } finally { + logSpy.mockRestore(); + fs.rmSync(victimDir, { recursive: true, force: true }); + } + }); + + it('warns instead of reporting no logs when a cleanup directory cannot be read', async () => { + const archiveDir = getLogArchiveDir(); + fs.mkdirSync(archiveDir, { recursive: true }); + + const originalReaddirSync = fs.readdirSync; + const readdirSpy = spyOn(fs, 'readdirSync').mockImplementation((dirPath, options) => { + if (String(dirPath) === archiveDir) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + + return originalReaddirSync(dirPath, options as never); + }); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleCleanupCommand(['--dry-run']); + + const output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join('\n'); + + expect(output).toContain('Could not read CCS Log Archives'); + expect(output).toContain('permission denied'); + expect(output).not.toContain('No CCS or CLIProxy logs found.'); + } finally { + readdirSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + it('warns instead of reporting no error logs when CLIProxy logs cannot be read', async () => { + const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs'); + fs.mkdirSync(cliproxyLogsDir, { recursive: true }); + + const originalReaddirSync = fs.readdirSync; + const readdirSpy = spyOn(fs, 'readdirSync').mockImplementation((dirPath, options) => { + if (String(dirPath) === cliproxyLogsDir) { + throw Object.assign(new Error('disk I/O failed'), { code: 'EIO' }); + } + + return originalReaddirSync(dirPath, options as never); + }); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleCleanupCommand(['--errors']); + + const output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join('\n'); + + expect(output).toContain('Could not read CLIProxy logs'); + expect(output).toContain('disk I/O failed'); + expect(output).not.toContain('No error logs found.'); + } finally { + readdirSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + it('warns instead of treating CLIProxy log inspection errors as missing directories', async () => { + const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs'); + fs.mkdirSync(cliproxyLogsDir, { recursive: true }); + + const originalLstatSync = fs.lstatSync; + const lstatSpy = spyOn(fs, 'lstatSync').mockImplementation((targetPath, options) => { + if (String(targetPath) === cliproxyLogsDir) { + throw Object.assign(new Error('stat failed'), { code: 'EIO' }); + } + + return originalLstatSync(targetPath, options as never); + }); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleCleanupCommand(['--errors']); + + const output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join('\n'); + + expect(output).toContain('Could not inspect CLIProxy logs'); + expect(output).toContain('stat failed'); + expect(output).not.toContain('No CLIProxy logs directory found.'); + } finally { + lstatSpy.mockRestore(); + logSpy.mockRestore(); + } + }); }); diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index f6bd033d..955ea422 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -84,3 +84,48 @@ describe('cliproxy quota subcommand failure formatting', () => { expect(resolveDisplayedTier('pro', 'unknown')).toBe('pro'); }); }); + +describe('cliproxy quota subcommand Codex label formatting', () => { + it('falls back to the cached window label for invalid Codex feature labels', async () => { + const { getCodexWindowDisplayLabel } = await loadQuotaCommandTestExports(); + + const cases = [ + { featureLabel: '', cadence: '5h', expected: 'Codex Spark (5h)' }, + { featureLabel: ' ', cadence: 'weekly', expected: 'Codex Spark (weekly)' }, + { + featureLabel: '\u001b[2J\u001b]52;c;payload\u0007', + cadence: '5h', + expected: 'Codex Spark (5h)', + }, + { featureLabel: { unexpected: true }, cadence: '5h', expected: 'Codex Spark (5h)' }, + ] as const; + + for (const { featureLabel, cadence, expected } of cases) { + const label = getCodexWindowDisplayLabel({ + label: 'GPT-5.3-Codex-Spark', + resetAfterSeconds: 3600, + category: 'additional', + cadence, + featureLabel, + } as never); + + expect(label).toBe(expected); + } + }); + + it('removes terminal control characters from cached Codex feature labels', async () => { + const { getCodexWindowDisplayLabel } = await loadQuotaCommandTestExports(); + + const label = getCodexWindowDisplayLabel({ + label: 'ignored', + resetAfterSeconds: 3600, + category: 'additional', + cadence: 'weekly', + featureLabel: '\u001b[2JGPT-5.3-Codex-Spark\u001b]52;c;payload\u0007', + }); + + expect(label).toBe('Codex Spark (weekly)'); + expect(label).not.toContain('\u001b'); + expect(label).not.toContain('\u0007'); + }); +}); diff --git a/tests/unit/commands/cliproxy-variant-args.test.ts b/tests/unit/commands/cliproxy-variant-args.test.ts index a810bceb..5e3f514c 100644 --- a/tests/unit/commands/cliproxy-variant-args.test.ts +++ b/tests/unit/commands/cliproxy-variant-args.test.ts @@ -26,11 +26,11 @@ describe('cliproxy variant arg parser', () => { expect(parsed.errors).toEqual(['Missing value for --target']); }); - test('rejects runtime-only codex as a persisted variant target value', () => { + test('accepts codex as a persisted variant target value', () => { const parsed = parseProfileArgs(['variant-a', '--target', 'codex']); - expect(parsed.target).toBeUndefined(); - expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']); + expect(parsed.target).toBe('codex'); + expect(parsed.errors).toEqual([]); }); test('uses last --target value when repeated', () => { 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/commands/shell-completion-command.test.ts b/tests/unit/commands/shell-completion-command.test.ts index c8a285d9..86d2efd4 100644 --- a/tests/unit/commands/shell-completion-command.test.ts +++ b/tests/unit/commands/shell-completion-command.test.ts @@ -143,6 +143,27 @@ describe('shell-completion command', () => { ); }); + it('completion adapters only delegate to the ccs command on PATH', () => { + const completionScripts = [ + '../../../scripts/completion/ccs.bash', + '../../../scripts/completion/ccs.zsh', + '../../../scripts/completion/ccs.fish', + '../../../scripts/completion/ccs.ps1', + ]; + + for (const scriptPath of completionScripts) { + const script = readFileSync(join(import.meta.dir, scriptPath), 'utf8'); + + expect(script).not.toContain('../..'); + expect(script).not.toContain('repo_root'); + expect(script).not.toContain('repoRoot'); + expect(script).not.toContain('repo_cli'); + expect(script).not.toContain('repoCli'); + expect(script).not.toContain('node '); + expect(script).not.toContain('& node'); + } + }); + it('fish completion strips a duplicated partial token before delegating to __complete', () => { const fishScript = readFileSync( join(import.meta.dir, '../../../scripts/completion/ccs.fish'), 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/data-aggregator.test.ts b/tests/unit/data-aggregator.test.ts index e15d468c..5aa90210 100644 --- a/tests/unit/data-aggregator.test.ts +++ b/tests/unit/data-aggregator.test.ts @@ -150,6 +150,40 @@ describe('aggregateDailyUsage', () => { expect(result[0].modelBreakdowns[0].provider).toBe('github-copilot'); expect(result[0].modelBreakdowns[0].inputTokens).toBe(3000); }); + + test('handles provider names inherited from Object.prototype', () => { + const entries: RawUsageEntry[] = [ + createEntry({ model: 'model-a', target: '__proto__' }), + createEntry({ model: 'model-b', target: 'constructor' }), + createEntry({ model: 'model-c', target: 'toString' }), + ]; + + const daily = aggregateDailyUsage(entries); + const hourly = aggregateHourlyUsage(entries); + const monthly = aggregateMonthlyUsage(entries); + const session = aggregateSessionUsage(entries); + + expect(daily[0].modelBreakdowns.map((item) => item.provider)).toEqual([ + '__proto__', + 'constructor', + 'tostring', + ]); + expect(hourly[0].modelBreakdowns.map((item) => item.provider)).toEqual([ + '__proto__', + 'constructor', + 'tostring', + ]); + expect(monthly[0].modelBreakdowns.map((item) => item.provider)).toEqual([ + '__proto__', + 'constructor', + 'tostring', + ]); + expect(session[0].modelBreakdowns.map((item) => item.provider)).toEqual([ + '__proto__', + 'constructor', + 'tostring', + ]); + }); }); // ============================================================================ 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/github/stable-release-issue-cleanup.test.mjs b/tests/unit/github/stable-release-issue-cleanup.test.mjs new file mode 100644 index 00000000..de97747d --- /dev/null +++ b/tests/unit/github/stable-release-issue-cleanup.test.mjs @@ -0,0 +1,96 @@ +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('ignores unrelated issue references after the action reference sequence', () => { + const text = [ + 'fix: guard release parser', + '', + 'Fixes #12; see #99 for the follow-up', + 'Resolves #13, #14 and #15; related to #100', + ].join('\n'); + + expect(extractIssueNumbers(text, { includeRefs: true })).toEqual([12, 13, 14, 15]); + expect(extractIssueNumbers(text, { includeRefs: false })).toEqual([12, 13, 14, 15]); + }); + + 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..025809f6 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' }); @@ -433,6 +439,45 @@ describe('ccs-browser MCP server - session and interception', () => { expect(listText).not.toContain('Docs'); }); + it('closes a page when Chrome DevTools requires PUT and returns text', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 8331, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 8332, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 8333, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ], + { requirePutForClosePage: true, closePageRespondsWithText: true } + ); + + const closeText = getResponseText(responses.find((message) => message.id === 8332)); + expect(closeText).toContain('status: closed'); + expect(closeText).toContain('selectedPageId: page-1'); + + const listText = getResponseText(responses.find((message) => message.id === 8333)); + expect(listText).toContain('0. Home'); + expect(listText).toContain('selected: true'); + expect(listText).not.toContain('Docs'); + }); + it('keeps the selected page when closing a different page', async () => { const responses = await runMcpRequests( [ @@ -780,8 +825,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/hooks/browser-mcp-test-harness.ts b/tests/unit/hooks/browser-mcp-test-harness.ts index 52dd8cea..087f1956 100644 --- a/tests/unit/hooks/browser-mcp-test-harness.ts +++ b/tests/unit/hooks/browser-mcp-test-harness.ts @@ -350,6 +350,8 @@ type RunMcpRequestsOptions = { childEnv?: NodeJS.ProcessEnv; responseTimeoutMs?: number; requirePutForNewPage?: boolean; + requirePutForClosePage?: boolean; + closePageRespondsWithText?: boolean; }; function encodeMessage(message: unknown): string { @@ -746,6 +748,12 @@ function createMockBrowser(pagesInput: MockPageState[]) { } if (req.url?.startsWith('/json/close/')) { + if (options.requirePutForClosePage && req.method !== 'PUT') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'method not allowed' })); + return; + } + const targetId = decodeURIComponent(req.url.slice('/json/close/'.length)); const entry = Array.from(pageStates.entries()).find(([, page]) => page.id === targetId); if (!entry) { @@ -754,6 +762,11 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } pageStates.delete(entry[0]); + if (options.closePageRespondsWithText) { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Target is closing'); + return; + } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ id: targetId })); return; diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 9e6eca84..16652330 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -168,6 +168,83 @@ describe('model-pricing', () => { expect(opus47dated.outputPerMillion).toBe(25.0); }); + it('should return correct pricing for Claude Opus 4.8', () => { + const opus48 = getModelPricing('claude-opus-4-8'); + expect(opus48.inputPerMillion).toBe(5.0); + expect(opus48.outputPerMillion).toBe(25.0); + expect(opus48.cacheCreationPerMillion).toBe(6.25); + expect(opus48.cacheReadPerMillion).toBe(0.5); + }); + + it('should match date-stamped Claude Opus 4.8 to correct pricing', () => { + // Anthropic stopped issuing date-stamped Opus IDs starting with the 4.6 + // generation, so this is a defensive guard for stripDateSuffix in case + // a third-party emits a synthesized dated alias. + const opus48dated = getModelPricing('claude-opus-4-8-20260530'); + expect(opus48dated.inputPerMillion).toBe(5.0); + expect(opus48dated.outputPerMillion).toBe(25.0); + }); + + it('should return fast-tier pricing for Claude Opus 4.8 when serviceTier=fast', () => { + // Anthropic's "fast mode" charges 2x for Opus 4.8 ($10/$50 vs $5/$25). + // Cache rates scale by the same standard multipliers (1.25x write, 0.1x read). + const opus48fast = getModelPricing('claude-opus-4-8', { serviceTier: 'fast' }); + expect(opus48fast.inputPerMillion).toBe(10.0); + expect(opus48fast.outputPerMillion).toBe(50.0); + expect(opus48fast.cacheCreationPerMillion).toBe(12.5); + expect(opus48fast.cacheReadPerMillion).toBe(1.0); + }); + + it('should return fast-tier pricing for Claude Opus 4.7 when serviceTier=fast', () => { + // Fast mode on 4.7 carries a 6x premium ($30/$150 per Anthropic docs). + const opus47fast = getModelPricing('claude-opus-4-7', { serviceTier: 'fast' }); + expect(opus47fast.inputPerMillion).toBe(30.0); + expect(opus47fast.outputPerMillion).toBe(150.0); + expect(opus47fast.cacheCreationPerMillion).toBe(37.5); // 30 * 1.25 + expect(opus47fast.cacheReadPerMillion).toBe(3.0); // 30 * 0.1 + }); + + it('should return fast-tier pricing for Claude Opus 4.6 when serviceTier=fast', () => { + // Fast mode on 4.6 carries the same 6x premium as 4.7 ($30/$150). + const opus46fast = getModelPricing('claude-opus-4-6', { serviceTier: 'fast' }); + expect(opus46fast.inputPerMillion).toBe(30.0); + expect(opus46fast.outputPerMillion).toBe(150.0); + expect(opus46fast.cacheCreationPerMillion).toBe(37.5); // 30 * 1.25 + expect(opus46fast.cacheReadPerMillion).toBe(3.0); // 30 * 0.1 + }); + + it('should apply fast-tier pricing together with date-suffix stripping', () => { + // stripDateSuffix (base lookup) and applyServiceTier run independently; + // confirm they compose for a date-stamped id requesting the fast tier. + const opus48fastDated = getModelPricing('claude-opus-4-8-20260530', { serviceTier: 'fast' }); + expect(opus48fastDated.inputPerMillion).toBe(10.0); + expect(opus48fastDated.outputPerMillion).toBe(50.0); + expect(opus48fastDated.cacheCreationPerMillion).toBe(12.5); + expect(opus48fastDated.cacheReadPerMillion).toBe(1.0); + }); + + it('should preserve serviceTiers metadata when applying tier rates', () => { + // applyServiceTier must keep the serviceTiers map on the returned object + // so callers can still inspect available tiers after rate substitution. + const opus48fast = getModelPricing('claude-opus-4-8', { serviceTier: 'fast' }); + expect(opus48fast.serviceTiers).toBeDefined(); + expect(opus48fast.serviceTiers?.fast).toBeDefined(); + }); + + it('should fall back to standard rates when serviceTier is unknown', () => { + // Unknown tier names must not throw; revert to base pricing transparently. + const opus48 = getModelPricing('claude-opus-4-8', { serviceTier: 'enterprise-mythos' }); + expect(opus48.inputPerMillion).toBe(5.0); + expect(opus48.outputPerMillion).toBe(25.0); + }); + + it('should preserve standard rates for Opus 4.8 when serviceTier omitted', () => { + // Regression guard: existing callers must keep current behavior. + const opus48 = getModelPricing('claude-opus-4-8'); + expect(opus48.inputPerMillion).toBe(5.0); + expect(opus48.outputPerMillion).toBe(25.0); + }); + it('should not map unknown future model families onto known family pricing', () => { const fallback = getModelPricing('unknown-model-xyz'); @@ -256,16 +333,38 @@ 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 }); + + it('should calculate Claude Opus 4.8 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-8'); + expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 + }); + + it('should calculate fast-tier Claude Opus 4.8 cost (2x premium)', () => { + 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-8', { serviceTier: 'fast' }); + expect(cost).toBe(73.5); // 10 + 50 + 12.5 + 1.0 + }); }); describe('getKnownModels', () => { @@ -469,17 +568,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/profile-router.test.ts b/tests/unit/proxy/profile-router.test.ts index 18f0d398..3608c448 100644 --- a/tests/unit/proxy/profile-router.test.ts +++ b/tests/unit/proxy/profile-router.test.ts @@ -28,6 +28,20 @@ describe('resolveOpenAICompatProfileConfig', () => { expect(result?.insecure).toBe(true); }); + it('supports opt-in reasoning payload shaping for opaque OpenAI-compatible model IDs', () => { + const result = resolveOpenAICompatProfileConfig('gateway', '/tmp/gateway.settings.json', { + ANTHROPIC_BASE_URL: 'https://gateway.example.com/v1', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + ANTHROPIC_MODEL: 'b3f9a2c7e8d14f60', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + CCS_OPENAI_REASONING_MODEL: 'yes', + }); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('generic-chat-completion-api'); + expect(result?.forceOpenAIReasoningModel).toBe(true); + }); + it('ignores Anthropic-compatible profiles', () => { const result = resolveOpenAICompatProfileConfig('glm', '/tmp/glm.settings.json', { ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', 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/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-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts index 5a48af16..6d4927d9 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { CodexAdapter } from '../../../src/targets/codex-adapter'; +import { CCSXP_CLIPROXY_SHORTCUT_ENV } from '../../../src/targets/codex-cliproxy-provider-config'; import { buildCodexBrowserMcpOverrides, getCodexBrowserMcpServerName, @@ -97,6 +98,30 @@ describe('CodexAdapter', () => { ).toThrow(/does not advertise --config overrides/); }); + test('rejects ccsxp default launches when codex lacks config override support', () => { + const originalShortcut = process.env[CCSXP_CLIPROXY_SHORTCUT_ENV]; + process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = '1'; + try { + expect(() => + adapter.buildArgs('default', ['--config', 'model_provider="cliproxy"', '--version'], { + profileType: 'default', + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + version: 'codex-cli 0.1.0', + features: [], + }, + }) + ).toThrow(/does not advertise --config overrides/); + } finally { + if (originalShortcut === undefined) { + delete process.env[CCSXP_CLIPROXY_SHORTCUT_ENV]; + } else { + process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = originalShortcut; + } + } + }); + test('injects transient config overrides for CCS-backed launches', () => { const runtimeConfigOverrides = buildCodexBrowserMcpOverrides(); const args = adapter.buildArgs('codex', ['--search'], { @@ -120,7 +145,9 @@ describe('CodexAdapter', () => { expect(args).toContain('model_provider="ccs_runtime"'); expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"'); expect(args).toContain('model="gpt-5.4"'); - expect(args).toContain(`mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`); + expect(args).toContain( + `mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}` + ); expect(args).toContain('model_reasoning_effort="high"'); expect(args[args.length - 1]).toBe('--search'); }); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index a14dfed4..48bbaaf1 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'], @@ -714,8 +716,10 @@ process.exit(0); expect(result.status).toBe(0); expect(result.stdout).toContain('codex-cli 9.9.9-test'); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', '--version'], + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + '--version', ]); }); @@ -737,19 +741,17 @@ process.exit(0); }); expect(result.status).toBe(0); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: path.join(tmpHome, '.codex'), - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CLIPROXY_API_KEY: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, - ]); + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: path.join(tmpHome, '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); }); it('routes default ccsxp launches through native Codex with the cliproxy provider override', () => { @@ -768,24 +770,50 @@ process.exit(0); }); expect(result.status).toBe(0); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', ]); const codexConfig = fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf8'); expect(codexConfig).toContain('[model_providers.cliproxy]'); expect(codexConfig).toContain('env_key = "CLIPROXY_API_KEY"'); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: path.join(tmpHome, '.codex'), - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CLIPROXY_API_KEY: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: path.join(tmpHome, '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); + }); + + it('fails fast when ccsxp needs unsupported Codex config overrides', () => { + if (process.platform === 'win32') return; + + const result = runCcsxpAlias(['--version'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + HOME: tmpHome, + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_CONFIG_OVERRIDE_STATUS: 'unsupported', + CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CCS_TEST_CODEX_HELP: ' -p, --profile \n', + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Codex CLI (codex-cli 9.9.9-test)'); + expect(result.stderr).toContain('does not advertise --config overrides'); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ + ['-c', 'model="gpt-5"', '--version'], + ['--help'], + ['--version'], ]); }); @@ -808,8 +836,10 @@ process.exit(0); }); expect(result.status).toBe(0); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', ]); const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); expect(codexConfig).toContain('model = "gpt-5.5"'); @@ -846,6 +876,32 @@ process.exit(0); ]); }); + it('preserves ccsxp positional model-like arguments after the option terminator', () => { + if (process.platform === 'win32') return; + + const result = runCcsxpAlias(['--', '--', '-m', 'gpt-5.5-high-fast', 'prompt text'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + HOME: tmpHome, + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + }); + + expect(result.status).toBe(0); + const codexCalls = readLoggedCodexCalls(codexArgsLogPath); + expect(codexCalls.at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + '--', + '-m', + 'gpt-5.5-high-fast', + 'prompt text', + ]); + }); + it('normalizes ccsxp native low Codex tuning aliases in config.toml', () => { if (process.platform === 'win32') return; @@ -903,24 +959,24 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', ]); const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); expect(codexConfig).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"'); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: codexHome, - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CCS_CUSTOM_CLIPROXY_TOKEN: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, - ]); + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: codexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_CUSTOM_CLIPROXY_TOKEN: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); }); it('preserves a custom cliproxy provider base_url for ccsxp launches', () => { @@ -954,27 +1010,27 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', ]); const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); expect(codexConfig).toContain( 'base_url = "https://cliproxy.example.com/api/provider/codex/responses"' ); expect(codexConfig).toContain('env_key = "CCS_REMOTE_CLIPROXY_TOKEN"'); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: codexHome, - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CCS_REMOTE_CLIPROXY_TOKEN: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, - ]); + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: codexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_REMOTE_CLIPROXY_TOKEN: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); }); it('keeps ccsxp native when the CCS default profile is a Claude account', () => { @@ -1005,22 +1061,22 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', 'fix failing tests'], - ]); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: path.join(tmpHome, '.codex'), - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CLIPROXY_API_KEY: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', ]); + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: path.join(tmpHome, '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); }); it('keeps implicit ccs --target codex launches native when the CCS default is a Claude account', () => { @@ -1045,6 +1101,7 @@ supports_websockets = false HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, + CODEX_HOME: undefined, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, CCS_THINKING: '8192', @@ -1135,7 +1192,9 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(result.stderr).not.toContain('Codex CLI does not support Claude account-based profiles.'); + expect(result.stderr).not.toContain( + 'Codex CLI does not support Claude account-based profiles.' + ); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['fix failing tests']]); expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ { @@ -1205,19 +1264,17 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: explicitCodexHome, - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CLIPROXY_API_KEY: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, - ]); + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: explicitCodexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); }); it('fails with a clean CLI error when ccsxp receives a malformed --target flag', () => { diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 72bfbdaa..172da6e0 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -68,9 +68,9 @@ describe('resolveTargetType', () => { expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude'); }); - it('should ignore runtime-only codex target when it appears in persisted profile config', () => { + it('should use codex from persisted profile config', () => { process.argv = ['node', 'ccs']; - expect(resolveTargetType([], { target: 'codex' })).toBe('claude'); + expect(resolveTargetType([], { target: 'codex' })).toBe('codex'); }); it('should prioritize --target flag over profile config', () => { 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/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts index 246a0d3f..5957e83c 100644 --- a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -3,12 +3,20 @@ import { DEFAULT_IMAGE_ANALYSIS_CONFIG, type ImageAnalysisConfig, } from '../../../../src/config/unified-config-types'; +import { findModel } from '../../../../src/cliproxy/model-catalog'; import { canonicalizeImageAnalysisConfig, resolveImageAnalysisStatus, } from '../../../../src/utils/hooks/image-analysis-backend-resolver'; describe('image-analysis-backend-resolver', () => { + it('uses a catalog-backed Claude image analysis default model', () => { + const defaultModel = DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models.claude; + + expect(defaultModel).toBe('claude-haiku-4-5-20251001'); + expect(findModel('claude', defaultModel)?.id).toBe(defaultModel); + }); + it('canonicalizes provider aliases in config', () => { const config = canonicalizeImageAnalysisConfig({ enabled: true, 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/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index db9dffa1..32f1195f 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -20,7 +20,10 @@ function fetchRawResponse(): Promise { return Promise.resolve(rawResponse); } -function buildResponse(inputTokens: number, timestamp = '2026-03-02T12:00:00.000Z'): CliproxyUsageApiResponse { +function buildResponse( + inputTokens: number, + timestamp = '2026-03-02T12:00:00.000Z' +): CliproxyUsageApiResponse { return { usage: { apis: { @@ -109,6 +112,21 @@ describe('cliproxy usage syncer', () => { const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); expect(fs.existsSync(snapshotPath)).toBe(true); + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as { + details: Array>; + }; + expect(snapshot.details[0]).not.toHaveProperty('source'); + expect(snapshot.details[0]).not.toHaveProperty('authIndex'); + + if (process.platform !== 'win32') { + const cacheDir = path.join(ccsDir, 'cache'); + const cliproxyCacheDir = path.dirname(snapshotPath); + expect(fs.statSync(ccsDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(cacheDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(cliproxyCacheDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(snapshotPath).mode & 0o777).toBe(0o600); + } + const cached = await runWithScopedConfigDir(ccsDir, async () => { return await loadCachedCliproxyData(); }); @@ -175,6 +193,69 @@ describe('cliproxy usage syncer', () => { }); }); + it('normalizes old v3 snapshot details before loading and merging history', async () => { + await runWithScopedConfigDir(ccsDir, async () => { + const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); + fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); + fs.writeFileSync( + snapshotPath, + JSON.stringify({ + version: 3, + timestamp: Date.now() - 60_000, + details: [ + { + model: 'gemini-2.5-pro', + timestamp: '2026-03-02T12:00:00.000Z', + source: 'account-a', + authIndex: '0', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 10, + failed: false, + }, + ], + daily: [ + { + date: '2026-03-02', + source: 'cliproxy', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: null, + totalCost: null, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [], + }, + ], + hourly: [], + monthly: [], + }), + 'utf-8' + ); + + const loaded = await loadCachedCliproxyData(); + expect(loaded.daily[0].inputTokens).toBe(100); + expect(Number.isFinite(loaded.daily[0].totalCost)).toBe(true); + expect(loaded.hourly[0].requestCount).toBe(1); + + await syncCliproxyUsage(fetchRawResponse); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily).toHaveLength(1); + expect(cached.daily[0].inputTokens).toBe(100); + expect(cached.hourly[0].requestCount).toBe(1); + + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as { + details: Array<{ requestCount?: number; cost?: number; provider?: string }>; + }; + expect(snapshot.details).toHaveLength(1); + expect(snapshot.details[0].requestCount).toBe(1); + expect(Number.isFinite(snapshot.details[0].cost)).toBe(true); + expect(snapshot.details[0].provider).toBe('google'); + }); + }); + it('migrates legacy v1 and v2 snapshots forward before merging new history', async () => { for (const version of [1, 2]) { await runWithScopedConfigDir(ccsDir, async () => { @@ -264,7 +345,9 @@ describe('cliproxy usage syncer', () => { const cached = await loadCachedCliproxyData(); expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']); - expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe(7); + expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe( + 7 + ); }); } }); @@ -274,9 +357,7 @@ describe('cliproxy usage syncer', () => { await syncCliproxyUsage(() => Promise.resolve(buildResponse(100, '2024-01-01T12:00:00.000Z')) ); - await syncCliproxyUsage(() => - Promise.resolve(buildResponse(200, new Date().toISOString())) - ); + await syncCliproxyUsage(() => Promise.resolve(buildResponse(200, new Date().toISOString()))); const cached = await loadCachedCliproxyData(); expect(cached.daily.some((entry) => entry.date === '2024-01-01')).toBe(false); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts index 4e590aa6..ae2dbd79 100644 --- a/tests/unit/web-server/cliproxy-usage-transformer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -11,6 +11,7 @@ import { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails, mergeCliproxyUsageHistoryDetails, + normalizeCliproxyUsageHistoryDetail, transformCliproxyToDailyUsage, transformCliproxyToHourlyUsage, transformCliproxyToMonthlyUsage, @@ -109,20 +110,16 @@ describe('cliproxy usage transformer', () => { const flat = extractCliproxyUsageHistoryDetails(sampleResponse); expect(flat).toHaveLength(4); expect(flat[0].provider).toBe('google'); + expect(flat[0]).not.toHaveProperty('source'); + expect(flat[0]).not.toHaveProperty('authIndex'); expect( flat.some( - (entry) => - entry.failed === true && - entry.inputTokens === 40 && - entry.outputTokens === 10 + (entry) => entry.failed === true && entry.inputTokens === 40 && entry.outputTokens === 10 ) ).toBe(true); expect( flat.some( - (entry) => - entry.failed === true && - entry.inputTokens === 0 && - entry.outputTokens === 0 + (entry) => entry.failed === true && entry.inputTokens === 0 && entry.outputTokens === 0 ) ).toBe(false); }); @@ -134,6 +131,69 @@ describe('cliproxy usage transformer', () => { expect(merged).toHaveLength(details.length); }); + it('normalizes old v3 history details before merging with provider-aware entries', () => { + const [incoming] = extractCliproxyUsageHistoryDetails({ + usage: { + apis: { + gemini: { + models: { + 'gemini-2.5-pro': { + details: [ + { + timestamp: '2026-03-01T10:15:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 100, + output_tokens: 50, + reasoning_tokens: 0, + cached_tokens: 20, + total_tokens: 170, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }); + const legacyDetail = normalizeCliproxyUsageHistoryDetail({ + model: incoming.model, + timestamp: incoming.timestamp, + source: 'account-a', + authIndex: '0', + inputTokens: incoming.inputTokens, + outputTokens: incoming.outputTokens, + cacheReadTokens: incoming.cacheReadTokens, + failed: incoming.failed, + }); + + expect(legacyDetail?.requestCount).toBe(1); + expect(Number.isFinite(legacyDetail?.cost)).toBe(true); + expect(legacyDetail).not.toHaveProperty('source'); + expect(legacyDetail).not.toHaveProperty('authIndex'); + + const merged = mergeCliproxyUsageHistoryDetails([legacyDetail!], [incoming]); + expect(merged).toHaveLength(1); + expect(merged[0].provider).toBe(incoming.provider); + expect(merged[0].cost).toBe(incoming.cost); + }); + + it('strips legacy account identifiers when merging persisted history', () => { + const details = extractCliproxyUsageHistoryDetails(sampleResponse); + const legacyDetail = { + ...details[0], + source: 'user@example.com', + authIndex: 'auth-file-7', + }; + const merged = mergeCliproxyUsageHistoryDetails([legacyDetail], []); + + expect(merged[0]).not.toHaveProperty('source'); + expect(merged[0]).not.toHaveProperty('authIndex'); + }); + it('preserves legitimate duplicate requests when the incoming batch has more occurrences', () => { const details = extractCliproxyUsageHistoryDetails(sampleResponse); const repeated = [details[0], { ...details[0] }]; diff --git a/tests/unit/web-server/codex-native-usage-collector.test.ts b/tests/unit/web-server/codex-native-usage-collector.test.ts index 50d68991..0111f560 100644 --- a/tests/unit/web-server/codex-native-usage-collector.test.ts +++ b/tests/unit/web-server/codex-native-usage-collector.test.ts @@ -311,6 +311,28 @@ describe('codex native usage collector', () => { expect(entries).toHaveLength(2); }); + it('writes native usage caches with owner-only permissions', async () => { + if (process.platform === 'win32') return; + + writeCodexRollout(tempRoot); + const cacheDir = getCacheDir(); + const previousUmask = process.umask(0o022); + + try { + await scanCodexNativeUsageEntries({ + env: { CODEX_HOME: tempRoot }, + homeDir: tempRoot, + cacheDir, + }); + } finally { + process.umask(previousUmask); + } + + const cachePath = path.join(cacheDir, 'codex-native-usage-v1.json'); + expect(fs.statSync(cacheDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(cachePath).mode & 0o777).toBe(0o600); + }); + it('keeps default and include-cliproxy cache entries separate', async () => { writeCodexRollout(tempRoot, { modelProvider: 'cliproxy' }); const cacheDir = getCacheDir(); 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/target-parse-routes.test.ts b/tests/unit/web-server/target-parse-routes.test.ts index b0446dab..d1942308 100644 --- a/tests/unit/web-server/target-parse-routes.test.ts +++ b/tests/unit/web-server/target-parse-routes.test.ts @@ -7,16 +7,16 @@ describe('route target parsing', () => { it('accepts valid target values', () => { expect(parseProfileTarget('claude')).toBe('claude'); expect(parseProfileTarget('DROID')).toBe('droid'); + expect(parseProfileTarget('codex')).toBe('codex'); expect(parseVariantTarget(' claude ')).toBe('claude'); expect(parseVariantTarget('droid')).toBe('droid'); + expect(parseVariantTarget(' codex ')).toBe('codex'); }); it('returns null for invalid target values', () => { expect(parseProfileTarget('glm')).toBeNull(); - expect(parseProfileTarget('codex')).toBeNull(); expect(parseProfileTarget('')).toBeNull(); expect(parseVariantTarget('factory')).toBeNull(); - expect(parseVariantTarget('codex')).toBeNull(); expect(parseVariantTarget(' ')).toBeNull(); }); 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/account/flow-viz/utils.ts b/ui/src/components/account/flow-viz/utils.ts index e6249006..fdbd0e35 100644 --- a/ui/src/components/account/flow-viz/utils.ts +++ b/ui/src/components/account/flow-viz/utils.ts @@ -47,10 +47,15 @@ export function generateConnectionEvents(accounts: AccountData[]): ConnectionEve // Use a shared base time so events from all accounts interleave in the timeline. // Without this, accounts with more recent lastUsedAt dominate the sorted output. const now = Date.now(); - const sharedBaseTime = accounts.reduce((latest, a) => { - const t = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : now; - return Math.max(latest, isNaN(t) ? now : t); - }, now); + const latestLastUsedAt = accounts.reduce((latest, account) => { + if (!account.lastUsedAt) return latest; + + const timestamp = new Date(account.lastUsedAt).getTime(); + if (Number.isNaN(timestamp)) return latest; + + return latest === undefined ? timestamp : Math.max(latest, timestamp); + }, undefined); + const sharedBaseTime = latestLastUsedAt ?? now; accounts.forEach((account) => { const lastUsed = new Date(sharedBaseTime); diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index ad61b231..8e680913 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -172,6 +172,8 @@ export function AccountSurfaceCard({ const { t } = useTranslation(); const identity = getAccountIdentityPresentation(accountId, email, tokenFile); const title = displayEmail || identity.email || accountId; + const sensitiveTitle = (value: string | null | undefined) => + privacyMode ? undefined : (value ?? undefined); const normalizedProvider = provider.toLowerCase(); const effectiveTier = resolveEffectiveTier(tier, quota); const effectiveCodexBadge = @@ -192,6 +194,7 @@ export function AccountSurfaceCard({ @@ -201,9 +204,10 @@ export function AccountSurfaceCard({ {normalizedProvider === 'codex' ? effectiveCodexBadge?.label && ( @@ -212,9 +216,10 @@ export function AccountSurfaceCard({ ) : identity.audienceLabel && ( @@ -223,9 +228,10 @@ export function AccountSurfaceCard({ )} {normalizedProvider !== 'codex' && identity.compactDetailLabel && ( @@ -260,6 +266,7 @@ export function AccountSurfaceCard({ @@ -303,6 +311,7 @@ export function AccountSurfaceCard({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 border-transparent', + privacyMode && PRIVACY_BLUR_CLASS, getAudienceBadgeClass(identity.audience) )} > @@ -310,7 +319,10 @@ export function AccountSurfaceCard({ )} {!isCompact && normalizedProvider !== 'codex' && identity.detailLabel && ( - + {identity.detailLabel} )} 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/logs-filters.tsx b/ui/src/components/logs/logs-filters.tsx index 4d00d830..7080a1a9 100644 --- a/ui/src/components/logs/logs-filters.tsx +++ b/ui/src/components/logs/logs-filters.tsx @@ -44,7 +44,7 @@ export interface LogsFiltersProps { onRequestIdChange?: (v: string) => void; timeWindow?: LogsTimeWindow; onTimeWindowChange?: (v: LogsTimeWindow) => void; - /** When true, hides entries from `web-server:*` sources. Default ON. */ + /** When true, hides entries from `web-server:*` sources. Default OFF. */ hideDashboardInternals?: boolean; onHideDashboardInternalsChange?: (next: boolean) => void; onClearAll?: () => void; @@ -91,7 +91,7 @@ export function LogsFilters({ onRequestIdChange, timeWindow = 'all', onTimeWindowChange, - hideDashboardInternals = true, + hideDashboardInternals = false, onHideDashboardInternalsChange, onClearAll, }: LogsFiltersProps) { @@ -324,11 +324,10 @@ export function LogsFilters({ htmlFor="logs-hide-internals" className="block text-[12px] font-medium text-foreground" > - Hide dashboard internals + Hide dashboard web-server logs

- Suppress web-server:*{' '} - self-polling. + Optional noise reduction. Audit entries are visible by default.

onHideDashboardInternalsChange(e.target.checked)} className={cn('mt-0.5 h-4 w-4 cursor-pointer accent-foreground', FOCUS_RING)} - aria-label="Hide dashboard internals" + aria-label="Hide dashboard web-server logs" /> ) : null} 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/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx index dc9b5930..fc0c194a 100644 --- a/ui/src/components/setup/wizard/steps/account-step.tsx +++ b/ui/src/components/setup/wizard/steps/account-step.tsx @@ -53,6 +53,7 @@ export function AccountStep({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 border-transparent', + privacyMode && PRIVACY_BLUR_CLASS, codexBadge.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:text-sky-300' : codexBadge.audience === 'free' @@ -67,6 +68,7 @@ export function AccountStep({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 border-transparent', + privacyMode && PRIVACY_BLUR_CLASS, identity.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:text-sky-300' : identity.audience === 'free' @@ -78,7 +80,10 @@ export function AccountStep({ ) : null} {!codexBadge?.label && identity.detailLabel && ( - + {identity.detailLabel} )} diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 394212de..396cfe2b 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -89,6 +89,7 @@ export function VariantStep({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 border-transparent', + privacyMode && PRIVACY_BLUR_CLASS, selectedCodexBadge.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:text-sky-300' : selectedCodexBadge.audience === 'free' @@ -103,6 +104,7 @@ export function VariantStep({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 border-transparent', + privacyMode && PRIVACY_BLUR_CLASS, selectedAccountIdentity.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:text-sky-300' : selectedAccountIdentity.audience === 'free' @@ -114,7 +116,10 @@ export function VariantStep({ ) : null} {!selectedCodexBadge?.label && selectedAccountIdentity?.detailLabel && ( - + {selectedAccountIdentity.detailLabel} )} 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/hooks/use-logs.ts b/ui/src/hooks/use-logs.ts index 702db1f6..b27dc0ae 100644 --- a/ui/src/hooks/use-logs.ts +++ b/ui/src/hooks/use-logs.ts @@ -116,10 +116,10 @@ export function useLogsWorkspace() { const [limit, setLimit] = useState(DEFAULT_LIMIT); const [selectedEntryId, setSelectedEntryId] = useState(null); const [isPaused, setIsPaused] = useState(false); - // Default ON: dashboard self-polling generates 100s of identical entries - // per refresh which drown out real provider activity. Users can opt in to - // see internals via the advanced filter toggle. - const [hideDashboardInternals, setHideDashboardInternals] = useState(true); + // Default OFF: web-server:* entries include dashboard access and WebSocket + // audit evidence, so keep them visible unless the operator opts into noise + // reduction from the advanced filter toggle. + const [hideDashboardInternals, setHideDashboardInternals] = useState(false); const frozenIdsRef = useRef>(new Set()); const deferredSearch = useDeferredValue(search.trim()); @@ -196,8 +196,9 @@ export function useLogsWorkspace() { const ts = Date.parse(entry.timestamp); if (Number.isFinite(ts) && now - ts > cutoffMs) return false; } - // Hide dashboard self-polling unless user opted in. Preserves the - // signal-to-noise ratio for fresh-load investigations. + // Optional noise reduction only: web-server:* entries can contain + // security-relevant dashboard access and WebSocket audit evidence, so + // they remain visible by default. if (hideDashboardInternals && /^web-server:/i.test(entry.source)) return false; return true; }); @@ -273,7 +274,7 @@ export function useLogsWorkspace() { setStageFilter(''); setRequestIdFilter(''); setTimeWindow('all'); - setHideDashboardInternals(true); + setHideDashboardInternals(false); }, []); return { 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..8059da26 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)', @@ -630,10 +752,22 @@ export const MODEL_CATALOGS: Record = { displayName: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-6', models: [ + { + id: 'claude-opus-4-8', + name: 'Claude Opus 4.8', + description: 'Latest flagship model', + extendedContext: true, + presetMapping: { + default: 'claude-opus-4-8', + opus: 'claude-opus-4-8', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }, + }, { id: 'claude-opus-4-7', name: 'Claude Opus 4.7', - description: 'Latest flagship model', + description: 'Previous flagship model', extendedContext: true, presetMapping: { default: 'claude-opus-4-7', @@ -645,7 +779,7 @@ export const MODEL_CATALOGS: Record = { { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', - description: 'Previous flagship model', + description: 'Older flagship model', extendedContext: true, presetMapping: { default: 'claude-opus-4-6', @@ -792,11 +926,19 @@ export function buildUiCatalog( availableModels.some( (model) => normalizeModelId(model.id) === normalizeModelId(fallbackDefaultModel) ); + const hasLiveDefaultModel = availableModels.some( + (model) => normalizeModelId(model.id) === normalizeModelId(liveCatalog.defaultModel) + ); + const defaultModel = hasFallbackDefaultModel + ? fallbackDefaultModel + : hasLiveDefaultModel + ? liveCatalog.defaultModel + : (models[0]?.id ?? ''); return { provider: liveCatalog.provider, displayName: liveCatalog.displayName || staticCatalog?.displayName || provider, - defaultModel: hasFallbackDefaultModel ? fallbackDefaultModel : liveCatalog.defaultModel, + defaultModel, models, }; } diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts index 04adc14c..7e4d4082 100644 --- a/ui/src/lib/preset-utils.ts +++ b/ui/src/lib/preset-utils.ts @@ -66,11 +66,12 @@ export async function applyDefaultPreset( const defaultModelEntry = resolvedCatalog.models.find((model) => model.id === resolvedCatalog.defaultModel) || resolvedCatalog.models[0]; + const selectedModelId = defaultModelEntry?.id ?? resolvedCatalog.defaultModel; const mapping = defaultModelEntry?.presetMapping || { - default: resolvedCatalog.defaultModel, - opus: resolvedCatalog.defaultModel, - sonnet: resolvedCatalog.defaultModel, - haiku: resolvedCatalog.defaultModel, + default: selectedModelId, + opus: selectedModelId, + sonnet: selectedModelId, + haiku: selectedModelId, }; // Fetch effective API key (respects user customization) 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/components/account/account-surface-card.test.tsx b/ui/tests/components/account/account-surface-card.test.tsx new file mode 100644 index 00000000..c65456dc --- /dev/null +++ b/ui/tests/components/account/account-surface-card.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card'; + +describe('AccountSurfaceCard privacy titles', () => { + it('keeps sensitive account title attributes available outside privacy mode', () => { + const { container } = render( + + ); + + expect(screen.getByText('person@example.com')).toHaveAttribute('title', 'person@example.com'); + expect(container.querySelector('[title="Business"]')).toBeInTheDocument(); + expect(container.querySelector('[title="Workspace abcdef12"]')).toBeInTheDocument(); + }); + + it('removes sensitive account title attributes in privacy mode', () => { + const { container } = render( + <> + + + + ); + + expect(screen.getByText('person@example.com')).not.toHaveAttribute('title'); + expect(screen.getByText('codex@example.com')).not.toHaveAttribute('title'); + expect(container.querySelector('[title="Business"]')).not.toBeInTheDocument(); + expect(container.querySelector('[title="Workspace abcdef12"]')).not.toBeInTheDocument(); + expect(container.querySelector('[title*="Enterprise"]')).not.toBeInTheDocument(); + }); +}); 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/components/setup/account-identity-privacy.test.tsx b/ui/tests/unit/components/setup/account-identity-privacy.test.tsx new file mode 100644 index 00000000..0dc704fa --- /dev/null +++ b/ui/tests/unit/components/setup/account-identity-privacy.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@tests/setup/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { AccountStep } from '@/components/setup/wizard/steps/account-step'; +import { VariantStep } from '@/components/setup/wizard/steps/variant-step'; +import type { OAuthAccount } from '@/lib/api-client'; + +const businessAccount: OAuthAccount = { + id: 'victim@example.com#04a0f049-team', + email: 'victim@example.com', + provider: 'gemini', + isDefault: false, + tokenFile: 'gemini-victim@example.com-04a0f049-team.json', + createdAt: '2026-01-01T00:00:00.000Z', +}; + +describe('setup account identity privacy', () => { + it('blurs account metadata badges in the account selection step', () => { + render( + + ); + + expect(screen.getByText('victim@example.com')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Business')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Workspace 04a0f049')).toHaveClass('blur-[4px]'); + }); + + it('blurs account metadata badges in the variant step', () => { + render( + + ); + + expect(screen.getByText('victim@example.com')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Business')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Workspace 04a0f049')).toHaveClass('blur-[4px]'); + }); +}); 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/hooks/use-logs.test.tsx b/ui/tests/unit/hooks/use-logs.test.tsx new file mode 100644 index 00000000..f34c6b7a --- /dev/null +++ b/ui/tests/unit/hooks/use-logs.test.tsx @@ -0,0 +1,120 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { AllProviders } from '../../setup/test-utils'; +import { useLogsWorkspace } from '@/hooks/use-logs'; +import type { LogsEntry } from '@/lib/api-client'; + +function createJsonResponse(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const entries: LogsEntry[] = [ + { + id: 'http-audit-1', + timestamp: '2026-05-29T00:00:00.000Z', + level: 'error', + source: 'web-server:http', + event: 'request.completed', + message: 'Dashboard request completed', + processId: 1, + runId: null, + requestId: 'dashboard-audit', + }, + { + id: 'ws-audit-1', + timestamp: '2026-05-29T00:00:01.000Z', + level: 'warn', + source: 'web-server:websocket', + event: 'message.invalid', + message: 'WebSocket client sent invalid JSON', + processId: 1, + runId: null, + requestId: 'dashboard-audit', + }, + { + id: 'provider-1', + timestamp: '2026-05-29T00:00:02.000Z', + level: 'info', + source: 'provider:codex', + event: 'request.completed', + message: 'Provider request completed', + processId: 2, + runId: null, + requestId: 'provider-trace', + }, +]; + +function mockLogsApi(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('/api/logs/config')) { + return createJsonResponse({ + logging: { + enabled: true, + level: 'info', + rotate_mb: 10, + retain_days: 7, + redact: true, + live_buffer_size: 150, + }, + }); + } + if (url.startsWith('/api/logs/sources')) { + return createJsonResponse({ + sources: entries.map((entry) => ({ + source: entry.source, + label: entry.source, + kind: 'native', + count: 1, + lastTimestamp: entry.timestamp, + })), + }); + } + if (url.startsWith('/api/logs/entries')) { + return createJsonResponse({ entries }); + } + return createJsonResponse({ error: `Unexpected URL: ${url}` }, 404); + }) + ); +} + +describe('useLogsWorkspace', () => { + it('keeps dashboard audit events visible by default and only hides them on opt-in', async () => { + mockLogsApi(); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useLogsWorkspace(), { wrapper }); + + await waitFor(() => { + expect(result.current.entriesQuery.data?.map((entry) => entry.id)).toEqual([ + 'http-audit-1', + 'ws-audit-1', + 'provider-1', + ]); + }); + + act(() => result.current.setHideDashboardInternals(true)); + + await waitFor(() => { + expect(result.current.entriesQuery.data?.map((entry) => entry.id)).toEqual(['provider-1']); + }); + + act(() => result.current.clearAdvancedFilters()); + + await waitFor(() => { + expect(result.current.entriesQuery.data?.map((entry) => entry.id)).toEqual([ + 'http-audit-1', + 'ws-audit-1', + 'provider-1', + ]); + }); + }); +}); diff --git a/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts b/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts index 3d70a166..b5bdba4b 100644 --- a/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts +++ b/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts @@ -40,6 +40,7 @@ describe('generateConnectionEvents()', () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -78,6 +79,33 @@ describe('generateConnectionEvents()', () => { expect(emails.has('older@example.com')).toBe(true); }); + it('bases shared timelines on the latest actual lastUsedAt instead of current time', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-29T00:00:00.000Z')); + + const latestActualLastUsedAt = new Date('2021-01-01T00:00:00.000Z').getTime(); + const accounts: AccountData[] = [ + makeAccount({ + id: 'old2020', + email: 'old2020@example.com', + successCount: 1, + lastUsedAt: '2020-01-01T00:00:00.000Z', + }), + makeAccount({ + id: 'old2021', + email: 'old2021@example.com', + successCount: 1, + lastUsedAt: '2021-01-01T00:00:00.000Z', + }), + ]; + + const events = generateConnectionEvents(accounts); + const mostRecentTimestamp = Math.max(...events.map((event) => event.timestamp.getTime())); + + expect(mostRecentTimestamp).toBe(latestActualLastUsedAt); + expect(mostRecentTimestamp).toBeLessThan(Date.now() - 365 * 24 * 60 * 60 * 1000); + }); + it('returns at most MAX_TIMELINE_EVENTS events (cap respected by caller slice)', () => { // generateConnectionEvents returns all events unsorted-by-cap; cap is applied by caller. // But we verify total output does not exceed successCount + failureCount across accounts. diff --git a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx index 64d88fd0..dce54089 100644 --- a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx +++ b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx @@ -146,4 +146,42 @@ describe('AccountSurfaceCard', () => { expect(screen.queryByText('Pers')).not.toBeInTheDocument(); expect(screen.queryByTitle('Free')).not.toBeInTheDocument(); }); + + it('blurs detailed account identity metadata when privacy mode is enabled', () => { + render( + + ); + + expect(screen.getByText('victim@example.com')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Business')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Workspace 04a0f049')).toHaveClass('blur-[4px]'); + }); + + it('blurs compact account identity metadata when privacy mode is enabled', () => { + render( + + ); + + expect(screen.getByText('victim@example.com')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Biz')).toHaveClass('blur-[4px]'); + expect(screen.getByText('04a0f049')).toHaveClass('blur-[4px]'); + }); }); 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/account-visual-groups.test.ts b/ui/tests/unit/ui/lib/account-visual-groups.test.ts index cba2b9c2..eae7c8da 100644 --- a/ui/tests/unit/ui/lib/account-visual-groups.test.ts +++ b/ui/tests/unit/ui/lib/account-visual-groups.test.ts @@ -29,17 +29,14 @@ describe('buildAccountVisualGroups', () => { ]); expect(groups).toHaveLength(1); - expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual([ - 'business', - 'personal', - ]); + expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual(['business', 'free']); expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([ 'Business · Workspace 04a0f049', - 'Personal · Free', + 'Free', ]); expect(groups[0]?.variants?.map((variant) => variant.compactDetailLabel)).toEqual([ '04a0f049', - 'Free', + null, ]); expect(groups[0]?.memberIds).toEqual([ 'kaidu.kd@gmail.com#04a0f049-team', @@ -47,7 +44,7 @@ describe('buildAccountVisualGroups', () => { ]); }); - it('keeps multiple personal codex plans distinct inside the same grouped card', () => { + it('keeps personal and free codex plans distinct inside the same grouped card', () => { const groups = buildAccountVisualGroups([ makeAccount({ id: 'kaidu.kd@gmail.com#plus', @@ -61,8 +58,8 @@ describe('buildAccountVisualGroups', () => { expect(groups).toHaveLength(1); expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([ - 'Personal · Free', 'Personal · Plus', + 'Free', ]); }); }); diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index ecc472ff..d77a7a4d 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -16,12 +16,14 @@ describe('claude preset utils', () => { vi.restoreAllMocks(); }); - it('keeps the claude catalog default on Sonnet 4.6 while exposing Opus 4.7', () => { + it('keeps the claude catalog default on Sonnet 4.6 while exposing Opus 4.7 and 4.8', () => { const claudeCatalog = MODEL_CATALOGS.claude; + const ids = claudeCatalog.models.map((model) => model.id); expect(claudeCatalog.defaultModel).toBe('claude-sonnet-4-6'); - expect(claudeCatalog.models.map((model) => model.id)).toContain('claude-opus-4-7'); - expect(claudeCatalog.models.map((model) => model.id)).toContain('claude-sonnet-4-6'); + expect(ids).toContain('claude-opus-4-8'); + expect(ids).toContain('claude-opus-4-7'); + expect(ids).toContain('claude-sonnet-4-6'); }); it('applies the default claude preset from the catalog default model mapping', async () => { @@ -116,6 +118,55 @@ describe('claude preset utils', () => { ); }); + it('builds UI catalogs with a visible default when the live default is stale', () => { + const liveCatalogs = { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-2.5-pro', + models: [{ id: 'gemini-live-only', name: 'Gemini Live Only' }], + }, + }; + + const catalogs = buildUiCatalogs(liveCatalogs); + + expect(catalogs.gemini?.defaultModel).toBe('gemini-live-only'); + expect(catalogs.gemini?.models.map((model) => model.id)).toContain( + catalogs.gemini?.defaultModel + ); + }); + + it('uses the selected visible model for quick setup fallback mappings', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ apiKey: { value: 'managed-key' } }), + }) + .mockResolvedValueOnce({ ok: true }); + + vi.stubGlobal('fetch', fetchMock); + + const result = await applyDefaultPreset('gemini', undefined, { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-2.5-pro', + models: [{ id: 'gemini-live-only', name: 'Gemini Live Only' }], + }); + + expect(result).toEqual({ success: true, presetName: 'Gemini Live Only' }); + + const [, requestInit] = fetchMock.mock.calls[1] ?? []; + const body = JSON.parse(String(requestInit?.body)); + + expect(body.settings.env).toMatchObject({ + ANTHROPIC_MODEL: 'gemini-live-only', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-live-only', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-live-only', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-live-only', + }); + }); + it('keeps Gemini presets on 3.1 Pro while resolving 3/3.1 alias variants', () => { const geminiCatalog = MODEL_CATALOGS.gemini; const latestPro = geminiCatalog.models.find((model) => model.id === 'gemini-3.1-pro-preview'); 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);